From 6f371deaa79e12a6f28a734b29fc7311b952ff46 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:28:09 +0100 Subject: [PATCH] fix: show contact dates, print headings and the disabled notice in the app's language --- .../contacts/__tests__/contact-print.test.ts | 24 ++++- components/contacts/contact-context-menu.tsx | 7 +- components/contacts/contact-detail.tsx | 77 ++------------- components/contacts/contact-list-item.tsx | 6 +- components/contacts/contact-print.ts | 98 ++++++++++++------- components/contacts/contacts-app.tsx | 6 +- lib/__tests__/contact-dates.test.ts | 15 +++ lib/contact-dates.ts | 45 +++++++++ locales/ar/common.json | 3 + locales/ca/common.json | 3 + locales/cs/common.json | 3 + locales/da/common.json | 3 + locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/et/common.json | 3 + locales/fa/common.json | 3 + locales/fr/common.json | 3 + locales/he/common.json | 3 + locales/hu/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/ko/common.json | 3 + locales/lv/common.json | 3 + locales/mn/common.json | 3 + locales/nb/common.json | 3 + locales/nl/common.json | 3 + locales/pl/common.json | 3 + locales/pt/common.json | 3 + locales/ro/common.json | 3 + locales/ru/common.json | 3 + locales/sk/common.json | 3 + locales/th/common.json | 3 + locales/tr/common.json | 3 + locales/uk/common.json | 3 + locales/zh-TW/common.json | 3 + locales/zh/common.json | 3 + 37 files changed, 251 insertions(+), 114 deletions(-) create mode 100644 lib/__tests__/contact-dates.test.ts create mode 100644 lib/contact-dates.ts diff --git a/components/contacts/__tests__/contact-print.test.ts b/components/contacts/__tests__/contact-print.test.ts index 9a6ced8ef..e229e4100 100644 --- a/components/contacts/__tests__/contact-print.test.ts +++ b/components/contacts/__tests__/contact-print.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { printContact } from '../contact-print'; +import { printContact, printLabels } from '../contact-print'; import { useAuthStore } from '@/stores/auth-store'; import { clearContactPhotoCache } from '@/lib/contact-photo-cache'; import type { ContactCard } from '@/lib/jmap/types'; +const LABELS = printLabels((key) => key, 'en'); + const base: ContactCard = { id: 'c1', addressBookIds: {}, @@ -36,7 +38,7 @@ describe('printContact', () => { it('opens the window right away and prints a Stalwart 1.0 photo inline as a data: URI', async () => { fetchBlob.mockResolvedValue(new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' })); - const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } } }); + const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } } }, LABELS); // Before anything is awaited, while the click still allows a popup. expect(open).toHaveBeenCalledTimes(1); await done; @@ -47,7 +49,7 @@ describe('printContact', () => { }); it('prints a Stalwart 0.16 photo as it is', async () => { - await printContact({ ...base, media: { photo: { kind: 'photo', uri: 'data:base64,/9j/' } } }); + await printContact({ ...base, media: { photo: { kind: 'photo', uri: 'data:base64,/9j/' } } }, LABELS); expect(fetchBlob).not.toHaveBeenCalled(); expect(written()).toContain('src="data:image/jpeg;base64,/9j/"'); @@ -57,7 +59,7 @@ describe('printContact', () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); fetchBlob.mockRejectedValue(new Error('Failed to fetch blob: 404')); - const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcGone' } } }); + const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcGone' } } }, LABELS); await vi.advanceTimersByTimeAsync(1000); await done; @@ -68,9 +70,21 @@ describe('printContact', () => { it('does not write into a window the user already closed', async () => { fetchBlob.mockResolvedValue(new Blob(['x'], { type: 'image/jpeg' })); - const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX' } } }); + const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX' } } }, LABELS); printWindow.closed = true; await done; expect(printWindow.document.write).not.toHaveBeenCalled(); }); + + it('prints the headings and dates in the language of the app', async () => { + const de = printLabels((key) => ({ 'detail.emails': 'E-Mail-Adressen', 'detail.anniversaries': 'Jahrestage', 'detail.anniversary_birth': 'Geburtstag' } as Record)[key] ?? key, 'de'); + await printContact({ + ...base, + emails: { e: { address: 'ann@example.com' } }, + anniversaries: { b: { kind: 'birth', date: { year: 1988, month: 3, day: 15 } } }, + } as ContactCard, de); + expect(written()).toContain('

