mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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() {
|
||||
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
|
||||
</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
|
||||
label="Import contacts"
|
||||
description="Import contacts from a vCard (.vcf) file with duplicate detection."
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
deriveFullName,
|
||||
getContactDisplayName,
|
||||
getContactSortName,
|
||||
getCustomFullName,
|
||||
getContactPhotoUri,
|
||||
normalizeContactPhotoUri,
|
||||
@@ -122,3 +123,48 @@ describe('name.full for the vCard FN (#430)', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, ContactCard[]> = {};
|
||||
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<Section[]>(() => {
|
||||
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) => {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user