diff --git a/src/screens/ContactsScreen.tsx b/src/screens/ContactsScreen.tsx index 74eb72ec..140c02ab 100644 --- a/src/screens/ContactsScreen.tsx +++ b/src/screens/ContactsScreen.tsx @@ -50,8 +50,8 @@ interface Section { data: ContactCard[]; } -function groupContacts(contacts: ContactCard[], byLastName: boolean): Section[] { - const sorted = sortContactsByName(contacts, byLastName); +/** Letter sections for contacts already in `sortContactsByName` order. */ +function groupContacts(sorted: ContactCard[], byLastName: boolean): Section[] { const groups: Record = {}; for (const c of sorted) { // Sections follow the sort name, so with "sort by last name" Alice Smith @@ -137,7 +137,7 @@ export default function ContactsScreen() { const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]); const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]); - const visible = React.useMemo(() => { + const categoryContacts = React.useMemo(() => { let filtered: ContactCard[]; switch (selectedCategory.type) { case 'all': @@ -162,14 +162,25 @@ export default function ContactsScreen() { default: filtered = individuals; } - if (!searchQuery) return filtered; - return filtered.filter((c) => matchesContactSearch(c, searchQuery)); - }, [contacts, individuals, selectedCategory, searchQuery]); + return filtered; + }, [contacts, individuals, selectedCategory]); + + // Sort once per list or order change; search only filters the sorted list, + // so typing never re-sorts the address book (PF10). + const sorted = React.useMemo( + () => sortContactsByName(categoryContacts, sortByLastName), + [categoryContacts, sortByLastName], + ); + + const visible = React.useMemo(() => { + if (!searchQuery) return sorted; + return sorted.filter((c) => matchesContactSearch(c, searchQuery)); + }, [sorted, searchQuery]); const sections = React.useMemo(() => { if (groupByLetter) return groupContacts(visible, sortByLastName); // Flat list - single unnamed section keeps SectionList rendering simple. - return [{ title: '', data: sortContactsByName(visible, sortByLastName) }]; + return [{ title: '', data: visible }]; }, [visible, groupByLetter, sortByLastName]); const selectionMode = selection.size > 0; diff --git a/src/stores/__tests__/contacts-store.test.ts b/src/stores/__tests__/contacts-store.test.ts index 268452fc..092d3c7f 100644 --- a/src/stores/__tests__/contacts-store.test.ts +++ b/src/stores/__tests__/contacts-store.test.ts @@ -526,4 +526,10 @@ describe('sortContactsByName (#963)', () => { sortContactsByName(input, true); expect(input.map((c) => c.id)).toEqual(['zoe', 'alice', 'bob', 'acme']); }); + + it('compares case- and accent-insensitively, keeping ties in list order (PF10)', () => { + const named = (id: string, full: string) => card(id, { name: { full } }); + const list = [named('z', 'Zed'), named('e2', 'emile'), named('e1', 'Émile'), named('a', 'Anna')]; + expect(sortContactsByName(list, false).map((c) => c.id)).toEqual(['a', 'e2', 'e1', 'z']); + }); }); diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts index 7ae71d83..6045d941 100644 --- a/src/stores/contacts-store.ts +++ b/src/stores/contacts-store.ts @@ -928,9 +928,14 @@ export function sortContactsByDisplayName(contacts: ContactCard[]): ContactCard[ return sortContactsByName(contacts, false); } +// One collator for every contact sort: `localeCompare` with options sets one +// up per comparison, which made a large address book slow to sort (PF10). +const contactNameCollator = new Intl.Collator(undefined, { sensitivity: 'base' }); + /** 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) => - getContactSortName(a, byLastName).localeCompare(getContactSortName(b, byLastName), undefined, { sensitivity: 'base' }), - ); + // Work out each sort name once instead of twice per comparison. + const keyed = contacts.map((contact) => ({ contact, key: getContactSortName(contact, byLastName) })); + keyed.sort((a, b) => contactNameCollator.compare(a.key, b.key)); + return keyed.map((entry) => entry.contact); }