E-Mail-Adressen

'); + expect(written()).toContain('
Geburtstag
15. März 1988
'); + expect(written()).not.toContain('>Email<'); + }); }); diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx index c79b088b0..db1f2058e 100644 --- a/components/contacts/contact-context-menu.tsx +++ b/components/contacts/contact-context-menu.tsx @@ -1,6 +1,6 @@ "use client"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { ContextMenu, ContextMenuItem, @@ -24,7 +24,7 @@ import { import type { ContactCard } from "@/lib/jmap/types"; import { getContactPrimaryEmail } from "@/stores/contact-store"; import { exportContact, useContactExportBusy } from "./contact-export"; -import { printContact } from "./contact-print"; +import { printContact, printLabels } from "./contact-print"; import { toast } from "@/stores/toast-store"; function getContactPrimaryPhone(contact: ContactCard): string { @@ -84,6 +84,7 @@ export function ContactContextMenu({ onBatchDelete, }: ContactContextMenuProps) { const t = useTranslations("contacts"); + const locale = useLocale(); const email = getContactPrimaryEmail(contact); const phone = getContactPrimaryPhone(contact); const showBatchActions = isMultiSelect && selectedCount > 1; @@ -116,7 +117,7 @@ export function ContactContextMenu({ }; const handlePrint = () => { - void printContact(contact); + void printContact(contact, printLabels(t, locale)); }; if (showBatchActions) { diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index d84cecca1..91a35b2a8 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -1,19 +1,20 @@ "use client"; import { useState, useEffect, useRef, useMemo } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; +import { contactDateParts, formatContactDate } from "@/lib/contact-dates"; import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer, FolderInput } from "@/components/icons"; import { Avatar } from "@/components/ui/avatar"; import { MailtoLink } from "@/components/ui/mailto-link"; import { Button } from "@/components/ui/button"; import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { cn } from "@/lib/utils"; -import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; +import type { ContactCard, AnniversaryDate } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef } from "@/stores/contact-store"; import { ContactActivity } from "./contact-activity"; import { toast } from "@/stores/toast-store"; import { exportContact, useContactExportBusy } from "./contact-export"; -import { printContact } from "./contact-print"; +import { printContact, printLabels } from "./contact-print"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { contactHooks } from "@/lib/plugin-hooks"; @@ -66,32 +67,8 @@ function formatPhoneFeatures(features: Record | undefined, labe return Object.keys(features).filter(k => features[k]).map(label).join(", "); } -function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } { - if (typeof dateInput === "object" && dateInput !== null) { - if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") { - const d = new Date(dateInput.utc); - if (!isNaN(d.getTime())) { - return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }; - } - return {}; - } - const pd = dateInput as PartialDate; - return { year: pd.year, month: pd.month, day: pd.day }; - } - const s = String(dateInput); - if (s.startsWith("--")) { - const parts = s.substring(2).split("-"); - return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined }; - } - const d = new Date(s); - if (!isNaN(d.getTime())) { - return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() }; - } - return {}; -} - function getCompletedYears(dateInput: AnniversaryDate): number | null { - const { year, month, day } = getDateParts(dateInput); + const { year, month, day } = contactDateParts(dateInput); if (!year) return null; const now = new Date(); let years = now.getFullYear() - year; @@ -104,44 +81,10 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null { return years; } -function formatDate(dateInput: AnniversaryDate): string { - if (typeof dateInput === 'object' && dateInput !== null) { - if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') { - try { - const d = new Date(dateInput.utc as string); - if (!isNaN(d.getTime())) { - return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); - } - } catch { /* fallback */ } - return String(dateInput.utc); - } - const pd = dateInput as PartialDate; - const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - const parts: string[] = []; - if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]); - if (pd.day) parts.push(String(pd.day)); - if (pd.year) parts.push(String(pd.year)); - return parts.join(' ') || String(dateInput); - } - const dateStr = String(dateInput); - if (dateStr.startsWith("--")) { - const parts = dateStr.substring(2).split("-"); - const month = parseInt(parts[0], 10); - const day = parts[1] ? parseInt(parts[1], 10) : undefined; - const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - return day ? `${monthNames[month - 1]} ${day}` : monthNames[month - 1]; - } - try { - const d = new Date(dateStr); - if (!isNaN(d.getTime())) { - return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); - } - } catch { /* fallback */ } - return dateStr; -} export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, isMobile, className }: ContactDetailProps) { const t = useTranslations("contacts"); + const locale = useLocale(); const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? t(CONTEXT_LABEL_KEYS[key]) : key); const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? t(PHONE_FEATURE_LABEL_KEYS[key]) : key); @@ -179,7 +122,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli }; const handlePrint = () => { - void printContact(contact, name); + void printContact(contact, printLabels(t, locale), name); }; const moreItems: MoreItem[] = []; @@ -396,7 +339,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli return (
- {formatDate(ann.date)} + {formatContactDate(ann.date, locale)} {years !== null && ( · {t(suffixKey, { count: years })} )} @@ -543,8 +486,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli {(contact.created || contact.updated) && (
- {contact.created &&
{t("detail.created")}: {formatDate(contact.created)}
} - {contact.updated &&
{t("detail.updated")}: {formatDate(contact.updated)}
} + {contact.created &&
{t("detail.created")}: {formatContactDate(contact.created, locale)}
} + {contact.updated &&
{t("detail.updated")}: {formatContactDate(contact.updated, locale)}
}
)}
diff --git a/components/contacts/contact-list-item.tsx b/components/contacts/contact-list-item.tsx index e3b6e4923..78be0a7b5 100644 --- a/components/contacts/contact-list-item.tsx +++ b/components/contacts/contact-list-item.tsx @@ -2,6 +2,7 @@ import { useCallback, useRef, type DragEvent } from "react"; import { useLongPress } from "@/hooks/use-long-press"; +import { useTranslations } from "next-intl"; import { Avatar } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; import type { ContactCard } from "@/lib/jmap/types"; @@ -31,6 +32,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection, const longPress = useLongPress(() => { if (rowRef.current) onLongPress?.(rowRef.current); }, !!onLongPress); + const t = useTranslations("contacts"); const name = getContactDisplayName(contact); const email = getContactPrimaryEmail(contact); const org = contact.organizations @@ -56,11 +58,11 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection, box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-size: 14px; font-weight: 500; z-index: 9999; white-space: nowrap; pointer-events: none; `; - preview.textContent = ids.length === 1 ? (name || "1 contact") : `${ids.length} contacts`; + preview.textContent = ids.length === 1 && name ? name : t("drag_count", { count: ids.length }); document.body.appendChild(preview); e.dataTransfer.setDragImage(preview, 0, 0); requestAnimationFrame(() => preview.remove()); - }, [contact.id, name, email, selectedContactIds]); + }, [contact.id, name, email, selectedContactIds, t]); return (
undefined) : undefined; } -export async function printContact(contact: ContactCard, displayName?: string): Promise { +/** The print sheet's words, in the UI language. */ +export interface PrintLabels { + locale: string; + contact: string; + emails: string; + email: string; + phones: string; + phone: string; + organizations: string; + organization: string; + titles: string; + title: string; + role: string; + addresses: string; + address: string; + online: string; + onlineService: string; + anniversaries: string; + anniversary: (kind: string) => string; + notes: string; +} + +/** The print labels from the contacts translations (`t` of the "contacts" namespace). */ +export function printLabels(t: (key: string) => string, locale: string): PrintLabels { + return { + locale, + contact: t("detail.section_contact"), + emails: t("detail.emails"), + email: t("detail.email_default_label"), + phones: t("detail.phones"), + phone: t("detail.phone_default_label"), + organizations: t("detail.organizations"), + organization: t("detail.organization_label"), + titles: t("detail.titles"), + title: t("detail.title_label"), + role: t("detail.role_label"), + addresses: t("detail.addresses"), + address: t("detail.address_default_label"), + online: t("detail.online_services"), + onlineService: t("detail.online_service_default_label"), + anniversaries: t("detail.anniversaries"), + anniversary: (kind) => t(`detail.anniversary_${["birth", "death", "wedding"].includes(kind) ? kind : "other"}`), + notes: t("detail.notes"), + }; +} + +export async function printContact(contact: ContactCard, labels: PrintLabels, displayName?: string): Promise { // Open the window before anything is awaited: browsers allow the popup only // while the click that asked for it is being handled. const printWindow = window.open("", "_blank"); @@ -63,62 +110,45 @@ export async function printContact(contact: ContactCard, displayName?: string): const row = (label: string, value: string) => `
${escapeHtml(label)}
${escapeHtml(value)}
`; - section("Email", emails.map(e => row(e.label || "Email", e.address || ""))); - section("Phone", phones.map(p => row(p.label || "Phone", p.number || ""))); + section(labels.emails, emails.map(e => row(e.label || labels.email, e.address || ""))); + section(labels.phones, phones.map(p => row(p.label || labels.phone, p.number || ""))); section( - "Organization", + labels.organizations, orgs.map(o => { const units = o.units ? Object.values(o.units).map(u => u.name).filter(Boolean).join(", ") : ""; const value = [o.name, units].filter(Boolean).join(" \u2014 "); - return row("Organization", value); + return row(labels.organization, value); }), ); - section("Title", titles.map(t => row(t.kind === "role" ? "Role" : "Title", t.name || ""))); + section(labels.titles, titles.map(t => row(t.kind === "role" ? labels.role : labels.title, t.name || ""))); section( - "Address", + labels.addresses, addresses.map(a => { const parts = [a.street, a.locality, a.region, a.postcode, a.country] .map(p => (typeof p === "string" ? p : "")) .filter(Boolean); - return row(a.label || "Address", parts.join(", ")); + return row(a.label || labels.address, parts.join(", ")); }), ); section( - "Online", - onlineServices.map(s => row(s.label || s.service || "Online", s.uri || s.user || "")), + labels.online, + onlineServices.map(s => row(s.label || s.service || labels.onlineService, s.uri || s.user || "")), ); section( - "Anniversary", - anniversaries.map(a => { - const date = a.date; - let formatted = ""; - if (typeof date === "object" && date !== null) { - if ("utc" in date && typeof date.utc === "string") formatted = date.utc; - else { - const parts: string[] = []; - const pd = date as PartialDate; - if (pd.year) parts.push(String(pd.year)); - if (pd.month) parts.push(String(pd.month).padStart(2, "0")); - if (pd.day) parts.push(String(pd.day).padStart(2, "0")); - formatted = parts.join("-"); - } - } else if (date) { - formatted = String(date); - } - return row(a.kind || "Date", formatted); - }), + labels.anniversaries, + anniversaries.map(a => row(labels.anniversary(a.kind || "other"), a.date ? formatContactDate(a.date, labels.locale) : "")), ); - section("Notes", notes.map(n => row("", n.note || ""))); + section(labels.notes, notes.map(n => row("", n.note || ""))); const photoTag = photoUri ? `` : `
${escapeHtml((name || "?").charAt(0).toUpperCase())}
`; const html = ` - + -${escapeHtml(name || "Contact")} +${escapeHtml(name || labels.contact)}