From fb24ef5d24bdd9b4b70d3b91c59ff7a93c90b827 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:47:33 +0200 Subject: [PATCH] feat: sort contacts by last name #963 --- .../contacts/__tests__/contact-list.test.tsx | 41 ++++++++++++- components/contacts/contact-list.tsx | 41 ++++++++++--- components/settings/contacts-settings.tsx | 11 ++++ locales/ar/common.json | 7 ++- locales/ca/common.json | 7 ++- locales/cs/common.json | 7 ++- locales/da/common.json | 7 ++- locales/de/common.json | 9 ++- locales/en/common.json | 7 ++- locales/es/common.json | 9 ++- locales/fa/common.json | 7 ++- locales/fr/common.json | 9 ++- locales/he/common.json | 7 ++- locales/hu/common.json | 7 ++- locales/it/common.json | 9 ++- locales/ja/common.json | 9 ++- locales/ko/common.json | 9 ++- locales/lv/common.json | 9 ++- locales/mn/common.json | 7 ++- locales/nb/common.json | 7 ++- locales/nl/common.json | 9 ++- locales/pl/common.json | 9 ++- locales/pt/common.json | 9 ++- locales/ro/common.json | 7 ++- locales/ru/common.json | 9 ++- locales/sk/common.json | 7 ++- locales/tr/common.json | 7 ++- locales/uk/common.json | 9 ++- locales/zh-TW/common.json | 7 ++- locales/zh/common.json | 9 ++- stores/__tests__/contact-sort-name.test.ts | 57 +++++++++++++++++++ stores/contact-store.ts | 28 +++++++++ stores/settings-store.ts | 5 ++ 33 files changed, 348 insertions(+), 50 deletions(-) create mode 100644 stores/__tests__/contact-sort-name.test.ts diff --git a/components/contacts/__tests__/contact-list.test.tsx b/components/contacts/__tests__/contact-list.test.tsx index c70b16856..63975ce46 100644 --- a/components/contacts/__tests__/contact-list.test.tsx +++ b/components/contacts/__tests__/contact-list.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { ContactList } from '../contact-list'; +import { useSettingsStore } from '@/stores/settings-store'; import type { ContactCard } from '@/lib/jmap/types'; function makeContact(overrides: Partial & { id: string }): ContactCard { @@ -78,4 +79,42 @@ describe('ContactList', () => { expect(screen.getByText('bulk.export')).toBeInTheDocument(); }); + describe('sort order (#963)', () => { + const carol = makeContact({ + id: '4', + name: { components: [{ kind: 'given', value: 'Carol' }, { kind: 'surname', value: 'Smith' }], isOrdered: true }, + }); + const family = [alice, bob, carol]; + const NAME = /^(Alice Smith|Bob Jones|Carol Smith)$/; + const renderedNames = () => screen.getAllByText(NAME).map((el) => el.textContent); + + afterEach(() => { + useSettingsStore.setState({ sortContactsByLastName: false, groupContactsByLetter: true }); + }); + + it('sorts by display name by default', () => { + render(); + expect(renderedNames()).toEqual(['Alice Smith', 'Bob Jones', 'Carol Smith']); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('C')).toBeInTheDocument(); + }); + + it('groups family members together when sorting by last name', () => { + useSettingsStore.setState({ sortContactsByLastName: true }); + render(); + expect(renderedNames()).toEqual(['Bob Jones', 'Alice Smith', 'Carol Smith']); + // Letter headers follow the surname, not the given name. + expect(screen.getByText('J')).toBeInTheDocument(); + expect(screen.getByText('S')).toBeInTheDocument(); + expect(screen.queryByText('A')).not.toBeInTheDocument(); + }); + + it('still matches the search query against the display name', () => { + useSettingsStore.setState({ sortContactsByLastName: true }); + render(); + expect(renderedNames()).toEqual(['Alice Smith']); + }); + }); + }); diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 0d9f23c77..b4e1fc0ed 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { useTranslations, useLocale } from "next-intl"; -import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react"; +import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, ArrowDownAZ } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { ContactListItem } from "./contact-list-item"; @@ -10,7 +10,7 @@ import { ContactContextMenu } from "./contact-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { cn } from "@/lib/utils"; import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types"; -import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store"; +import { getContactDisplayName, getContactPhotoUri, getContactSortName } from "@/stores/contact-store"; import { useSettingsStore } from "@/stores/settings-store"; type TriState = boolean | null; @@ -141,6 +141,8 @@ export function ContactList({ const locale = useLocale(); const density = useSettingsStore((state) => state.density); const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter); + const sortByLastName = useSettingsStore((state) => state.sortContactsByLastName); + const updateSetting = useSettingsStore((state) => state.updateSetting); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); const [filtersOpen, setFiltersOpen] = useState(false); const [filters, setFilters] = useState(EMPTY_FILTERS); @@ -229,21 +231,22 @@ export function ContactList({ const sorted = useMemo(() => { return [...filtered].sort((a, b) => { - const nameA = getContactDisplayName(a).toLowerCase(); - const nameB = getContactDisplayName(b).toLowerCase(); + const nameA = getContactSortName(a, sortByLastName).toLowerCase(); + const nameB = getContactSortName(b, sortByLastName).toLowerCase(); return nameA.localeCompare(nameB); }); - }, [filtered]); + }, [filtered, sortByLastName]); const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]); - // Group sorted contacts by first letter of display name. Non-letter - // starters (digits, symbols, empty) collect under "#" which sorts last. + // Group sorted contacts by first letter of the sort name (display name, or + // surname when sorting by last name). Non-letter starters (digits, symbols, + // empty) collect under "#" which sorts last. const groupedSections = useMemo(() => { const collator = new Intl.Collator(locale, { sensitivity: "base" }); const groups = new Map(); for (const contact of sorted) { - const name = getContactDisplayName(contact).trim(); + const name = getContactSortName(contact, sortByLastName).trim(); const first = name.charAt(0); const letter = first && first.toLocaleUpperCase(locale).match(/\p{L}/u) ? first.toLocaleUpperCase(locale) @@ -259,7 +262,7 @@ export function ContactList({ return collator.compare(a, b); }) .map(([letter, items]) => ({ letter, items })); - }, [sorted, locale]); + }, [sorted, locale, sortByLastName]); const hasSelection = selectedContactIds.size > 0; const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id)); @@ -451,6 +454,26 @@ export function ContactList({ onClick={() => setFilters((f) => ({ ...f, hasPhoto: cycleTri(f.hasPhoto) }))} /> + + {/* Sort order (persisted setting, also on /settings/contacts) */} +
+ + + {t("filters.sort_by")} + + updateSetting("sortContactsByLastName", false)} + /> + updateSetting("sortContactsByLastName", true)} + /> +
)} diff --git a/components/settings/contacts-settings.tsx b/components/settings/contacts-settings.tsx index 8661979f9..092328b8a 100644 --- a/components/settings/contacts-settings.tsx +++ b/components/settings/contacts-settings.tsx @@ -22,6 +22,7 @@ export function ContactsSettings() { importContacts, } = useContactStore(); const groupContactsByLetter = useSettingsStore((s) => s.groupContactsByLetter); + const sortContactsByLastName = useSettingsStore((s) => s.sortContactsByLastName); const updateSetting = useSettingsStore((s) => s.updateSetting); const [showImport, setShowImport] = useState(false); @@ -68,6 +69,16 @@ export function ContactsSettings() { /> + + updateSetting("sortContactsByLastName", checked)} + /> + + ): ContactCard => ({ + id: 'c1', + addressBookIds: {}, + ...overrides, +}); + +const structured = make({ + name: { + components: [ + { kind: 'given', value: 'Alice' }, + { kind: 'middle', value: 'Jane' }, + { kind: 'surname', value: 'Smith' }, + ], + isOrdered: true, + }, +}); + +describe('getContactSortName (#963)', () => { + 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', () => { + const c = make({ name: { full: 'Jean Pierre Dupont' } }); + expect(getContactSortName(c, true)).toBe('Dupont, Jean Pierre'); + }); + + it('keeps a single-word name.full as-is', () => { + const c = make({ name: { full: 'Madonna' } }); + expect(getContactSortName(c, true)).toBe('Madonna'); + }); + + it('does not split organization or email fallbacks into a surname', () => { + const org = make({ organizations: { o1: { name: 'Acme Corp' } } }); + expect(getContactSortName(org, true)).toBe('Acme Corp'); + const mail = make({ emails: { e0: { address: 'someone@example.com' } } }); + expect(getContactSortName(mail, 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/stores/contact-store.ts b/stores/contact-store.ts index cbeef8e69..77e2e51f4 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -149,6 +149,34 @@ 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. +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 ''; return Object.values(contact.emails)[0]?.address || ''; diff --git a/stores/settings-store.ts b/stores/settings-store.ts index c4fda83b6..75d8372c6 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -388,6 +388,9 @@ interface SettingsState { // Contacts Display groupContactsByLetter: boolean; + // Sort (and group) the contact list by surname instead of given name so + // family members sit together (#963). + sortContactsByLastName: boolean; // Email Notifications emailNotificationsEnabled: boolean; @@ -620,6 +623,7 @@ const DEFAULT_SETTINGS = { // Contacts Display groupContactsByLetter: true, + sortContactsByLastName: false, // Email Notifications emailNotificationsEnabled: true, @@ -835,6 +839,7 @@ export const useSettingsStore = create()( birthdayCalendarColor: state.birthdayCalendarColor, sharedCalendarColors: state.sharedCalendarColors, groupContactsByLetter: state.groupContactsByLetter, + sortContactsByLastName: state.sortContactsByLastName, expandedFilterView: state.expandedFilterView, showTimeInMonthView: state.showTimeInMonthView, showWeekNumbers: state.showWeekNumbers,