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',