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

This commit is contained in:
Linus Rath
2026-10-28 05:20:37 +01:00
parent b540b68706
commit b7d68191dd
11 changed files with 74 additions and 28 deletions
@@ -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');
});
});
@@ -44,7 +44,7 @@ describe('ContactForm', () => {
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
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();
});
@@ -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 });
});
});
+8 -2
View File
@@ -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";
}
+4 -6
View File
@@ -821,15 +821,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("given_name")} <span className="text-destructive">*</span>
</label>
<label className="text-xs text-muted-foreground mb-1 block">{t("given_name")}</label>
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("surname")} <span className="text-destructive">*</span>
</label>
<label className="text-xs text-muted-foreground mb-1 block">{t("surname")}</label>
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
</div>
<div>
@@ -837,6 +833,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
</div>
</div>
{/* Either name is enough (or an organization), so neither is starred. */}
<p className="text-xs text-muted-foreground -mt-1">{t("name_required")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
+18 -15
View File
@@ -577,7 +577,8 @@ export function ContactList({
{/* Bulk action bar */}
{hasSelection && (
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
// One line at any width: below 28rem the buttons show only their icons.
<div className="@container px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-1 flex-nowrap min-w-0">
<button
type="button"
role="checkbox"
@@ -599,34 +600,36 @@ export function ContactList({
<Square className="w-4 h-4 text-muted-foreground" />
)}
</button>
<span className="text-xs font-medium text-foreground">
<span className="text-xs font-medium text-foreground whitespace-nowrap">
{t("bulk.selected", { count: selectedContactIds.size })}
</span>
<div className="flex-1" />
<Button variant="ghost" size="xs" onClick={onBulkAddToGroup}>
<Users className="w-3.5 h-3.5 me-1" />
{t("bulk.add_to_group")}
<Button variant="ghost" size="xs" onClick={onBulkAddToGroup} aria-label={t("bulk.add_to_group")} title={t("bulk.add_to_group")} className="shrink-0">
<Users className="w-3.5 h-3.5 @md:me-1" />
<span className="hidden @md:inline">{t("bulk.add_to_group")}</span>
</Button>
{onMoveContacts && (
<Button variant="ghost" size="xs" onClick={() => onMoveContacts([...selectedContactIds])}>
<FolderInput className="w-3.5 h-3.5 me-1" />
{t("address_books.move_to")}
<Button variant="ghost" size="xs" onClick={() => onMoveContacts([...selectedContactIds])} aria-label={t("address_books.move_to")} title={t("address_books.move_to")} className="shrink-0">
<FolderInput className="w-3.5 h-3.5 @md:me-1" />
<span className="hidden @md:inline">{t("address_books.move_to")}</span>
</Button>
)}
<Button variant="ghost" size="xs" onClick={onBulkExport} disabled={exporting} aria-busy={exporting}>
<Button variant="ghost" size="xs" onClick={onBulkExport} disabled={exporting} aria-busy={exporting} aria-label={t("bulk.export")} title={t("bulk.export")} className="shrink-0">
{exporting
? <Loader2 className="w-3.5 h-3.5 me-1 animate-spin" />
: <Download className="w-3.5 h-3.5 me-1" />}
{exporting ? t("export.exporting") : t("bulk.export")}
? <Loader2 className="w-3.5 h-3.5 @md:me-1 animate-spin" />
: <Download className="w-3.5 h-3.5 @md:me-1" />}
<span className="hidden @md:inline">{exporting ? t("export.exporting") : t("bulk.export")}</span>
</Button>
<Button
variant="ghost"
size="xs"
onClick={onBulkDelete}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
aria-label={t("bulk.delete")}
title={t("bulk.delete")}
className="shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-3.5 h-3.5 me-1" />
{t("bulk.delete")}
<Trash2 className="w-3.5 h-3.5 @md:me-1" />
<span className="hidden @md:inline">{t("bulk.delete")}</span>
</Button>
<PluginActions surface="contact-list-actions" context={pluginContext} />
<IconButton label={t("bulk.clear")} onClick={onClearSelection}>
+1
View File
@@ -727,6 +727,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
bcc: field === "bcc" ? recipients : [],
subject: "",
body: "",
inApp: true,
});
router.push("/");
}, [router]);
+5
View File
@@ -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);
+5
View File
@@ -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;
+10
View File
@@ -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');
});
});
+4 -3
View File
@@ -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];