mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -727,6 +727,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
bcc: field === "bcc" ? recipients : [],
|
||||
subject: "",
|
||||
body: "",
|
||||
inApp: true,
|
||||
});
|
||||
router.push("/");
|
||||
}, [router]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user