From 182774884ef55b0a87cc58c65c25de91c3f9e3db Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:51:21 +0200
Subject: [PATCH] feat: add a setting to sort contacts by last name #963
Port of the webmail's `sortContactsByLastName` (fb24ef5d). With the new
"Sort by last name" switch in Settings > Contacts, the contact list is
ordered and letter-grouped by "Surname, Given Middle", so family members
sit together. Cards without a structured surname fall back to the last
word of `name.full`; organizations, nicknames and bare addresses keep
their display name. The setting defaults to off, is persisted, and is
exported and imported under the webmail's key.
---
src/components/settings/ContactsSettings.tsx | 17 ++++++++
src/lib/__tests__/contact-utils.test.ts | 46 ++++++++++++++++++++
src/lib/contact-utils.ts | 29 ++++++++++++
src/screens/ContactsScreen.tsx | 19 ++++----
src/stores/__tests__/contacts-store.test.ts | 26 +++++++++++
src/stores/contacts-store.ts | 8 +++-
src/stores/settings-store.ts | 4 ++
7 files changed, 140 insertions(+), 9 deletions(-)
diff --git a/src/components/settings/ContactsSettings.tsx b/src/components/settings/ContactsSettings.tsx
index 3076b4b7..ea216fb5 100644
--- a/src/components/settings/ContactsSettings.tsx
+++ b/src/components/settings/ContactsSettings.tsx
@@ -10,6 +10,7 @@ import Button from '../Button';
import Dialog from '../Dialog';
import { ContactImportSheet, AddressBookPickerSheet } from '../contacts';
import { useSettingsStore } from '../../stores/settings-store';
+import { useLocaleStore } from '../../stores/locale-store';
import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store';
import { contactsToVCard } from '../../lib/vcard';
import { isGroup } from '../../lib/contact-utils';
@@ -21,6 +22,9 @@ export function ContactsSettings() {
const styles = React.useMemo(() => makeStyles(c), [c]);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
+ const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName);
+ const updateSetting = useSettingsStore((s) => s.updateSetting);
+ const t = useLocaleStore((s) => s.t);
const contacts = useContactsStore((s) => s.contacts);
const addressBooks = useContactsStore((s) => s.addressBooks);
// Derive in a memo from stable store fields. Subscribing with
@@ -154,6 +158,19 @@ export function ContactsSettings() {
+
+ updateSetting('sortContactsByLastName', checked)}
+ />
+
+
{
expect(getCustomFullName(org, 'Acme Inc')).toBe('Acme Corp');
});
});
+
+describe('getContactSortName (#963)', () => {
+ const make = (overrides: Partial): ContactCard => ({ id: 'c1', addressBookIds: {}, ...overrides });
+ const structured = make({
+ name: {
+ components: [
+ { kind: 'given', value: 'Alice' },
+ { kind: 'middle', value: 'Jane' },
+ { kind: 'surname', value: 'Smith' },
+ ],
+ isOrdered: true,
+ },
+ });
+
+ it('returns the display name when not sorting by last name', () => {
+ expect(getContactSortName(structured, false)).toBe('Alice Smith');
+ });
+
+ it('leads with the surname when sorting by last name', () => {
+ expect(getContactSortName(structured, true)).toBe('Smith, Alice Jane');
+ });
+
+ it('returns just the surname when no given name exists', () => {
+ const c = make({ name: { components: [{ kind: 'surname', value: 'Smith' }], isOrdered: true } });
+ expect(getContactSortName(c, true)).toBe('Smith');
+ });
+
+ it('uses the last word of name.full when there are no components', () => {
+ expect(getContactSortName(make({ name: { full: 'Jean Pierre Dupont' } }), true)).toBe('Dupont, Jean Pierre');
+ });
+
+ it('keeps a single-word name.full as-is', () => {
+ expect(getContactSortName(make({ name: { full: 'Madonna' } }), true)).toBe('Madonna');
+ });
+
+ it('does not split organization or email fallbacks into a surname', () => {
+ expect(getContactSortName(make({ organizations: { o1: { name: 'Acme Corp' } } }), true)).toBe('Acme Corp');
+ expect(getContactSortName(make({ emails: { e0: { address: 'someone@example.com' } } }), true)).toBe('someone@example.com');
+ });
+
+ it('falls back to the display name for a given-only name (e.g. a group)', () => {
+ const c = make({ kind: 'group', name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true } });
+ expect(getContactSortName(c, true)).toBe('Team');
+ });
+});
diff --git a/src/lib/contact-utils.ts b/src/lib/contact-utils.ts
index 09d8bf04..526ec554 100644
--- a/src/lib/contact-utils.ts
+++ b/src/lib/contact-utils.ts
@@ -34,6 +34,35 @@ export function getContactDisplayName(contact: ContactCard): string {
return '';
}
+// Name used to order (and letter-group) the contact list. With `byLastName`
+// the surname leads ("Smith, Alice") so family members sit together (#963).
+// Contacts without a structured surname fall back to the last word of
+// `name.full`; everything else (nickname, org, email) keeps the display name.
+// Same rules as the webmail's getContactSortName.
+export function getContactSortName(contact: ContactCard, byLastName: boolean): string {
+ const display = getContactDisplayName(contact);
+ if (!byLastName) return display;
+ const components = contact.name?.components;
+ if (components && components.length > 0) {
+ const pick = (...kinds: string[]) =>
+ components.filter((c) => kinds.includes(c.kind) && c.value).map((c) => c.value).join(' ');
+ const surname = pick('surname', 'surname2');
+ if (surname) {
+ const rest = pick('given', 'given2', 'middle', 'additional');
+ return rest ? `${surname}, ${rest}` : surname;
+ }
+ }
+ const full = contact.name?.full;
+ if (full && display === full) {
+ const words = full.trim().split(/\s+/);
+ if (words.length > 1) {
+ const last = words[words.length - 1];
+ return `${last}, ${words.slice(0, -1).join(' ')}`;
+ }
+ }
+ return display;
+}
+
export function getContactPrimaryEmail(contact: ContactCard): string {
if (!contact.emails) return '';
const entries = Object.values(contact.emails);
diff --git a/src/screens/ContactsScreen.tsx b/src/screens/ContactsScreen.tsx
index 69c045da..74eb72ec 100644
--- a/src/screens/ContactsScreen.tsx
+++ b/src/screens/ContactsScreen.tsx
@@ -24,12 +24,12 @@ import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types';
import {
useContactsStore,
- sortContactsByDisplayName,
+ sortContactsByName,
selectGroupMembers,
selectUncategorized,
type ContactCategory,
} from '../stores/contacts-store';
-import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
+import { getContactDisplayName, getContactSortName, isGroup, matchesContactSearch } from '../lib/contact-utils';
import { contactsToVCard } from '../lib/vcard';
import {
ContactListRow,
@@ -50,11 +50,13 @@ interface Section {
data: ContactCard[];
}
-function groupContacts(contacts: ContactCard[]): Section[] {
- const sorted = sortContactsByDisplayName(contacts);
+function groupContacts(contacts: ContactCard[], byLastName: boolean): Section[] {
+ const sorted = sortContactsByName(contacts, byLastName);
const groups: Record = {};
for (const c of sorted) {
- const name = getContactDisplayName(c).trim();
+ // Sections follow the sort name, so with "sort by last name" Alice Smith
+ // files under S.
+ const name = getContactSortName(c, byLastName).trim();
// Any letter (Ä, É, Ł, 王…) gets its own section like the webmail; only
// digits and symbols fall into "#". Hermes supports Unicode property escapes.
const first = Array.from(name)[0] || '#';
@@ -110,6 +112,7 @@ export default function ContactsScreen() {
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const getDefaultAddressBookId = useContactsStore((s) => s.getDefaultAddressBookId);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
+ const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName);
const [searchQuery, setSearchQuery] = React.useState('');
const [searchActive, setSearchActive] = React.useState(false);
@@ -164,10 +167,10 @@ export default function ContactsScreen() {
}, [contacts, individuals, selectedCategory, searchQuery]);
const sections = React.useMemo(() => {
- if (groupByLetter) return groupContacts(visible);
+ if (groupByLetter) return groupContacts(visible, sortByLastName);
// Flat list - single unnamed section keeps SectionList rendering simple.
- return [{ title: '', data: sortContactsByDisplayName(visible) }];
- }, [visible, groupByLetter]);
+ return [{ title: '', data: sortContactsByName(visible, sortByLastName) }];
+ }, [visible, groupByLetter, sortByLastName]);
const selectionMode = selection.size > 0;
const toggleSelect = (id: string) => {
diff --git a/src/stores/__tests__/contacts-store.test.ts b/src/stores/__tests__/contacts-store.test.ts
index f6a3f0af..268452fc 100644
--- a/src/stores/__tests__/contacts-store.test.ts
+++ b/src/stores/__tests__/contacts-store.test.ts
@@ -51,6 +51,7 @@ import {
normalizeSuggestions,
selectVisibleContacts,
selectGroupMembers,
+ sortContactsByName,
} from '../contacts-store';
import type { ContactCard } from '../../api/types';
@@ -501,3 +502,28 @@ describe('helpers', () => {
expect(selectGroupMembers({ contacts }, 'g1').map((c) => c.id)).toEqual(['acc-team:c9']);
});
});
+
+describe('sortContactsByName (#963)', () => {
+ const person = (id: string, given: string, surname: string) =>
+ card(id, { name: { components: [{ kind: 'given', value: given }, { kind: 'surname', value: surname }], isOrdered: true } });
+ const contacts = [
+ person('zoe', 'Zoe', 'Adams'),
+ person('alice', 'Alice', 'Smith'),
+ person('bob', 'bob', 'Smith'),
+ card('acme', { organizations: { o1: { name: 'Acme Corp' } } }),
+ ];
+
+ it('orders by display name by default', () => {
+ expect(sortContactsByName(contacts, false).map((c) => c.id)).toEqual(['acme', 'alice', 'bob', 'zoe']);
+ });
+
+ it('puts the surname first with byLastName, keeping families together', () => {
+ expect(sortContactsByName(contacts, true).map((c) => c.id)).toEqual(['acme', 'zoe', 'alice', 'bob']);
+ });
+
+ it('does not reorder its input', () => {
+ const input = [...contacts];
+ sortContactsByName(input, true);
+ expect(input.map((c) => c.id)).toEqual(['zoe', 'alice', 'bob', 'acme']);
+ });
+});
diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts
index a0767a78..7ae71d83 100644
--- a/src/stores/contacts-store.ts
+++ b/src/stores/contacts-store.ts
@@ -28,6 +28,7 @@ import {
getContactDisplayName,
getContactKeywords,
getContactPrimaryEmail,
+ getContactSortName,
isGroup,
matchesContactSearch,
} from '../lib/contact-utils';
@@ -924,7 +925,12 @@ export function selectVisibleContacts(
}
export function sortContactsByDisplayName(contacts: ContactCard[]): ContactCard[] {
+ return sortContactsByName(contacts, false);
+}
+
+/** Contact list order: by display name, or surname first with `byLastName` (#963). */
+export function sortContactsByName(contacts: ContactCard[], byLastName: boolean): ContactCard[] {
return [...contacts].sort((a, b) =>
- getContactDisplayName(a).localeCompare(getContactDisplayName(b), undefined, { sensitivity: 'base' }),
+ getContactSortName(a, byLastName).localeCompare(getContactSortName(b, byLastName), undefined, { sensitivity: 'base' }),
);
}
diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts
index 9fc4b744..163d1d4f 100644
--- a/src/stores/settings-store.ts
+++ b/src/stores/settings-store.ts
@@ -139,6 +139,9 @@ interface PersistedSettings {
// Contacts
groupContactsByLetter: boolean;
+ // Sort (and group) the contact list by surname instead of given name so
+ // family members sit together (#963).
+ sortContactsByLastName: boolean;
// Appearance
theme: ThemeMode;
@@ -292,6 +295,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
hideInlineImageAttachments: true,
groupContactsByLetter: true,
+ sortContactsByLastName: false,
theme: 'system',
fontSize: 'medium',