From b7d68191dde43115f730a4acceb5a9c8fa1cce26 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:20:37 +0100 Subject: [PATCH] fix: keep accented names in vCard file names, show an imported card's own name, stop starring both name fields, and compose from Contacts without asking for an account --- .../contacts/__tests__/contact-export.test.ts | 9 +++++ .../contacts/__tests__/contact-form.test.tsx | 4 +-- .../contacts/__tests__/contacts-app.test.tsx | 8 +++++ components/contacts/contact-export.tsx | 10 ++++-- components/contacts/contact-form.tsx | 10 +++--- components/contacts/contact-list.tsx | 33 ++++++++++--------- components/contacts/contacts-app.tsx | 1 + components/mail/mail-app.tsx | 5 +++ lib/protocol-handlers/mailto.ts | 5 +++ stores/__tests__/contact-store.test.ts | 10 ++++++ stores/contact-store.ts | 7 ++-- 11 files changed, 74 insertions(+), 28 deletions(-) diff --git a/components/contacts/__tests__/contact-export.test.ts b/components/contacts/__tests__/contact-export.test.ts index 1106e5bcc..db0734444 100644 --- a/components/contacts/__tests__/contact-export.test.ts +++ b/components/contacts/__tests__/contact-export.test.ts @@ -124,3 +124,12 @@ describe('vCard export of contact photos', () => { expect(await exported!.text()).toContain('FN:Contact 1'); }); }); + +describe('vCard file names', () => { + it('keep letters of any script instead of turning them into underscores', async () => { + const { sanitizeFilename } = await import('../contact-export'); + expect(sanitizeFilename('Jürgen Müller')).toBe('Jürgen_Müller'); + expect(sanitizeFilename('李 雷')).toBe('李_雷'); + expect(sanitizeFilename('a/b:c')).toBe('a_b_c'); + }); +}); diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index 3ad1693e4..6550dc98b 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -44,7 +44,7 @@ describe('ContactForm', () => { render(); fireEvent.submit(screen.getByText('save').closest('form')!); await waitFor(() => { - expect(screen.getByText('name_required')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('name_required'); }); expect(onSave).not.toHaveBeenCalled(); }); @@ -460,7 +460,7 @@ describe('ContactForm', () => { fireEvent.submit(screen.getByText('save').closest('form')!); await waitFor(() => { - expect(screen.getByText('name_required')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('name_required'); }); expect(onSave).not.toHaveBeenCalled(); }); diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index d7f304227..1ff1e1d2a 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -235,4 +235,12 @@ describe('ContactsApp', () => { fireEvent.click(screen.getByRole('button', { name: /import.title/ })); expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument(); }); + + it("marks a message started from Contacts as the app's own, so Mail does not ask for an account", async () => { + const { consumePendingMailto } = await import('@/lib/protocol-handlers/session'); + renderContacts(); + fireEvent.contextMenu(screen.getByText('Alice Smith')); + fireEvent.click(screen.getByText('context_menu.send_email')); + expect(consumePendingMailto()).toMatchObject({ to: [expect.stringContaining('alice@example.com')], inApp: true }); + }); }); diff --git a/components/contacts/contact-export.tsx b/components/contacts/contact-export.tsx index ba67c4988..3bc6bcedd 100644 --- a/components/contacts/contact-export.tsx +++ b/components/contacts/contact-export.tsx @@ -67,6 +67,12 @@ function downloadVcf(content: string, filename: string) { URL.revokeObjectURL(url); } -function sanitizeFilename(name: string): string { - return name.replace(/[^a-zA-Z0-9_-]/g, "_").substring(0, 50); +// Letters of any script stay ("Jürgen_Müller.vcf"); only what file systems +// refuse or treat specially becomes "_". +export function sanitizeFilename(name: string): string { + const safe = Array.from(name.normalize("NFC"), (ch) => (ch.charCodeAt(0) < 32 || '\\/:*?"<>|.'.includes(ch) || /\s/.test(ch) ? "_" : ch)) + .join("") + .replace(/_+/g, "_") + .replace(/^_|_$/g, ""); + return safe.substring(0, 50) || "contact"; } diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index df0b837e5..60e1bce1e 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -821,15 +821,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
- + setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
- + setSurname(e.target.value)} placeholder={t("surname")} />
@@ -837,6 +833,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
+ {/* Either name is enough (or an organization), so neither is starred. */} +

