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.
This commit is contained in:
Linus Rath
2026-09-24 19:03:34 +02:00
parent a734d0ade1
commit 182774884e
7 changed files with 140 additions and 9 deletions
@@ -10,6 +10,7 @@ import Button from '../Button';
import Dialog from '../Dialog'; import Dialog from '../Dialog';
import { ContactImportSheet, AddressBookPickerSheet } from '../contacts'; import { ContactImportSheet, AddressBookPickerSheet } from '../contacts';
import { useSettingsStore } from '../../stores/settings-store'; import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store'; import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store';
import { contactsToVCard } from '../../lib/vcard'; import { contactsToVCard } from '../../lib/vcard';
import { isGroup } from '../../lib/contact-utils'; import { isGroup } from '../../lib/contact-utils';
@@ -21,6 +22,9 @@ export function ContactsSettings() {
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter); const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter); 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 contacts = useContactsStore((s) => s.contacts);
const addressBooks = useContactsStore((s) => s.addressBooks); const addressBooks = useContactsStore((s) => s.addressBooks);
// Derive in a memo from stable store fields. Subscribing with // Derive in a memo from stable store fields. Subscribing with
@@ -154,6 +158,19 @@ export function ContactsSettings() {
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} /> <ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
</SettingItem> </SettingItem>
<SettingItem
label={t('settings.contacts.sort_by_last_name_label', 'Sort by last name')}
description={t(
'settings.contacts.sort_by_last_name_description',
'Order the contact list by surname so family members appear together',
)}
>
<ToggleSwitch
checked={sortByLastName}
onChange={(checked) => updateSetting('sortContactsByLastName', checked)}
/>
</SettingItem>
<SettingItem <SettingItem
label="Import contacts" label="Import contacts"
description="Import contacts from a vCard (.vcf) file with duplicate detection." description="Import contacts from a vCard (.vcf) file with duplicate detection."
+46
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import { import {
deriveFullName, deriveFullName,
getContactDisplayName, getContactDisplayName,
getContactSortName,
getCustomFullName, getCustomFullName,
getContactPhotoUri, getContactPhotoUri,
normalizeContactPhotoUri, normalizeContactPhotoUri,
@@ -122,3 +123,48 @@ describe('name.full for the vCard FN (#430)', () => {
expect(getCustomFullName(org, 'Acme Inc')).toBe('Acme Corp'); expect(getCustomFullName(org, 'Acme Inc')).toBe('Acme Corp');
}); });
}); });
describe('getContactSortName (#963)', () => {
const make = (overrides: Partial<ContactCard>): 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');
});
});
+29
View File
@@ -34,6 +34,35 @@ export function getContactDisplayName(contact: ContactCard): string {
return ''; 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 { export function getContactPrimaryEmail(contact: ContactCard): string {
if (!contact.emails) return ''; if (!contact.emails) return '';
const entries = Object.values(contact.emails); const entries = Object.values(contact.emails);
+11 -8
View File
@@ -24,12 +24,12 @@ import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types'; import type { ContactCard } from '../api/types';
import { import {
useContactsStore, useContactsStore,
sortContactsByDisplayName, sortContactsByName,
selectGroupMembers, selectGroupMembers,
selectUncategorized, selectUncategorized,
type ContactCategory, type ContactCategory,
} from '../stores/contacts-store'; } 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 { contactsToVCard } from '../lib/vcard';
import { import {
ContactListRow, ContactListRow,
@@ -50,11 +50,13 @@ interface Section {
data: ContactCard[]; data: ContactCard[];
} }
function groupContacts(contacts: ContactCard[]): Section[] { function groupContacts(contacts: ContactCard[], byLastName: boolean): Section[] {
const sorted = sortContactsByDisplayName(contacts); const sorted = sortContactsByName(contacts, byLastName);
const groups: Record<string, ContactCard[]> = {}; const groups: Record<string, ContactCard[]> = {};
for (const c of sorted) { 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 // Any letter (Ä, É, Ł, 王…) gets its own section like the webmail; only
// digits and symbols fall into "#". Hermes supports Unicode property escapes. // digits and symbols fall into "#". Hermes supports Unicode property escapes.
const first = Array.from(name)[0] || '#'; const first = Array.from(name)[0] || '#';
@@ -110,6 +112,7 @@ export default function ContactsScreen() {
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory); const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const getDefaultAddressBookId = useContactsStore((s) => s.getDefaultAddressBookId); const getDefaultAddressBookId = useContactsStore((s) => s.getDefaultAddressBookId);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter); const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName);
const [searchQuery, setSearchQuery] = React.useState(''); const [searchQuery, setSearchQuery] = React.useState('');
const [searchActive, setSearchActive] = React.useState(false); const [searchActive, setSearchActive] = React.useState(false);
@@ -164,10 +167,10 @@ export default function ContactsScreen() {
}, [contacts, individuals, selectedCategory, searchQuery]); }, [contacts, individuals, selectedCategory, searchQuery]);
const sections = React.useMemo<Section[]>(() => { const sections = React.useMemo<Section[]>(() => {
if (groupByLetter) return groupContacts(visible); if (groupByLetter) return groupContacts(visible, sortByLastName);
// Flat list - single unnamed section keeps SectionList rendering simple. // Flat list - single unnamed section keeps SectionList rendering simple.
return [{ title: '', data: sortContactsByDisplayName(visible) }]; return [{ title: '', data: sortContactsByName(visible, sortByLastName) }];
}, [visible, groupByLetter]); }, [visible, groupByLetter, sortByLastName]);
const selectionMode = selection.size > 0; const selectionMode = selection.size > 0;
const toggleSelect = (id: string) => { const toggleSelect = (id: string) => {
@@ -51,6 +51,7 @@ import {
normalizeSuggestions, normalizeSuggestions,
selectVisibleContacts, selectVisibleContacts,
selectGroupMembers, selectGroupMembers,
sortContactsByName,
} from '../contacts-store'; } from '../contacts-store';
import type { ContactCard } from '../../api/types'; import type { ContactCard } from '../../api/types';
@@ -501,3 +502,28 @@ describe('helpers', () => {
expect(selectGroupMembers({ contacts }, 'g1').map((c) => c.id)).toEqual(['acc-team:c9']); 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']);
});
});
+7 -1
View File
@@ -28,6 +28,7 @@ import {
getContactDisplayName, getContactDisplayName,
getContactKeywords, getContactKeywords,
getContactPrimaryEmail, getContactPrimaryEmail,
getContactSortName,
isGroup, isGroup,
matchesContactSearch, matchesContactSearch,
} from '../lib/contact-utils'; } from '../lib/contact-utils';
@@ -924,7 +925,12 @@ export function selectVisibleContacts(
} }
export function sortContactsByDisplayName(contacts: ContactCard[]): ContactCard[] { 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) => return [...contacts].sort((a, b) =>
getContactDisplayName(a).localeCompare(getContactDisplayName(b), undefined, { sensitivity: 'base' }), getContactSortName(a, byLastName).localeCompare(getContactSortName(b, byLastName), undefined, { sensitivity: 'base' }),
); );
} }
+4
View File
@@ -139,6 +139,9 @@ interface PersistedSettings {
// Contacts // Contacts
groupContactsByLetter: boolean; groupContactsByLetter: boolean;
// Sort (and group) the contact list by surname instead of given name so
// family members sit together (#963).
sortContactsByLastName: boolean;
// Appearance // Appearance
theme: ThemeMode; theme: ThemeMode;
@@ -292,6 +295,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
hideInlineImageAttachments: true, hideInlineImageAttachments: true,
groupContactsByLetter: true, groupContactsByLetter: true,
sortContactsByLastName: false,
theme: 'system', theme: 'system',
fontSize: 'medium', fontSize: 'medium',