From 8d8ef7e2f1b84257384cf5165fdbe4b1300d39ad Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:30:59 +0200 Subject: [PATCH 1/6] fix: unreadable native 's option list itself, so the bg-muted / text-foreground classes on the control never reach it. Themes whose --color-muted is translucent (Aurora Glass dark uses rgba(255,255,255,0.05)) composited to a light popup with near-white text, leaving every unselected option almost invisible. Pin option background/colour to the popover tokens, which every built-in theme defines as an opaque surface, and set color-scheme: dark on selects under .dark so Safari (which ignores option colours) renders a dark popup too. Fixes all ~40 native selects at once. Adds a stylesheet test that locks the rules and asserts every built-in theme's --color-popover stays opaque in each variant. --- app/globals.css | 16 +++++ lib/__tests__/native-select-options.test.ts | 77 +++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 lib/__tests__/native-select-options.test.ts diff --git a/app/globals.css b/app/globals.css index 76d6f1522..a3a4d3ab3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -187,6 +187,22 @@ body { "calt" 1; } +/* Native 's option list itself; the Tailwind + * classes on the control (bg-muted / text-foreground) never reach it. It only + * honours `color-scheme` and an explicit option background/colour. Themes with + * a translucent --color-muted (Aurora Glass dark) therefore composited to a + * light popup with near-white text (#999). globals.css pins options to the + * popover tokens, so every theme must define those as an opaque surface. + * + * jsdom does not render native popups, so lock the stylesheet rules. + */ +const css = readFileSync(path.join(process.cwd(), 'app', 'globals.css'), 'utf8'); + +/** Return the body of the first top-level `selector { ... }` rule. */ +function ruleBody(selector: string): string { + const idx = css.indexOf('\n' + selector + ' {'); + expect(idx, 'no `' + selector + '` rule in globals.css').toBeGreaterThan(-1); + const open = css.indexOf('{', idx); + return css.slice(open + 1, css.indexOf('}', open)); +} + +/** True when a CSS colour value has no alpha channel below 1. */ +function isOpaque(value: string): boolean { + const v = value.trim().toLowerCase(); + if (v === 'transparent') return false; + if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(v)) return true; + if (/^#[0-9a-f]{4}$/.test(v)) return v.endsWith('f'); + if (/^#[0-9a-f]{8}$/.test(v)) return v.endsWith('ff'); + const fn = v.match(/^(rgb|hsl)a?\((.*)\)$/); + if (fn) { + const parts = fn[2].split(/[\s,/]+/).filter(Boolean); + if (parts.length < 4) return true; + const alpha = parts[3]; + return alpha.endsWith('%') ? parseFloat(alpha) >= 100 : parseFloat(alpha) >= 1; + } + return false; +} + +/** Concrete `--color-popover` values, skipping the `@theme` alias `var(--color-popover)`. */ +function popoverValues(source: string): string[] { + return [...source.matchAll(/--color-popover:\s*([^;]+);/g)] + .map((m) => m[1].trim()) + .filter((v) => !v.startsWith('var(')); +} + +describe('native updateSetting('replyIdentityMatch', value as ReplyIdentityMatch)} + options={[ + { value: 'exact', label: t('reply_identity_match.exact') }, + { value: 'domain', label: t('reply_identity_match.domain') }, + ]} + /> + + )} + { expect(result).toEqual({ identityId: 'primary' }); }); + // #1000: a domain whose extra addresses are distribution lists, not + // catch-all aliases. Exact mode keeps the identity matching but never + // surfaces a From override. + it('returns null instead of a catch-all override in exact mode', () => { + expect(resolveReplyFrom(identities, { to: [{ email: 'stripe@primary.com', name: 'Stripe' }] }, 'exact')) + .toBeNull(); + }); + + it('still matches configured identities (exact and +tag) in exact mode', () => { + expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] }, 'exact')) + .toEqual({ identityId: 'secondary' }); + expect(resolveReplyFrom(identities, { to: [{ email: 'harry+news@primary.com' }] }, 'exact')) + .toEqual({ identityId: 'primary' }); + }); + it('returns null when recipients are on foreign domains', () => { expect(resolveReplyFrom(identities, { to: [{ email: 'nobody@elsewhere.com' }] })) .toBeNull(); diff --git a/lib/reply-identity.ts b/lib/reply-identity.ts index 52522c29b..a5109b104 100644 --- a/lib/reply-identity.ts +++ b/lib/reply-identity.ts @@ -198,6 +198,16 @@ export function findDraftIdentityId( return base?.id ?? null; } +/** + * How far `resolveReplyFrom` goes when matching received addresses: + * `exact` stops at the user's configured identities (steps 1-2 below), + * `domain` also takes the same-domain catch-all step (3), which rewrites + * `From:` to an address the user has not configured. Deployments where the + * extra addresses on a domain are distribution lists rather than aliases + * want `exact` (#1000). + */ +export type ReplyIdentityMatchMode = 'exact' | 'domain'; + export interface ReplyFromResolution { /** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */ identityId: string; @@ -223,12 +233,14 @@ export interface ReplyFromResolution { * sub-addressing, reply as that identity with no override. * 3. Else if a recipient address is on a domain that one of the identities * uses, treat that recipient as a catch-all alias: return the matching - * identity + the recipient as a header-From override. + * identity + the recipient as a header-From override. Skipped in + * `exact` match mode. * 4. Else return `null` (caller falls back to primary identity). */ export function resolveReplyFrom( identities: Identity[], recipients?: ReplyRecipients, + matchMode: ReplyIdentityMatchMode = 'domain', ): ReplyFromResolution | null { if (identities.length === 0 || !recipients) { return null; @@ -265,6 +277,10 @@ export function resolveReplyFrom( return { identityId: baseIdentity.id }; } + if (matchMode !== 'domain') { + return null; + } + const ownedDomains = new Set(identities.map((i) => domainOf(i.email)).filter(Boolean)); const catchAll = received.find((r) => { diff --git a/lib/settings-search.ts b/lib/settings-search.ts index f55b22930..646da5d1b 100644 --- a/lib/settings-search.ts +++ b/lib/settings-search.ts @@ -97,6 +97,7 @@ export const tabSearchPaths: Record = { composing: [ 'settings.email_behavior.attachment_reminder', 'settings.email_behavior.auto_select_reply_identity', + 'settings.email_behavior.reply_identity_match', 'settings.email_behavior.plain_text_mode', 'settings.email_behavior.rtl_editing', 'settings.email_behavior.default_mail_program', diff --git a/locales/ar/common.json b/locales/ar/common.json index 7ce66df4e..be39104fd 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1335,6 +1335,12 @@ "label": "الرد من العنوان المستلَم إليه", "description": "عند الرد، الإرسال من العنوان الذي أُرسلت إليه الرسالة أصلًا. يطابق الهويات أولًا؛ وبالنسبة لعمليات التسليم الشاملة للنطاق، يعيد كتابة ترويسة \"من\" إلى العنوان البديل مع الإرسال عبر هويتك الأساسية." }, + "reply_identity_match": { + "label": "مطابقة عنوان الاستلام", + "description": "أي عناوين الاستلام تُعدّ عناوينك. «العنوان المطابق فقط» يختار إحدى هوياتك المضبوطة فقط؛ أما «نفس النطاق» فيعامل أيضًا أي عنوان آخر على نطاقات هوياتك كاسم مستعار شامل ويستبدل به ترويسة المرسل. اختر العنوان المطابق إذا كانت تلك العناوين قوائم توزيع.", + "exact": "العنوان المطابق فقط", + "domain": "أي عنوان على نطاقاتي" + }, "signature_position": { "label": "موضع التوقيع", "description": "أين يُدرج توقيعك في الردود وإعادة التوجيه. أعلى النص المقتبس يُقرأ بشكل طبيعي كخاتمة للرد؛ وأسفله يُبقي الرسالة الأصلية متصلة.", diff --git a/locales/ca/common.json b/locales/ca/common.json index d3bceb0dd..6c660b636 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1335,6 +1335,12 @@ "label": "Respon des de l'adreça de recepció", "description": "En respondre, envia des de l'adreça a la qual es va enviar originalment el missatge. Primer intenta coincidir amb les identitats; per als lliuraments de captura general de domini, reescriu la capçalera «De» amb l'àlies mentre envia a través de la identitat principal." }, + "reply_identity_match": { + "label": "Coincidència de l'adreça de recepció", + "description": "Quines adreces de recepció compten com a teves. «Només l'adreça exacta» tria únicament una de les teves identitats configurades; «mateix domini» també tracta qualsevol altra adreça dels teus dominis d'identitat com un àlies catch-all i hi reescriu la capçalera De. Tria l'adreça exacta si aquestes adreces són llistes de distribució.", + "exact": "Només l'adreça exacta", + "domain": "Qualsevol adreça dels meus dominis" + }, "signature_position": { "label": "Posició de la signatura", "description": "On inserir la signatura a les respostes i reenviaments. Abans del text citat es llegeix de manera natural com a tancament de la resposta; després manté el missatge original contigu.", diff --git a/locales/cs/common.json b/locales/cs/common.json index 7367db8ef..4024055ca 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1332,6 +1332,12 @@ "label": "Automaticky vybírat adresu pro odpověď", "description": "Při odpovídání automaticky přepnout adresu odesílatele na identitu, která původně obdržela zprávu" }, + "reply_identity_match": { + "label": "Porovnávání přijímací adresy", + "description": "Které přijímací adresy se počítají jako vaše. „Pouze přesná adresa“ vybere jen jednu z vašich nastavených identit; „stejná doména“ navíc považuje jakoukoli jinou adresu na doménách vašich identit za catch-all alias a přepíše na ni hlavičku Od. Pokud jsou tyto adresy distribuční seznamy, zvolte přesnou adresu.", + "exact": "Pouze přesná adresa", + "domain": "Jakákoli adresa na mých doménách" + }, "signature_position": { "label": "Pozice podpisu", "description": "Kam vložit podpis v odpovědích a přeposláních. Nad citovaným textem působí přirozeně jako zakončení odpovědi; pod ním zachovává původní zprávu vcelku.", diff --git a/locales/da/common.json b/locales/da/common.json index 1a60ef4eb..850695d45 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1335,6 +1335,12 @@ "label": "Svar fra modtaget adresse", "description": "Når du svarer, send fra den adresse som beskeden oprindeligt blev sendt til. Matcher først identiteter; for domæne catch-all-leveringer omskriver Fra-headeren til aliaset, mens der sendes gennem din primære identitet." }, + "reply_identity_match": { + "label": "Matchning af modtageradresse", + "description": "Hvilke modtageradresser der tæller som dine. „Kun præcis adresse“ vælger udelukkende en af dine opsatte identiteter; „samme domæne“ behandler desuden enhver anden adresse på dine identitetsdomæner som et catch-all-alias og omskriver Fra-headeren til den. Vælg præcis adresse, hvis disse adresser er distributionslister.", + "exact": "Kun præcis adresse", + "domain": "Enhver adresse på mine domæner" + }, "signature_position": { "label": "Signaturplacering", "description": "Hvor din signatur indsættes i svar og videresendelser. Før citeret tekst læses naturligt som en afslutning på svaret; under bevarer den oprindelige besked sammenhængende.", diff --git a/locales/de/common.json b/locales/de/common.json index 22ccb6591..4d94aad07 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1332,6 +1332,12 @@ "label": "Antwortadresse automatisch wählen", "description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat" }, + "reply_identity_match": { + "label": "Abgleich der Empfangsadresse", + "description": "Welche Empfangsadressen als eigene gelten. „Nur exakte Adresse“ wählt ausschließlich eine Ihrer eingerichteten Identitäten. „Gleiche Domain“ behandelt zusätzlich jede andere Adresse auf einer Ihrer Identitäts-Domains als Catch-all-Alias und ersetzt den Absender-Header damit. Wählen Sie die exakte Adresse, wenn solche Adressen Verteilerlisten und keine Aliasse sind.", + "exact": "Nur exakte Adresse", + "domain": "Jede Adresse auf meinen Domains" + }, "signature_position": { "label": "Signaturposition", "description": "Wo Ihre Signatur in Antworten und Weiterleitungen eingefügt wird. Über dem zitierten Text liest sie sich natürlich als Abschluss der Antwort; darunter bleibt die ursprüngliche Nachricht zusammenhängend.", diff --git a/locales/en/common.json b/locales/en/common.json index cf57ed107..5ebd3e118 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1333,7 +1333,13 @@ }, "auto_select_reply_identity": { "label": "Reply From Received Address", - "description": "When replying, send from the address the message was originally sent to. Matches identities first; for domain catch-all deliveries, rewrites the From header to the alias while sending through your primary identity." + "description": "When replying, send from the address the message was originally sent to. Your configured identities always match; whether other addresses on your domains count too (catch-all) is set by “Received Address Matching” below." + }, + "reply_identity_match": { + "label": "Received Address Matching", + "description": "Which received addresses count as yours. Exact address only picks one of your configured identities. Same domain also treats any other address on one of your identity domains as a catch-all alias and rewrites the From header to it. Choose exact address if those addresses are distribution lists rather than aliases.", + "exact": "Exact address only", + "domain": "Any address on my domains" }, "signature_position": { "label": "Signature Position", diff --git a/locales/es/common.json b/locales/es/common.json index f9e47b094..8b0d8510c 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1327,6 +1327,12 @@ "label": "Seleccionar dirección de respuesta automáticamente", "description": "Al responder, cambia automáticamente la dirección del remitente a la identidad que recibió el mensaje original" }, + "reply_identity_match": { + "label": "Coincidencia de la dirección de recepción", + "description": "Qué direcciones de recepción cuentan como tuyas. «Solo la dirección exacta» elige únicamente una de tus identidades configuradas; «mismo dominio» también trata cualquier otra dirección de tus dominios de identidad como un alias catch-all y reescribe la cabecera De con ella. Elige la dirección exacta si esas direcciones son listas de distribución.", + "exact": "Solo la dirección exacta", + "domain": "Cualquier dirección de mis dominios" + }, "signature_position": { "label": "Posición de la firma", "description": "Dónde insertar tu firma en respuestas y reenvíos. Encima del texto citado se lee de forma natural como cierre de la respuesta; debajo mantiene el mensaje original contiguo.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 30ae80711..48451181d 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1335,6 +1335,12 @@ "label": "پاسخ از آدرس دریافتی", "description": "ارسال با آدرس دریافت شده" }, + "reply_identity_match": { + "label": "تطبیق نشانی دریافت", + "description": "کدام نشانی‌های دریافت به‌عنوان نشانی شما در نظر گرفته شوند. «فقط نشانی دقیق» تنها یکی از هویت‌های پیکربندی‌شدهٔ شما را انتخاب می‌کند؛ «همان دامنه» هر نشانی دیگری در دامنه‌های هویت شما را نیز نام مستعار catch-all در نظر می‌گیرد و سرآیند فرستنده را با آن بازنویسی می‌کند. اگر این نشانی‌ها فهرست توزیع هستند، نشانی دقیق را انتخاب کنید.", + "exact": "فقط نشانی دقیق", + "domain": "هر نشانی در دامنه‌های من" + }, "signature_position": { "label": "موقعیت امضا", "description": "محل درج امضا", diff --git a/locales/fr/common.json b/locales/fr/common.json index b35db2eee..fd575360f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1327,6 +1327,12 @@ "label": "Sélection automatique de l'adresse de réponse", "description": "Lors d'une réponse, bascule automatiquement l'adresse d'expédition vers l'identité qui a reçu le message d'origine" }, + "reply_identity_match": { + "label": "Correspondance de l'adresse de réception", + "description": "Quelles adresses de réception comptent comme les vôtres. « Adresse exacte uniquement » ne choisit qu'une de vos identités configurées ; « même domaine » traite aussi toute autre adresse de vos domaines d'identité comme un alias catch-all et y réécrit l'en-tête De. Choisissez l'adresse exacte si ces adresses sont des listes de diffusion.", + "exact": "Adresse exacte uniquement", + "domain": "Toute adresse de mes domaines" + }, "signature_position": { "label": "Position de la signature", "description": "Où insérer votre signature dans les réponses et les transferts. Au-dessus du texte cité, elle se lit naturellement comme la conclusion de la réponse ; en dessous, elle garde le message d'origine contigu.", diff --git a/locales/he/common.json b/locales/he/common.json index 8e4086695..2f342e94d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1286,6 +1286,12 @@ "label": "בחירה אוטומטית בכתובת תשובה", "description": "בעת תשובה, העבר אוטומטית את הכתובת מאת לזהות שקיבלה את ההודעה במקור" }, + "reply_identity_match": { + "label": "התאמת כתובת הקבלה", + "description": "אילו כתובות קבלה נחשבות לשלך. „כתובת מדויקת בלבד“ בוחרת רק אחת מהזהויות שהגדרת; „אותו דומיין“ מתייחסת גם לכל כתובת אחרת בדומייני הזהויות שלך ככינוי catch-all ומשכתבת אליה את כותרת השולח. בחר בכתובת מדויקת אם כתובות אלה הן רשימות תפוצה.", + "exact": "כתובת מדויקת בלבד", + "domain": "כל כתובת בדומיינים שלי" + }, "attachment_click_action": { "label": "קובץ מצורף לחץ על פעולה", "description": "בחר אם לחיצה על קובץ מצורף תציג אותו בתצוגה מקדימה או תוריד אותו מיד", diff --git a/locales/hu/common.json b/locales/hu/common.json index 6366ece1e..36055002f 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1335,6 +1335,12 @@ "label": "Válasz a fogadott címről", "description": "Válaszküldéskor arról a címről küldjön, amelyre az üzenet eredetileg érkezett. Először identitásokat keres; tartományi catch-all kézbesítések esetén átírja a Feladó fejlécet az aliasra, miközben az elsődleges identitáson keresztül küld." }, + "reply_identity_match": { + "label": "Fogadó cím egyeztetése", + "description": "Mely fogadó címek számítanak a sajátjának. A „Csak pontos cím” kizárólag a beállított identitásai közül választ; az „azonos domain” az identitás-domainjein lévő bármely más címet is catch-all aliasként kezeli, és arra írja át a Feladó fejlécet. Válassza a pontos címet, ha ezek a címek terjesztési listák.", + "exact": "Csak pontos cím", + "domain": "Bármely cím a domainjeimen" + }, "signature_position": { "label": "Aláírás pozíciója", "description": "Hova kerüljön az aláírás a válaszokban és továbbításokban. Az idézett szöveg felett természetes lezárásként olvasható; alatta az eredeti üzenet marad egybefüggő.", diff --git a/locales/it/common.json b/locales/it/common.json index f6a3017a5..53807a2be 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1327,6 +1327,12 @@ "label": "Seleziona automaticamente l'indirizzo di risposta", "description": "Quando rispondi, passa automaticamente l'indirizzo mittente all'identità che ha ricevuto il messaggio originale" }, + "reply_identity_match": { + "label": "Corrispondenza dell'indirizzo di ricezione", + "description": "Quali indirizzi di ricezione contano come tuoi. «Solo indirizzo esatto» sceglie soltanto una delle tue identità configurate; «stesso dominio» tratta anche qualsiasi altro indirizzo dei tuoi domini di identità come alias catch-all e riscrive con esso l'intestazione Da. Scegli l'indirizzo esatto se quegli indirizzi sono liste di distribuzione.", + "exact": "Solo indirizzo esatto", + "domain": "Qualsiasi indirizzo dei miei domini" + }, "signature_position": { "label": "Posizione della firma", "description": "Dove inserire la tua firma nelle risposte e negli inoltri. Sopra il testo citato si legge in modo naturale come chiusura della risposta; sotto mantiene il messaggio originale contiguo.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 147cc1fab..08448a1f7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1327,6 +1327,12 @@ "label": "返信元アドレスを自動選択", "description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます" }, + "reply_identity_match": { + "label": "受信アドレスの照合", + "description": "どの受信アドレスを自分のものとみなすか。「完全一致のみ」は設定済みのアイデンティティからのみ選択します。「同じドメイン」はアイデンティティのドメイン上の他のアドレスもキャッチオールのエイリアスとして扱い、From ヘッダーをそのアドレスに書き換えます。それらのアドレスが配布リストの場合は完全一致を選択してください。", + "exact": "完全一致のみ", + "domain": "自分のドメイン上の任意のアドレス" + }, "signature_position": { "label": "署名の位置", "description": "返信や転送で署名を挿入する位置。引用テキストの上は返信の締めとして自然に読めます。下は元のメッセージを続けて表示します。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 5acb161f4..41b5aa5dc 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1332,6 +1332,12 @@ "label": "답장 시 보내는 사람 자동 선택", "description": "답장할 때 메일을 받았던 주소로 보내는 사람을 자동으로 변경해요" }, + "reply_identity_match": { + "label": "수신 주소 일치 방식", + "description": "어떤 수신 주소를 내 주소로 간주할지 정합니다. '정확한 주소만'은 설정된 ID 중에서만 선택합니다. '같은 도메인'은 ID 도메인의 다른 주소도 캐치올 별칭으로 간주하여 보낸 사람 헤더를 해당 주소로 바꿉니다. 해당 주소가 배포 목록이라면 정확한 주소를 선택하세요.", + "exact": "정확한 주소만", + "domain": "내 도메인의 모든 주소" + }, "signature_position": { "label": "서명 위치", "description": "답장과 전달에서 서명을 삽입할 위치. 인용된 텍스트 위에 두면 답장의 마무리처럼 자연스럽게 읽히고, 아래에 두면 원본 메시지가 이어져 보입니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index a2133d779..2569ca623 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1327,6 +1327,12 @@ "label": "Automātiski izvēlēties atbildes adresi", "description": "Atbildot automātiski izmantot to kontu, uz kuru vēstule tika saņemta" }, + "reply_identity_match": { + "label": "Saņemšanas adreses atbilstība", + "description": "Kuras saņemšanas adreses tiek uzskatītas par jūsu. „Tikai precīza adrese“ izvēlas vienīgi kādu no jūsu konfigurētajām identitātēm; „tas pats domēns“ arī jebkuru citu adresi jūsu identitāšu domēnos uzskata par catch-all aizstājvārdu un pārraksta ar to galveni „No“. Izvēlieties precīzu adresi, ja šīs adreses ir izplatīšanas saraksti.", + "exact": "Tikai precīza adrese", + "domain": "Jebkura adrese manos domēnos" + }, "signature_position": { "label": "Paraksta novietojums", "description": "Kur ievietot jūsu parakstu atbildēs un pārsūtīšanā. Virs citētā teksta tas dabiski lasās kā atbildes noslēgums; zem tā saglabā oriģinālo ziņojumu vienkopus.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 28e281cd8..143c832ac 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1335,6 +1335,12 @@ "label": "Хүлээн авсан хаягаас хариу бичих", "description": "Хариу бичихдээ мессежийг анх илгээсэн хаягаар нь илгээнэ үү. Эхлээд таних тэмдэгтэй таарах; Домэйн бүх хүргэлтийн хувьд, таны үндсэн таниулбараар дамжуулан илгээхдээ From толгой хэсгийг өөр нэр рүү дахин бичнэ." }, + "reply_identity_match": { + "label": "Хүлээн авах хаягийн тохирол", + "description": "Хүлээн авах ямар хаягуудыг таных гэж үзэх. „Зөвхөн яг таарах хаяг“ нь зөвхөн таны тохируулсан identity-уудаас сонгоно; „ижил домэйн“ нь таны identity-ийн домэйн дээрх бусад хаягийг ч catch-all нэр болгон үзэж, Илгээгч толгойг тэр хаягаар солино. Эдгээр хаяг нь түгээлтийн жагсаалт бол яг таарах хаягийг сонгоно уу.", + "exact": "Зөвхөн яг таарах хаяг", + "domain": "Миний домэйн дээрх дурын хаяг" + }, "signature_position": { "label": "Гарын үсэг зурах албан тушаал", "description": "Хариулт болон дамжуулалтад гарын үсгээ хаана оруулах вэ. Иш татсан бичвэрийн дээрх хариултыг хаах нь ойлгомжтой; доор нь эх мессежийг залгаж хадгална.", diff --git a/locales/nb/common.json b/locales/nb/common.json index e37def473..3bf6184c3 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1335,6 +1335,12 @@ "label": "Svar fra mottatt adresse", "description": "Når du svarer, bruker Bulwark adressen meldingen opprinnelig ble sendt til. Programmet ser først etter en samsvarende identitet. For meldinger til et oppsamlingsdomene brukes aliaset i Fra-feltet, mens sendingen går gjennom hovedidentiteten din." }, + "reply_identity_match": { + "label": "Samsvar for mottaksadresse", + "description": "Hvilke mottaksadresser som regnes som dine. «Kun nøyaktig adresse» velger bare en av dine oppsatte identiteter; «samme domene» behandler i tillegg enhver annen adresse på identitetsdomenene dine som et catch-all-alias og skriver om Fra-headeren til den. Velg nøyaktig adresse hvis disse adressene er distribusjonslister.", + "exact": "Kun nøyaktig adresse", + "domain": "Enhver adresse på mine domener" + }, "signature_position": { "label": "Signaturposisjon", "description": "Velg hvor signaturen skal settes inn i svar og videresendinger. Over den siterte teksten fungerer den som en naturlig avslutning på svaret. Under den siterte teksten holdes den opprinnelige meldingen samlet.", diff --git a/locales/nl/common.json b/locales/nl/common.json index f27bbb48f..16d1fe35a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1327,6 +1327,12 @@ "label": "Antwoordadres automatisch selecteren", "description": "Schakel bij het beantwoorden automatisch het Van-adres om naar de identiteit die het oorspronkelijke bericht ontving" }, + "reply_identity_match": { + "label": "Overeenkomst van ontvangstadres", + "description": "Welke ontvangstadressen als de uwe gelden. „Alleen exact adres“ kiest uitsluitend een van uw ingestelde identiteiten; „zelfde domein“ behandelt ook elk ander adres op uw identiteitsdomeinen als catch-all-alias en herschrijft de Van-header ernaar. Kies exact adres als die adressen distributielijsten zijn.", + "exact": "Alleen exact adres", + "domain": "Elk adres op mijn domeinen" + }, "signature_position": { "label": "Positie van handtekening", "description": "Waar je handtekening in antwoorden en doorgestuurde berichten moet worden ingevoegd. Boven de geciteerde tekst leest natuurlijk als afsluiting van het antwoord; eronder houdt het originele bericht aaneengesloten.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 69b0cb99f..9a2d01412 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1332,6 +1332,12 @@ "label": "Automatycznie wybieraj adres odpowiedzi", "description": "Podczas odpowiadania automatycznie przełączaj adres nadawcy na tożsamość, która pierwotnie otrzymała wiadomość" }, + "reply_identity_match": { + "label": "Dopasowanie adresu odbioru", + "description": "Które adresy odbioru są uznawane za Twoje. „Tylko dokładny adres” wybiera wyłącznie jedną ze skonfigurowanych tożsamości; „ta sama domena” traktuje dodatkowo każdy inny adres w domenach Twoich tożsamości jako alias catch-all i przepisuje na niego nagłówek Od. Wybierz dokładny adres, jeśli te adresy to listy dystrybucyjne.", + "exact": "Tylko dokładny adres", + "domain": "Dowolny adres w moich domenach" + }, "signature_position": { "label": "Pozycja podpisu", "description": "Gdzie wstawić podpis w odpowiedziach i wiadomościach przekazanych dalej. Nad cytowanym tekstem brzmi naturalnie jako zakończenie odpowiedzi; pod nim zachowuje oryginalną wiadomość w całości.", diff --git a/locales/pt/common.json b/locales/pt/common.json index d1970d94f..6d9094471 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1327,6 +1327,12 @@ "label": "Selecionar automaticamente o endereço de resposta", "description": "Ao responder, muda automaticamente o endereço do remetente para a identidade que recebeu a mensagem original" }, + "reply_identity_match": { + "label": "Correspondência do endereço de receção", + "description": "Quais endereços de receção contam como seus. «Apenas endereço exato» escolhe somente uma das suas identidades configuradas; «mesmo domínio» trata também qualquer outro endereço dos seus domínios de identidade como alias catch-all e reescreve o cabeçalho De com ele. Escolha o endereço exato se esses endereços forem listas de distribuição.", + "exact": "Apenas endereço exato", + "domain": "Qualquer endereço dos meus domínios" + }, "signature_position": { "label": "Posição da assinatura", "description": "Onde inserir a sua assinatura em respostas e encaminhamentos. Acima do texto citado lê-se naturalmente como fecho da resposta; abaixo mantém a mensagem original contígua.", diff --git a/locales/ro/common.json b/locales/ro/common.json index f5f9dd539..6265ee4b6 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1335,6 +1335,12 @@ "label": "Răspunde de la adresa de la care a fost primit mesajul", "description": "Când răspundeți, trimiteți mesajul de la adresa la care a fost trimis inițial. Se potrivesc mai întâi identitățile; pentru livrările de tip „catch-all” ale domeniului, antetul „De la” este rescris cu aliasul, în timp ce trimiterea se face prin identitatea dvs. principală." }, + "reply_identity_match": { + "label": "Potrivirea adresei de primire", + "description": "Care adrese de primire sunt considerate ale tale. „Doar adresa exactă” alege numai una dintre identitățile configurate; „același domeniu” tratează și orice altă adresă de pe domeniile identităților tale ca alias catch-all și rescrie antetul De la cu ea. Alege adresa exactă dacă aceste adrese sunt liste de distribuție.", + "exact": "Doar adresa exactă", + "domain": "Orice adresă de pe domeniile mele" + }, "signature_position": { "label": "Poziția semnăturii", "description": "Unde să inserați semnătura în răspunsuri și redirecționări. Deasupra textului citat, semnătura se citește în mod natural ca o încheiere a răspunsului; sub text, mesajul original rămâne contiguu.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 3a51f6e36..3e778846e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1327,6 +1327,12 @@ "label": "Автоматически выбирать адрес для ответа", "description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение" }, + "reply_identity_match": { + "label": "Сопоставление адреса получения", + "description": "Какие адреса получения считать вашими. «Только точный адрес» выбирает исключительно одну из настроенных личностей; «тот же домен» также считает любой другой адрес на доменах ваших личностей catch-all-псевдонимом и подставляет его в заголовок «От». Выберите точный адрес, если такие адреса являются списками рассылки.", + "exact": "Только точный адрес", + "domain": "Любой адрес на моих доменах" + }, "signature_position": { "label": "Положение подписи", "description": "Куда вставлять подпись в ответах и пересылке. Над цитируемым текстом она читается естественно как завершение ответа; под ним сохраняет целостность исходного сообщения.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 38a9537ed..c3b87d53e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1335,6 +1335,12 @@ "label": "Odpovedať z prijatej adresy", "description": "Pri odpovedaní automaticky prepnúť adresu odosielateľa na identitu, ktorá pôvodne prijala správu" }, + "reply_identity_match": { + "label": "Porovnávanie prijímacej adresy", + "description": "Ktoré prijímacie adresy sa považujú za vaše. „Iba presná adresa“ vyberie len jednu z vašich nastavených identít; „rovnaká doména“ navyše považuje akúkoľvek inú adresu na doménach vašich identít za catch-all alias a prepíše na ňu hlavičku Od. Ak sú tieto adresy distribučné zoznamy, zvoľte presnú adresu.", + "exact": "Iba presná adresa", + "domain": "Akákoľvek adresa na mojich doménach" + }, "signature_position": { "label": "Pozícia podpisu", "description": "Kam vložiť podpis v odpovediach a preposlaniach.", diff --git a/locales/tr/common.json b/locales/tr/common.json index 8df0a36a7..214db7ce3 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1332,6 +1332,12 @@ "label": "Yanıt Adresini Otomatik Seç", "description": "Yanıtlarken, Kimden adresini iletiyi başlangıçta alan kimliğe otomatik olarak değiştir" }, + "reply_identity_match": { + "label": "Alıcı adresi eşleştirme", + "description": "Hangi alıcı adreslerinin sizin sayılacağı. „Yalnızca tam adres“ sadece yapılandırılmış kimliklerinizden birini seçer; „aynı alan adı“ ayrıca kimlik alan adlarınızdaki diğer tüm adresleri catch-all takma adı olarak değerlendirir ve Kimden başlığını buna göre yeniden yazar. Bu adresler dağıtım listesiyse tam adresi seçin.", + "exact": "Yalnızca tam adres", + "domain": "Alan adlarımdaki herhangi bir adres" + }, "signature_position": { "label": "İmza konumu", "description": "Yanıtlarda ve iletmelerde imzanızın nereye ekleneceği. Alıntılanan metnin üzerinde, yanıt için doğal bir kapanış olarak okunur; altında ise orijinal mesajı bir bütün hâlinde tutar.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 1910a3c77..cf5864d6c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1332,6 +1332,12 @@ "label": "Автоматичний вибір адреси для відповіді", "description": "Під час відповіді автоматично змінюйте адресу відправника на особу, яка спочатку отримала повідомлення" }, + "reply_identity_match": { + "label": "Зіставлення адреси отримання", + "description": "Які адреси отримання вважати вашими. «Лише точна адреса» вибирає виключно одну з налаштованих ідентичностей; «той самий домен» також вважає будь-яку іншу адресу на доменах ваших ідентичностей catch-all-псевдонімом і підставляє її в заголовок «Від». Виберіть точну адресу, якщо такі адреси є списками розсилки.", + "exact": "Лише точна адреса", + "domain": "Будь-яка адреса на моїх доменах" + }, "signature_position": { "label": "Розташування підпису", "description": "Куди вставляти підпис у відповідях і пересиланнях. Над цитованим текстом читається природно як завершення відповіді; під ним зберігає цілісність оригінального повідомлення.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b2bdfc864..d5f1fa125 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1335,6 +1335,12 @@ "label": "使用收件地址回覆", "description": "回覆時,使用原始郵件的收件地址寄送。系統會先比對寄件身分;若是網域 catch-all 收件,則透過主要寄件身分傳送,但將 From 標頭改寫為別名地址。" }, + "reply_identity_match": { + "label": "接收地址比對", + "description": "哪些接收地址算作您的地址。「僅精確地址」只從已設定的身分中選擇;「相同網域」還會將身分網域下的其他任何地址視為 catch-all 別名,並將寄件者標頭改寫為該地址。如果這些地址是通訊群組清單,請選擇精確地址。", + "exact": "僅精確地址", + "domain": "我的網域下的任意地址" + }, "signature_position": { "label": "簽名檔位置", "description": "在回覆與轉寄中插入簽名檔的位置。放在引用文字上方可自然作為回覆結尾;放在下方則能保持原始郵件連續。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 1eeba3c9c..b05b89763 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1332,6 +1332,12 @@ "label": "自动选择回复地址", "description": "回复时自动将发件人地址切换为最初收到该邮件的身份" }, + "reply_identity_match": { + "label": "接收地址匹配", + "description": "哪些接收地址算作您的地址。“仅精确地址”只从已配置的身份中选择;“相同域名”还会将身份域名下的其他任何地址视为 catch-all 别名,并将发件人标头改写为该地址。如果这些地址是分发列表,请选择精确地址。", + "exact": "仅精确地址", + "domain": "我的域名下的任意地址" + }, "signature_position": { "label": "签名位置", "description": "在回复和转发中插入签名的位置。位于引用文本上方时,可作为回复的自然结尾;位于下方时,保持原始邮件连贯。", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index dd40f66c0..c4fda83b6 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -102,6 +102,7 @@ export type ListDensity = Density; export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent'; export type ReplyMode = 'reply' | 'replyAll'; export type SignaturePosition = 'above_quote' | 'below_quote'; +export type ReplyIdentityMatch = 'exact' | 'domain'; /** How to handle an incoming Disposition-Notification-To (read-receipt) request. */ export type ReadReceiptResponse = 'ask' | 'always' | 'never'; export type DateFormat = 'smart' | 'relative' | 'full'; @@ -343,6 +344,7 @@ interface SettingsState { sendConfirmation: boolean; defaultReplyMode: ReplyMode; autoSelectReplyIdentity: boolean; + replyIdentityMatch: ReplyIdentityMatch; // With autoSelectReplyIdentity on: 'exact' = configured identities only, 'domain' = also same-domain catch-all addresses (rewrites From) #1000 plainTextMode: boolean; // Send plain text only (no rich text editor) rtlEditingSupport: boolean; // Show a per-paragraph LTR/RTL direction control in the composer (Gmail-style) subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@") @@ -582,6 +584,7 @@ const DEFAULT_SETTINGS = { sendConfirmation: false, defaultReplyMode: 'reply' as ReplyMode, autoSelectReplyIdentity: false, + replyIdentityMatch: 'domain' as ReplyIdentityMatch, plainTextMode: false, rtlEditingSupport: false, subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, @@ -808,6 +811,7 @@ export const useSettingsStore = create()( sendConfirmation: state.sendConfirmation, defaultReplyMode: state.defaultReplyMode, autoSelectReplyIdentity: state.autoSelectReplyIdentity, + replyIdentityMatch: state.replyIdentityMatch, plainTextMode: state.plainTextMode, rtlEditingSupport: state.rtlEditingSupport, subAddressDelimiter: state.subAddressDelimiter, 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 6/6] 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,