{t("name_required")}

diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 211d23968..6833b8d1e 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -577,7 +577,8 @@ export function ContactList({ {/* Bulk action bar */} {hasSelection && ( -
+ // One line at any width: below 28rem the buttons show only their icons. +
- + {t("bulk.selected", { count: selectedContactIds.size })}
- {onMoveContacts && ( - )} - diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 6fae16132..bad67db5c 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -727,6 +727,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = bcc: field === "bcc" ? recipients : [], subject: "", body: "", + inApp: true, }); router.push("/"); }, [router]); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 22682245f..3c486e6b0 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -1425,6 +1425,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => { if (!(await mailtoAllowed(pending))) return; + // From another app of this webmail: the account in use, no question. + if (pending.inApp) { + openMailtoDraft(pending); + return; + } const protocolAccounts = getMailtoProtocolAccounts(); if (protocolAccounts.length > 1) { setPendingMailtoAccountChoice(pending); diff --git a/lib/protocol-handlers/mailto.ts b/lib/protocol-handlers/mailto.ts index 76afa09a8..8cb217dd8 100644 --- a/lib/protocol-handlers/mailto.ts +++ b/lib/protocol-handlers/mailto.ts @@ -14,6 +14,11 @@ export interface ParsedMailto { htmlBody?: string; /** Only from api.compose.open: files the plugin uploaded, attached as they are. */ attachments?: Array<{ blobId: string; name: string; type: string; size: number }>; + /** + * Started in the app itself (Contacts' "Send email"), not by the OS: it is + * written from the account in use, without asking which one. + */ + inApp?: boolean; } const MAX_RECIPIENTS = 200; diff --git a/stores/__tests__/contact-store.test.ts b/stores/__tests__/contact-store.test.ts index 090c88ccb..1a6fb072d 100644 --- a/stores/__tests__/contact-store.test.ts +++ b/stores/__tests__/contact-store.test.ts @@ -1434,3 +1434,13 @@ describe('contact-store', () => { }); }); }); + +describe('contact display name', () => { + it('shows the name an imported card gives itself (FN), not only first and last name', async () => { + const { getContactDisplayName } = await import('../contact-store'); + expect(getContactDisplayName({ + id: 'x', addressBookIds: {}, + name: { full: 'UX-cf Jürgen Müller', components: [{ kind: 'given', value: 'Jürgen' }, { kind: 'surname', value: 'Müller' }] }, + } as never)).toBe('UX-cf Jürgen Müller'); + }); +}); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index f9a550975..9cb62dea7 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -178,15 +178,16 @@ function resolveRawAddressBookIds( export function getContactDisplayName(contact: ContactCard): string { if (contact.name) { - // Try given + surname from components first + // The card's own display name (RFC 9553 name.full, vCard FN) first: an + // imported "Dr. Jürgen Müller" stays that, and this app's form keeps it + // in step with the parts it writes. + if (contact.name.full?.trim()) return contact.name.full.trim(); if (contact.name.components && contact.name.components.length > 0) { const given = contact.name.components.find(c => c.kind === 'given')?.value || ''; const surname = contact.name.components.find(c => c.kind === 'surname')?.value || ''; const full = [given, surname].filter(Boolean).join(' '); if (full) return full; } - // Fall back to name.full (RFC 9553 - used by Stalwart and other JMAP servers) - if (contact.name.full) return contact.name.full; } if (contact.nicknames) { const nick = Object.values(contact.nicknames)[0];