feat: sort contacts by last name #963

This commit is contained in:
Linus Rath
2026-09-09 19:47:33 +02:00
parent 565d5c1095
commit fb24ef5d24
33 changed files with 348 additions and 50 deletions
@@ -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<ContactCard> & { 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(<ContactList {...defaultProps} contacts={family} />);
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(<ContactList {...defaultProps} contacts={family} />);
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(<ContactList {...defaultProps} contacts={family} searchQuery="alice" />);
expect(renderedNames()).toEqual(['Alice Smith']);
});
});
});
+32 -9
View File
@@ -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<ContactCard>();
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<ListFilters>(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<string, ContactCard[]>();
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) }))}
/>
</div>
{/* Sort order (persisted setting, also on /settings/contacts) */}
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-border/50">
<span className="text-xs text-muted-foreground inline-flex items-center gap-1">
<ArrowDownAZ className="w-3.5 h-3.5" />
{t("filters.sort_by")}
</span>
<ToggleChip
icon={null}
label={t("filters.sort_first_name")}
value={sortByLastName ? null : true}
onClick={() => updateSetting("sortContactsByLastName", false)}
/>
<ToggleChip
icon={null}
label={t("filters.sort_last_name")}
value={sortByLastName ? true : null}
onClick={() => updateSetting("sortContactsByLastName", true)}
/>
</div>
</div>
</div>
)}
+11
View File
@@ -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() {
/>
</SettingItem>
<SettingItem
label={tSettings("sort_by_last_name_label")}
description={tSettings("sort_by_last_name_description")}
>
<ToggleSwitch
checked={sortContactsByLastName}
onChange={(checked) => updateSetting("sortContactsByLastName", checked)}
/>
</SettingItem>
<SettingItem
label={tSettings("import_label")}
description={tSettings("import_description")}
+6 -1
View File
@@ -1898,6 +1898,8 @@
"title": "جهات الاتصال",
"description": "استيراد وتصدير جهات اتصالك",
"group_by_letter_label": "التجميع حسب الحرف الأول",
"sort_by_last_name_label": "الترتيب حسب اسم العائلة",
"sort_by_last_name_description": "ترتيب قائمة جهات الاتصال حسب اسم العائلة بحيث يظهر أفراد العائلة معًا",
"group_by_letter_description": "إظهار عناوين أقسام أبجدية في قائمة جهات الاتصال",
"import_label": "استيراد جهات الاتصال",
"import_description": "استيراد جهات الاتصال من ملف vCard (‎.vcf)",
@@ -2746,7 +2748,10 @@
"any_month": "أي شهر",
"has_email": "لديه بريد إلكتروني",
"has_phone": "لديه هاتف",
"has_photo": "لديه صورة"
"has_photo": "لديه صورة",
"sort_by": "الترتيب حسب",
"sort_first_name": "الاسم الأول",
"sort_last_name": "اسم العائلة"
}
},
"calendar": {
+6 -1
View File
@@ -1898,6 +1898,8 @@
"title": "Contactes",
"description": "Importeu i exporteu els contactes",
"group_by_letter_label": "Agrupa per lletra inicial",
"sort_by_last_name_label": "Ordena per cognom",
"sort_by_last_name_description": "Ordena la llista de contactes per cognom perquè els membres d'una família apareguin junts",
"group_by_letter_description": "Mostra capçaleres de secció alfabètiques a la llista de contactes",
"import_label": "Importa contactes",
"import_description": "Importeu contactes des d'un fitxer vCard (.vcf)",
@@ -2746,7 +2748,10 @@
"any_month": "Qualsevol mes",
"has_email": "Té correu electrònic",
"has_phone": "Té telèfon",
"has_photo": "Té foto"
"has_photo": "Té foto",
"sort_by": "Ordena per",
"sort_first_name": "Nom",
"sort_last_name": "Cognom"
}
},
"calendar": {
+6 -1
View File
@@ -1891,6 +1891,8 @@
"title": "Kontakty",
"description": "Import a export kontaktů",
"group_by_letter_label": "Seskupit podle prvního písmena",
"sort_by_last_name_label": "Řadit podle příjmení",
"sort_by_last_name_description": "Seřadit seznam kontaktů podle příjmení, aby členové rodiny byli pohromadě",
"group_by_letter_description": "Zobrazit záhlaví sekcí podle abecedy v seznamu kontaktů",
"import_label": "Importovat kontakty",
"import_description": "Importovat kontakty ze souboru vCard (.vcf)",
@@ -2745,7 +2747,10 @@
"any_month": "Kterýkoli měsíc",
"has_email": "Má e-mail",
"has_phone": "Má telefon",
"has_photo": "Má fotku"
"has_photo": "Má fotku",
"sort_by": "Řadit podle",
"sort_first_name": "Jméno",
"sort_last_name": "Příjmení"
},
"open_categories": "Otevřít kategorie"
},
+6 -1
View File
@@ -1894,6 +1894,8 @@
"title": "Kontakter",
"description": "Importér og eksportér dine kontakter",
"group_by_letter_label": "Gruppér efter første bogstav",
"sort_by_last_name_label": "Sortér efter efternavn",
"sort_by_last_name_description": "Sortér kontaktlisten efter efternavn, så familiemedlemmer står samlet",
"group_by_letter_description": "Vis alfabetiske sektionsoverskrifter i kontaktlisten",
"import_label": "Importér kontakter",
"import_description": "Importér kontakter fra en vCard-fil (.vcf)",
@@ -2745,7 +2747,10 @@
"any_month": "Enhver måned",
"has_email": "Har e-mail",
"has_phone": "Har telefon",
"has_photo": "Har billede"
"has_photo": "Har billede",
"sort_by": "Sortér efter",
"sort_first_name": "Fornavn",
"sort_last_name": "Efternavn"
},
"open_categories": "Åbn kategorier"
},
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "Kontaktkategorien umbenennen",
"no_categories": "Keine Kategorien gefunden",
"group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen",
"group_by_letter_label": "Nach Anfangsbuchstaben gruppieren"
"group_by_letter_label": "Nach Anfangsbuchstaben gruppieren",
"sort_by_last_name_label": "Nach Nachname sortieren",
"sort_by_last_name_description": "Kontaktliste nach Nachnamen ordnen, damit Familienmitglieder zusammenstehen"
},
"downloads": {
"title": "Downloads",
@@ -2745,7 +2747,10 @@
"any_month": "Beliebiger Monat",
"has_email": "Mit E-Mail",
"has_phone": "Mit Telefon",
"has_photo": "Mit Foto"
"has_photo": "Mit Foto",
"sort_by": "Sortieren nach",
"sort_first_name": "Vorname",
"sort_last_name": "Nachname"
},
"open_categories": "Kategorien öffnen"
},
+6 -1
View File
@@ -1899,6 +1899,8 @@
"description": "Import and export your contacts",
"group_by_letter_label": "Group by first letter",
"group_by_letter_description": "Show alphabetical section headers in the contact list",
"sort_by_last_name_label": "Sort by last name",
"sort_by_last_name_description": "Order the contact list by surname so family members appear together",
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
@@ -2746,7 +2748,10 @@
"any_month": "Any month",
"has_email": "Has email",
"has_phone": "Has phone",
"has_photo": "Has photo"
"has_photo": "Has photo",
"sort_by": "Sort by",
"sort_first_name": "First name",
"sort_last_name": "Last name"
}
},
"calendar": {
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "Renombrar categorías de contactos",
"no_categories": "No se encontraron categorías",
"group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos",
"group_by_letter_label": "Agrupar por primera letra"
"group_by_letter_label": "Agrupar por primera letra",
"sort_by_last_name_label": "Ordenar por apellido",
"sort_by_last_name_description": "Ordenar la lista de contactos por apellido para que los miembros de una familia aparezcan juntos"
},
"downloads": {
"title": "Descargas",
@@ -2745,7 +2747,10 @@
"any_month": "Cualquier mes",
"has_email": "Con correo",
"has_phone": "Con teléfono",
"has_photo": "Con foto"
"has_photo": "Con foto",
"sort_by": "Ordenar por",
"sort_first_name": "Nombre",
"sort_last_name": "Apellido"
},
"open_categories": "Abrir categorías"
},
+6 -1
View File
@@ -1898,6 +1898,8 @@
"title": "مخاطبین",
"description": "وارد کردن و خروجی مخاطبین",
"group_by_letter_label": "گروه‌بندی بر اساس حرف اول",
"sort_by_last_name_label": "مرتب‌سازی بر اساس نام خانوادگی",
"sort_by_last_name_description": "لیست مخاطبین را بر اساس نام خانوادگی مرتب کنید تا اعضای خانواده کنار هم نمایش داده شوند",
"group_by_letter_description": "نمایش هدرهای الفبایی در لیست مخاطبین",
"import_label": "وارد کردن مخاطبین",
"import_description": "وارد کردن مخاطبین از فایل vCard (.vcf)",
@@ -2746,7 +2748,10 @@
"any_month": "هر ماه",
"has_email": "دارای ایمیل",
"has_phone": "دارای تلفن",
"has_photo": "دارای عکس"
"has_photo": "دارای عکس",
"sort_by": "مرتب‌سازی بر اساس",
"sort_first_name": "نام",
"sort_last_name": "نام خانوادگی"
}
},
"calendar": {
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "Renommer les catégories de contacts",
"no_categories": "Aucune catégorie trouvée",
"group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts",
"group_by_letter_label": "Grouper par première lettre"
"group_by_letter_label": "Grouper par première lettre",
"sort_by_last_name_label": "Trier par nom de famille",
"sort_by_last_name_description": "Classer la liste des contacts par nom de famille pour regrouper les membres d'une même famille"
},
"downloads": {
"title": "Téléchargements",
@@ -2745,7 +2747,10 @@
"any_month": "Tous les mois",
"has_email": "Avec e-mail",
"has_phone": "Avec téléphone",
"has_photo": "Avec photo"
"has_photo": "Avec photo",
"sort_by": "Trier par",
"sort_first_name": "Prénom",
"sort_last_name": "Nom de famille"
},
"open_categories": "Ouvrir les catégories"
},
+6 -1
View File
@@ -1867,6 +1867,8 @@
"categories_description": "שנה שם של קטגוריות אנשי קשר",
"no_categories": "לא נמצאו קטגוריות",
"group_by_letter_label": "קבוצה לפי אות ראשונה",
"sort_by_last_name_label": "מיון לפי שם משפחה",
"sort_by_last_name_description": "סידור רשימת אנשי הקשר לפי שם משפחה כך שבני אותה משפחה יופיעו יחד",
"group_by_letter_description": "הצג כותרות חלקים אלפביתיים ברשימת אנשי הקשר"
},
"filters": {
@@ -2670,7 +2672,10 @@
"any_month": "כל חודש",
"has_email": "יש דוא״ל",
"has_phone": "יש טלפון",
"has_photo": "יש תמונה"
"has_photo": "יש תמונה",
"sort_by": "מיון לפי",
"sort_first_name": "שם פרטי",
"sort_last_name": "שם משפחה"
}
},
"calendar": {
+6 -1
View File
@@ -1894,6 +1894,8 @@
"title": "Névjegyek",
"description": "Névjegyek importálása és exportálása",
"group_by_letter_label": "Csoportosítás kezdőbetű szerint",
"sort_by_last_name_label": "Rendezés vezetéknév szerint",
"sort_by_last_name_description": "A névjegylista vezetéknév szerinti rendezése, hogy a családtagok egymás mellett legyenek",
"group_by_letter_description": "ABC sorrendű fejléc mutatása a névjegy listában",
"import_label": "Névjegyek importálása",
"import_description": "Névjegyek importálása vCard (.vcf) fájlból",
@@ -2746,7 +2748,10 @@
"any_month": "Bármely hónap",
"has_email": "Van e-mail",
"has_phone": "Van telefon",
"has_photo": "Van fotó"
"has_photo": "Van fotó",
"sort_by": "Rendezés",
"sort_first_name": "Keresztnév",
"sort_last_name": "Vezetéknév"
}
},
"calendar": {
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "Rinomina le categorie dei contatti",
"no_categories": "Nessuna categoria trovata",
"group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti",
"group_by_letter_label": "Raggruppa per prima lettera"
"group_by_letter_label": "Raggruppa per prima lettera",
"sort_by_last_name_label": "Ordina per cognome",
"sort_by_last_name_description": "Ordina l'elenco dei contatti per cognome così i familiari compaiono insieme"
},
"downloads": {
"title": "Download",
@@ -2745,7 +2747,10 @@
"any_month": "Qualsiasi mese",
"has_email": "Con email",
"has_phone": "Con telefono",
"has_photo": "Con foto"
"has_photo": "Con foto",
"sort_by": "Ordina per",
"sort_first_name": "Nome",
"sort_last_name": "Cognome"
},
"open_categories": "Apri categorie"
},
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "連絡先カテゴリの名前を変更",
"no_categories": "カテゴリが見つかりません",
"group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示",
"group_by_letter_label": "頭文字でグループ化"
"group_by_letter_label": "頭文字でグループ化",
"sort_by_last_name_label": "姓で並べ替え",
"sort_by_last_name_description": "家族が並んで表示されるように、連絡先一覧を姓の順に並べ替えます"
},
"downloads": {
"title": "ダウンロード",
@@ -2745,7 +2747,10 @@
"any_month": "すべての月",
"has_email": "メールあり",
"has_phone": "電話あり",
"has_photo": "写真あり"
"has_photo": "写真あり",
"sort_by": "並べ替え",
"sort_first_name": "名",
"sort_last_name": "姓"
},
"open_categories": "カテゴリを開く"
},
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "연락처 카테고리 이름 변경",
"no_categories": "카테고리를 찾을 수 없음",
"group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시",
"group_by_letter_label": "첫 글자로 그룹화"
"group_by_letter_label": "첫 글자로 그룹화",
"sort_by_last_name_label": "성으로 정렬",
"sort_by_last_name_description": "가족 구성원이 함께 표시되도록 연락처 목록을 성 기준으로 정렬합니다"
},
"downloads": {
"title": "다운로드",
@@ -2745,7 +2747,10 @@
"any_month": "모든 달",
"has_email": "이메일 있음",
"has_phone": "전화번호 있음",
"has_photo": "사진 있음"
"has_photo": "사진 있음",
"sort_by": "정렬 기준",
"sort_first_name": "이름",
"sort_last_name": "성"
},
"open_categories": "카테고리 열기"
},
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "Pārdēvēt kontaktu kategorijas",
"no_categories": "Kategorijas nav atrastas",
"group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā",
"group_by_letter_label": "Grupēt pēc pirmā burta"
"group_by_letter_label": "Grupēt pēc pirmā burta",
"sort_by_last_name_label": "Kārtot pēc uzvārda",
"sort_by_last_name_description": "Kārtot kontaktu sarakstu pēc uzvārda, lai ģimenes locekļi būtu kopā"
},
"downloads": {
"title": "Lejupielādes",
@@ -2745,7 +2747,10 @@
"any_month": "Jebkurš mēnesis",
"has_email": "Ar e-pastu",
"has_phone": "Ar tālruni",
"has_photo": "Ar foto"
"has_photo": "Ar foto",
"sort_by": "Kārtot pēc",
"sort_first_name": "Vārds",
"sort_last_name": "Uzvārds"
},
"open_categories": "Atvērt kategorijas"
},
+6 -1
View File
@@ -1898,6 +1898,8 @@
"title": "Харилцах утас",
"description": "Харилцагчаа импортлох, экспортлох",
"group_by_letter_label": "Эхний үсгээр бүлэглэх",
"sort_by_last_name_label": "Овгоор эрэмбэлэх",
"sort_by_last_name_description": "Гэр бүлийн гишүүд хамт харагдахаар харилцах хүмүүсийн жагсаалтыг овгоор эрэмбэлэх",
"group_by_letter_description": "Харилцах хүмүүсийн жагсаалтад цагаан толгойн үсгийн дарааллаар хэсгийн толгойг харуулах",
"import_label": "Харилцагчдыг импортлох",
"import_description": "vCard (.vcf) файлаас харилцагчдыг импортлох",
@@ -2746,7 +2748,10 @@
"any_month": "Ямар ч сар",
"has_email": "Имэйл хаягтай",
"has_phone": "Утастай",
"has_photo": "Зурагтай"
"has_photo": "Зурагтай",
"sort_by": "Эрэмбэлэх",
"sort_first_name": "Нэр",
"sort_last_name": "Овог"
}
},
"calendar": {
+6 -1
View File
@@ -1898,6 +1898,8 @@
"title": "Kontakter",
"description": "Importer og eksporter kontaktene dine",
"group_by_letter_label": "Grupper etter første bokstav",
"sort_by_last_name_label": "Sorter etter etternavn",
"sort_by_last_name_description": "Sorter kontaktlisten etter etternavn slik at familiemedlemmer står samlet",
"group_by_letter_description": "Vis alfabetiske overskrifter i kontaktlisten",
"import_label": "Importer kontakter",
"import_description": "Importer kontakter fra en vCard-fil (.vcf).",
@@ -2746,7 +2748,10 @@
"any_month": "Alle måneder",
"has_email": "Har e-postadresse",
"has_phone": "Har telefonnummer",
"has_photo": "Har bilde"
"has_photo": "Har bilde",
"sort_by": "Sorter etter",
"sort_first_name": "Fornavn",
"sort_last_name": "Etternavn"
}
},
"calendar": {
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "Contactcategorieën hernoemen",
"no_categories": "Geen categorieën gevonden",
"group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst",
"group_by_letter_label": "Groeperen op eerste letter"
"group_by_letter_label": "Groeperen op eerste letter",
"sort_by_last_name_label": "Sorteren op achternaam",
"sort_by_last_name_description": "Sorteer de contactenlijst op achternaam zodat gezinsleden bij elkaar staan"
},
"downloads": {
"title": "Downloads",
@@ -2745,7 +2747,10 @@
"any_month": "Elke maand",
"has_email": "Met e-mail",
"has_phone": "Met telefoon",
"has_photo": "Met foto"
"has_photo": "Met foto",
"sort_by": "Sorteren op",
"sort_first_name": "Voornaam",
"sort_last_name": "Achternaam"
},
"open_categories": "Categorieën openen"
},
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "Zmień nazwy kategorii kontaktów",
"no_categories": "Nie znaleziono kategorii",
"group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów",
"group_by_letter_label": "Grupuj według pierwszej litery"
"group_by_letter_label": "Grupuj według pierwszej litery",
"sort_by_last_name_label": "Sortuj według nazwiska",
"sort_by_last_name_description": "Uporządkuj listę kontaktów według nazwiska, aby członkowie rodziny byli obok siebie"
},
"downloads": {
"title": "Pobrane",
@@ -2745,7 +2747,10 @@
"any_month": "Dowolny miesiąc",
"has_email": "Z e-mailem",
"has_phone": "Z telefonem",
"has_photo": "Ze zdjęciem"
"has_photo": "Ze zdjęciem",
"sort_by": "Sortuj według",
"sort_first_name": "Imię",
"sort_last_name": "Nazwisko"
},
"open_categories": "Otwórz kategorie"
},
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "Renomear categorias de contatos",
"no_categories": "Nenhuma categoria encontrada",
"group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos",
"group_by_letter_label": "Agrupar pela primeira letra"
"group_by_letter_label": "Agrupar pela primeira letra",
"sort_by_last_name_label": "Ordenar por sobrenome",
"sort_by_last_name_description": "Ordenar a lista de contatos por sobrenome para que os membros da família apareçam juntos"
},
"downloads": {
"title": "Downloads",
@@ -2745,7 +2747,10 @@
"any_month": "Qualquer mês",
"has_email": "Com e-mail",
"has_phone": "Com telefone",
"has_photo": "Com foto"
"has_photo": "Com foto",
"sort_by": "Ordenar por",
"sort_first_name": "Nome",
"sort_last_name": "Sobrenome"
},
"open_categories": "Abrir categorias"
},
+6 -1
View File
@@ -1898,6 +1898,8 @@
"title": "Contacte",
"description": "Importați și exportați contactele",
"group_by_letter_label": "Grupați după prima literă",
"sort_by_last_name_label": "Sortare după nume de familie",
"sort_by_last_name_description": "Ordonați lista de contacte după numele de familie, astfel încât membrii familiei să apară împreună",
"group_by_letter_description": "Afișați titlurile secțiunilor în ordine alfabetică în lista de contacte",
"import_label": "Importați contactele",
"import_description": "Importați contactele dintr-un fișier „vCard” (.vcf)",
@@ -2746,7 +2748,10 @@
"any_month": "Orice lună",
"has_email": "Are e-mail",
"has_phone": "Are telefon",
"has_photo": "Are fotografie"
"has_photo": "Are fotografie",
"sort_by": "Sortare după",
"sort_first_name": "Prenume",
"sort_last_name": "Nume de familie"
}
},
"calendar": {
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "Переименование категорий контактов",
"no_categories": "Категории не найдены",
"group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов",
"group_by_letter_label": "Группировать по первой букве"
"group_by_letter_label": "Группировать по первой букве",
"sort_by_last_name_label": "Сортировать по фамилии",
"sort_by_last_name_description": "Упорядочить список контактов по фамилии, чтобы члены семьи шли подряд"
},
"downloads": {
"title": "Загрузки",
@@ -2745,7 +2747,10 @@
"any_month": "Любой месяц",
"has_email": "С эл. почтой",
"has_phone": "С телефоном",
"has_photo": "С фото"
"has_photo": "С фото",
"sort_by": "Сортировать по",
"sort_first_name": "Имя",
"sort_last_name": "Фамилия"
},
"open_categories": "Открыть категории"
},
+6 -1
View File
@@ -1898,6 +1898,8 @@
"title": "Kontakty",
"description": "Import a export kontaktov",
"group_by_letter_label": "Zoskupiť podľa prvého písmena",
"sort_by_last_name_label": "Zoradiť podľa priezviska",
"sort_by_last_name_description": "Zoradiť zoznam kontaktov podľa priezviska, aby členovia rodiny boli pri sebe",
"group_by_letter_description": "Zobraziť abecedné záhlavia sekcií v zozname kontaktov",
"import_label": "Importovať kontakty",
"import_description": "Importovať kontakty zo súboru vCard (.vcf)",
@@ -2746,7 +2748,10 @@
"any_month": "Ktorýkoľvek mesiac",
"has_email": "Má e-mail",
"has_phone": "Má telefón",
"has_photo": "Má fotku"
"has_photo": "Má fotku",
"sort_by": "Zoradiť podľa",
"sort_first_name": "Meno",
"sort_last_name": "Priezvisko"
}
},
"calendar": {
+6 -1
View File
@@ -1891,6 +1891,8 @@
"title": "Kişiler",
"description": "Kişilerinizi içe ve dışa aktarın",
"group_by_letter_label": "İlk harfe göre grupla",
"sort_by_last_name_label": "Soyada göre sırala",
"sort_by_last_name_description": "Aile üyeleri bir arada görünsün diye kişi listesini soyada göre sıralar",
"group_by_letter_description": "Kişi listesinde alfabetik bölüm başlıkları göster",
"import_label": "Kişileri İçe Aktar",
"import_description": "Kişileri bir vCard (.vcf) dosyasından içe aktarın",
@@ -2745,7 +2747,10 @@
"any_month": "Herhangi bir ay",
"has_email": "E-postası var",
"has_phone": "Telefonu var",
"has_photo": "Fotoğrafı var"
"has_photo": "Fotoğrafı var",
"sort_by": "Sıralama",
"sort_first_name": "Ad",
"sort_last_name": "Soyad"
},
"open_categories": "Kategorileri aç"
},
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "Перейменувати категорії контактів",
"no_categories": "Категорії не знайдено",
"group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів",
"group_by_letter_label": "Групувати за першою літерою"
"group_by_letter_label": "Групувати за першою літерою",
"sort_by_last_name_label": "Сортувати за прізвищем",
"sort_by_last_name_description": "Упорядкувати список контактів за прізвищем, щоб члени родини були поруч"
},
"downloads": {
"title": "Завантаження",
@@ -2745,7 +2747,10 @@
"any_month": "Будь-який місяць",
"has_email": "З ел. поштою",
"has_phone": "З телефоном",
"has_photo": "З фото"
"has_photo": "З фото",
"sort_by": "Сортувати за",
"sort_first_name": "Ім'я",
"sort_last_name": "Прізвище"
},
"open_categories": "Відкрити категорії"
},
+6 -1
View File
@@ -1898,6 +1898,8 @@
"title": "聯絡人",
"description": "匯入與匯出聯絡人",
"group_by_letter_label": "依首字分組",
"sort_by_last_name_label": "依姓氏排序",
"sort_by_last_name_description": "依姓氏排列聯絡人清單,讓家庭成員顯示在一起",
"group_by_letter_description": "在聯絡人清單中顯示依字母排列的分節標題",
"import_label": "匯入聯絡人",
"import_description": "從 vCard(.vcf)檔案匯入聯絡人",
@@ -2746,7 +2748,10 @@
"any_month": "任何月份",
"has_email": "有電子郵件地址",
"has_phone": "有電話",
"has_photo": "有照片"
"has_photo": "有照片",
"sort_by": "排序方式",
"sort_first_name": "名",
"sort_last_name": "姓"
}
},
"calendar": {
+7 -2
View File
@@ -1901,7 +1901,9 @@
"categories_description": "重命名联系人类别",
"no_categories": "未找到类别",
"group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题",
"group_by_letter_label": "按首字母分组"
"group_by_letter_label": "按首字母分组",
"sort_by_last_name_label": "按姓氏排序",
"sort_by_last_name_description": "按姓氏排列联系人列表,使家庭成员显示在一起"
},
"downloads": {
"title": "下载",
@@ -2745,7 +2747,10 @@
"any_month": "任意月份",
"has_email": "有邮箱",
"has_phone": "有电话",
"has_photo": "有照片"
"has_photo": "有照片",
"sort_by": "排序方式",
"sort_first_name": "名",
"sort_last_name": "姓"
},
"open_categories": "打开分类"
},
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { getContactSortName } from '../contact-store';
import type { ContactCard } from '@/lib/jmap/types';
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,
},
});
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');
});
});
+28
View File
@@ -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 || '';
+5
View File
@@ -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<SettingsState>()(
birthdayCalendarColor: state.birthdayCalendarColor,
sharedCalendarColors: state.sharedCalendarColors,
groupContactsByLetter: state.groupContactsByLetter,
sortContactsByLastName: state.sortContactsByLastName,
expandedFilterView: state.expandedFilterView,
showTimeInMonthView: state.showTimeInMonthView,
showWeekNumbers: state.showWeekNumbers,