From 179d3994bbdb421e9a54cb27658d6c27b1914e8a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:57:46 +0100 Subject: [PATCH 1/5] feat: refresh Calendar, Contacts and Files with F9 in the desktop app, and move through the calendar from Go --- .../calendar-desktop-menu-ux-pim.test.tsx | 30 +++++++++++++ components/calendar/calendar-app.tsx | 9 +++- .../use-refresh-gesture-desktop-menu.test.tsx | 42 +++++++++++++++++++ hooks/use-refresh-gesture.tsx | 7 ++++ lib/desktop/languages.ts | 4 ++ locales/ar/common.json | 12 ++++++ locales/ca/common.json | 12 ++++++ locales/cs/common.json | 12 ++++++ locales/da/common.json | 12 ++++++ locales/de/common.json | 12 ++++++ locales/en/common.json | 12 ++++++ locales/es/common.json | 12 ++++++ locales/et/common.json | 12 ++++++ locales/fa/common.json | 12 ++++++ locales/fr/common.json | 12 ++++++ locales/he/common.json | 12 ++++++ locales/hu/common.json | 12 ++++++ locales/it/common.json | 12 ++++++ locales/ja/common.json | 12 ++++++ locales/ko/common.json | 12 ++++++ locales/lv/common.json | 12 ++++++ locales/mn/common.json | 12 ++++++ locales/nb/common.json | 12 ++++++ locales/nl/common.json | 12 ++++++ locales/pl/common.json | 12 ++++++ locales/pt/common.json | 12 ++++++ locales/ro/common.json | 12 ++++++ locales/ru/common.json | 12 ++++++ locales/sk/common.json | 12 ++++++ locales/th/common.json | 12 ++++++ locales/tr/common.json | 12 ++++++ locales/uk/common.json | 12 ++++++ locales/vi/common.json | 12 ++++++ locales/zh-TW/common.json | 12 ++++++ locales/zh/common.json | 12 ++++++ 35 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 hooks/__tests__/use-refresh-gesture-desktop-menu.test.tsx diff --git a/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx b/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx index 0fc99ee3e..7b239e204 100644 --- a/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx +++ b/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx @@ -30,6 +30,7 @@ vi.mock('@/hooks/use-config', () => ({ useConfig: () => config })); import { CalendarApp } from '../calendar-app'; import { requestAppCommand } from '@/lib/app-commands'; +import { mergedDesktopState, resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands'; vi.setConfig({ testTimeout: 30000 }); @@ -84,3 +85,32 @@ describe('Calendar in the desktop menus', () => { expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull(); }); }); + +// pim-p2-9 (2026-11-04): the desktop app's Go menu in Calendar moves through time +// (Today, Next period, Previous period) instead of offering Mail's folders. +describe('Calendar in the desktop Go menu', () => { + beforeEach(() => { + resetDesktopCommands(); + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: vi.fn(), onCommand: vi.fn() } }; + }); + afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + resetDesktopCommands(); + }); + + it('offers Today, Next period and Previous period, and each moves the week', () => { + render(); + const state = mergedDesktopState(); + expect(state['go.today']).toBe(true); + expect(state['go.nextPeriod']).toBe(true); + expect(state['go.previousPeriod']).toBe(true); + const day = () => useCalendarStore.getState().selectedDate.toDateString(); + act(() => { expect(runDesktopCommand('go.nextPeriod')).toBe(true); }); + expect(day()).toBe(new Date(2026, 9, 19).toDateString()); + act(() => { runDesktopCommand('go.previousPeriod'); }); + act(() => { runDesktopCommand('go.previousPeriod'); }); + expect(day()).toBe(new Date(2026, 9, 5).toDateString()); + act(() => { runDesktopCommand('go.today'); }); + expect(day()).toBe(new Date().toDateString()); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 3ce9695bb..b22d639a5 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -1686,7 +1686,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = "new-task": () => { if (enableCalendarTasks) openCreateTaskModal(); }, "import-calendar": () => { if (!demoMode) setShowImportModal(true); }, }, true); - // Its View menu's Day, Week, Month and Agenda, and Edit > Search events. + // Its View menu's Day, Week, Month and Agenda, Go's Today and periods, and Edit > Search events. const viewFromMenu = (mode: CalendarViewMode) => () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); }; useDesktopCommands({ "view.calendar.day": viewFromMenu("day"), @@ -1694,7 +1694,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = "view.calendar.month": viewFromMenu("month"), "view.calendar.agenda": viewFromMenu("agenda"), "edit.search": openSearch, + // Go > Today, Next period and Previous period: the toolbar's arrows and T. + "go.today": () => { leaveSearch(); goToToday(); }, + "go.nextPeriod": () => { leaveSearch(); navigateNext(); }, + "go.previousPeriod": () => { leaveSearch(); navigatePrev(); }, }, { + "go.today": normalizedViewMode !== "tasks", + "go.nextPeriod": normalizedViewMode !== "tasks", + "go.previousPeriod": normalizedViewMode !== "tasks", "view.calendar.day": { enabled: true, checked: normalizedViewMode === "day" }, "view.calendar.week": { enabled: true, checked: normalizedViewMode === "week" }, "view.calendar.month": { enabled: true, checked: normalizedViewMode === "month" }, diff --git a/hooks/__tests__/use-refresh-gesture-desktop-menu.test.tsx b/hooks/__tests__/use-refresh-gesture-desktop-menu.test.tsx new file mode 100644 index 000000000..c363bb1db --- /dev/null +++ b/hooks/__tests__/use-refresh-gesture-desktop-menu.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act } from '@testing-library/react'; +import { useRefreshGesture } from '@/hooks/use-refresh-gesture'; +import { mergedDesktopState, resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands'; + +/** + * pim-p2-9 (2026-11-04): in the desktop app View > Refresh (F9) was Mail's "Check + * for new mail" and greyed in Calendar, Contacts and Files. Each screen's own + * refresh (the one F5 and pull-to-refresh run) now answers the menu. + */ + +function Screen({ onRefresh, enabled = true }: { onRefresh: () => void; enabled?: boolean }) { + const { indicator } = useRefreshGesture({ onRefresh, enabled }); + return
{indicator}
; +} + +describe('useRefreshGesture in the desktop app', () => { + beforeEach(() => { + resetDesktopCommands(); + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: vi.fn(), onCommand: vi.fn() } }; + }); + afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + resetDesktopCommands(); + }); + + it('enables View > Refresh and runs the screen refresh from it', async () => { + const onRefresh = vi.fn(); + render(); + expect(mergedDesktopState()['view.refresh']).toBe(true); + await act(async () => { + expect(runDesktopCommand('view.refresh')).toBe(true); + }); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('offers nothing while the screen can not refresh', () => { + render(); + expect(mergedDesktopState()['view.refresh']).toBeUndefined(); + expect(runDesktopCommand('view.refresh')).toBe(false); + }); +}); diff --git a/hooks/use-refresh-gesture.tsx b/hooks/use-refresh-gesture.tsx index f74737516..a4a26ac68 100644 --- a/hooks/use-refresh-gesture.tsx +++ b/hooks/use-refresh-gesture.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { useDesktopCommands } from "@/lib/desktop/commands"; import { PullToRefreshIndicator, createPullToRefreshStore, @@ -93,8 +94,12 @@ export function useRefreshGesture({ onRefresh, enabled = true }: UseRefreshGestu const t = useTranslations("common"); const onRefreshRef = useRef(onRefresh); const runningRef = useRef(false); + const triggerRef = useRef<(() => void) | null>(null); const store = useMemo(() => createPullToRefreshStore(), []); + // The desktop app's View > Refresh (F9, F5) runs the same refresh for the screen in front. + useDesktopCommands({ "view.refresh": () => triggerRef.current?.() }, { "view.refresh": enabled }, enabled); + useEffect(() => { onRefreshRef.current = onRefresh; }, [onRefresh]); @@ -135,6 +140,7 @@ export function useRefreshGesture({ onRefresh, enabled = true }: UseRefreshGestu } })(); }; + triggerRef.current = trigger; const handleKeyDown = (event: KeyboardEvent) => { const isReloadKey = @@ -240,6 +246,7 @@ export function useRefreshGesture({ onRefresh, enabled = true }: UseRefreshGestu window.removeEventListener("touchcancel", cancelPull); timers.forEach(clearTimeout); timers.clear(); + if (triggerRef.current === trigger) triggerRef.current = null; store.set({ phase: "idle", distance: 0 }); }; }, [enabled, store]); diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts index 010b284fa..86c6d598a 100644 --- a/lib/desktop/languages.ts +++ b/lib/desktop/languages.ts @@ -91,6 +91,7 @@ export function shellLabels(t: (key: string, values?: Record) => menuSelectAll: t('desktopUi.menu.select_all'), menuSearch: t('desktopUi.menu.search'), menuRefresh: t('desktopUi.menu.refresh'), + menuRefreshView: t('ux_p2_pim_cm.menu_refresh'), menuReadingPane: t('desktopUi.menu.reading_pane'), menuPaneRight: t('desktopUi.menu.pane_right'), menuPaneBelow: t('desktopUi.menu.pane_below'), @@ -112,6 +113,9 @@ export function shellLabels(t: (key: string, values?: Record) => menuDelete: t('context_menu.delete'), menuOpenInWindow: t('desktopUi.menu.open_in_window'), menuGoInbox: t('sidebar.mailboxes.inbox'), + menuGoToday: t('ux_p2_pim_cm.menu_today'), + menuGoNextPeriod: t('ux_p2_pim_cm.menu_next_period'), + menuGoPreviousPeriod: t('ux_p2_pim_cm.menu_previous_period'), menuGoSent: t('sidebar.mailboxes.sent'), menuGoDrafts: t('sidebar.mailboxes.drafts'), menuGoNext: t('desktopUi.menu.next_message'), diff --git a/locales/ar/common.json b/locales/ar/common.json index db3f91ff9..e0d3199a4 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "التقويم…", "import_contacts": "جهات الاتصال…" }, + "ux_p2_pim_cm": { + "menu_refresh": "تحديث", + "menu_today": "اليوم", + "menu_next_period": "الفترة التالية", + "menu_previous_period": "الفترة السابقة", + "account_scope_label": "يعرض {address}. تبديل الحساب", + "account_scope_menu": "تبديل الحساب", + "merge_links": "روابط", + "merge_photo": "صورة", + "merge_kept": "محفوظ", + "import_show": "عرض" + }, "desktopEdit": { "edit": "تعديل", "edit_named": "تعديل {name}", diff --git a/locales/ca/common.json b/locales/ca/common.json index 370dd4902..87286548a 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendari…", "import_contacts": "Contactes…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Actualitza", + "menu_today": "Avui", + "menu_next_period": "Període següent", + "menu_previous_period": "Període anterior", + "account_scope_label": "Es mostra {address}. Canvia de compte", + "account_scope_menu": "Canvia de compte", + "merge_links": "Enllaços", + "merge_photo": "Foto", + "merge_kept": "Es conserva", + "import_show": "Mostra" + }, "desktopEdit": { "edit": "Edita", "edit_named": "Edita {name}", diff --git a/locales/cs/common.json b/locales/cs/common.json index c77cddf2a..9129e66e7 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalendář…", "import_contacts": "Kontakty…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Obnovit", + "menu_today": "Dnes", + "menu_next_period": "Další období", + "menu_previous_period": "Předchozí období", + "account_scope_label": "Zobrazuje se {address}. Přepnout účet", + "account_scope_menu": "Přepnout účet", + "merge_links": "Odkazy", + "merge_photo": "Fotka", + "merge_kept": "Zůstane", + "import_show": "Zobrazit" + }, "desktopEdit": { "edit": "Upravit", "edit_named": "Upravit {name}", diff --git a/locales/da/common.json b/locales/da/common.json index 89522ce74..4da1fc158 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalender…", "import_contacts": "Kontakter…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Opdater", + "menu_today": "I dag", + "menu_next_period": "Næste periode", + "menu_previous_period": "Forrige periode", + "account_scope_label": "Viser {address}. Skift konto", + "account_scope_menu": "Skift konto", + "merge_links": "Links", + "merge_photo": "Foto", + "merge_kept": "Beholdes", + "import_show": "Vis" + }, "desktopEdit": { "edit": "Rediger", "edit_named": "Rediger {name}", diff --git a/locales/de/common.json b/locales/de/common.json index 5f077d7fd..f587c4eda 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalender…", "import_contacts": "Kontakte…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Aktualisieren", + "menu_today": "Heute", + "menu_next_period": "Nächster Zeitraum", + "menu_previous_period": "Vorheriger Zeitraum", + "account_scope_label": "Zeigt {address}. Konto wechseln", + "account_scope_menu": "Konto wechseln", + "merge_links": "Links", + "merge_photo": "Foto", + "merge_kept": "Bleibt erhalten", + "import_show": "Anzeigen" + }, "desktopEdit": { "edit": "Bearbeiten", "edit_named": "{name} bearbeiten", diff --git a/locales/en/common.json b/locales/en/common.json index ea30812aa..b708501bf 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendar…", "import_contacts": "Contacts…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Refresh", + "menu_today": "Today", + "menu_next_period": "Next period", + "menu_previous_period": "Previous period", + "account_scope_label": "Showing {address}. Switch account", + "account_scope_menu": "Switch account", + "merge_links": "Links", + "merge_photo": "Photo", + "merge_kept": "Kept", + "import_show": "Show" + }, "desktopEdit": { "edit": "Edit", "edit_named": "Edit {name}", diff --git a/locales/es/common.json b/locales/es/common.json index e28d12f5f..b15556a39 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendario…", "import_contacts": "Contactos…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Actualizar", + "menu_today": "Hoy", + "menu_next_period": "Periodo siguiente", + "menu_previous_period": "Periodo anterior", + "account_scope_label": "Se muestra {address}. Cambiar de cuenta", + "account_scope_menu": "Cambiar de cuenta", + "merge_links": "Enlaces", + "merge_photo": "Foto", + "merge_kept": "Se conserva", + "import_show": "Mostrar" + }, "desktopEdit": { "edit": "Editar", "edit_named": "Editar {name}", diff --git a/locales/et/common.json b/locales/et/common.json index 584c4f2b7..cebb3038c 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalender…", "import_contacts": "Kontaktid…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Värskenda", + "menu_today": "Täna", + "menu_next_period": "Järgmine periood", + "menu_previous_period": "Eelmine periood", + "account_scope_label": "Kuvatakse {address}. Vaheta kontot", + "account_scope_menu": "Vaheta kontot", + "merge_links": "Lingid", + "merge_photo": "Foto", + "merge_kept": "Jääb alles", + "import_show": "Näita" + }, "desktopEdit": { "edit": "Muuda", "edit_named": "Muuda faili {name}", diff --git a/locales/fa/common.json b/locales/fa/common.json index 1c49696fe..868dcbc6c 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "تقویم…", "import_contacts": "مخاطبین…" }, + "ux_p2_pim_cm": { + "menu_refresh": "بازخوانی", + "menu_today": "امروز", + "menu_next_period": "بازه بعدی", + "menu_previous_period": "بازه قبلی", + "account_scope_label": "نمایش {address}. تغییر حساب", + "account_scope_menu": "تغییر حساب", + "merge_links": "پیوندها", + "merge_photo": "عکس", + "merge_kept": "حفظ می‌شود", + "import_show": "نمایش" + }, "desktopEdit": { "edit": "ویرایش", "edit_named": "ویرایش {name}", diff --git a/locales/fr/common.json b/locales/fr/common.json index 1a6a2f71c..386b54271 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendrier…", "import_contacts": "Contacts…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Actualiser", + "menu_today": "Aujourd’hui", + "menu_next_period": "Période suivante", + "menu_previous_period": "Période précédente", + "account_scope_label": "Affiche {address}. Changer de compte", + "account_scope_menu": "Changer de compte", + "merge_links": "Liens", + "merge_photo": "Photo", + "merge_kept": "Conservé", + "import_show": "Afficher" + }, "desktopEdit": { "edit": "Modifier", "edit_named": "Modifier {name}", diff --git a/locales/he/common.json b/locales/he/common.json index e15809a86..d3ff2084f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -7520,6 +7520,18 @@ "import_calendar": "לוח שנה…", "import_contacts": "אנשי קשר…" }, + "ux_p2_pim_cm": { + "menu_refresh": "רענון", + "menu_today": "היום", + "menu_next_period": "התקופה הבאה", + "menu_previous_period": "התקופה הקודמת", + "account_scope_label": "מוצג {address}. החלפת חשבון", + "account_scope_menu": "החלפת חשבון", + "merge_links": "קישורים", + "merge_photo": "תמונה", + "merge_kept": "נשמר", + "import_show": "הצגה" + }, "desktopEdit": { "edit": "עריכה", "edit_named": "עריכת {name}", diff --git a/locales/hu/common.json b/locales/hu/common.json index 23c5c88d4..96a881a27 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Naptár…", "import_contacts": "Névjegyek…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Frissítés", + "menu_today": "Ma", + "menu_next_period": "Következő időszak", + "menu_previous_period": "Előző időszak", + "account_scope_label": "Látható: {address}. Fiókváltás", + "account_scope_menu": "Fiókváltás", + "merge_links": "Hivatkozások", + "merge_photo": "Fénykép", + "merge_kept": "Megmarad", + "import_show": "Megjelenítés" + }, "desktopEdit": { "edit": "Szerkesztés", "edit_named": "{name} szerkesztése", diff --git a/locales/it/common.json b/locales/it/common.json index 73fda3f89..311905906 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendario…", "import_contacts": "Contatti…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Aggiorna", + "menu_today": "Oggi", + "menu_next_period": "Periodo successivo", + "menu_previous_period": "Periodo precedente", + "account_scope_label": "Mostra {address}. Cambia account", + "account_scope_menu": "Cambia account", + "merge_links": "Link", + "merge_photo": "Foto", + "merge_kept": "Mantenuto", + "import_show": "Mostra" + }, "desktopEdit": { "edit": "Modifica", "edit_named": "Modifica {name}", diff --git a/locales/ja/common.json b/locales/ja/common.json index 6b2f3e82b..321e6e60c 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "カレンダー…", "import_contacts": "連絡先…" }, + "ux_p2_pim_cm": { + "menu_refresh": "更新", + "menu_today": "今日", + "menu_next_period": "次の期間", + "menu_previous_period": "前の期間", + "account_scope_label": "{address} を表示中。アカウントを切り替え", + "account_scope_menu": "アカウントを切り替え", + "merge_links": "リンク", + "merge_photo": "写真", + "merge_kept": "残ります", + "import_show": "表示" + }, "desktopEdit": { "edit": "編集", "edit_named": "{name} を編集", diff --git a/locales/ko/common.json b/locales/ko/common.json index fe9560d3f..3b329a19e 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "캘린더…", "import_contacts": "연락처…" }, + "ux_p2_pim_cm": { + "menu_refresh": "새로 고침", + "menu_today": "오늘", + "menu_next_period": "다음 기간", + "menu_previous_period": "이전 기간", + "account_scope_label": "{address} 표시 중. 계정 전환", + "account_scope_menu": "계정 전환", + "merge_links": "링크", + "merge_photo": "사진", + "merge_kept": "유지됨", + "import_show": "보기" + }, "desktopEdit": { "edit": "편집", "edit_named": "{name} 편집", diff --git a/locales/lv/common.json b/locales/lv/common.json index 6c2792f2a..855030759 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalendārs…", "import_contacts": "Kontakti…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Atsvaidzināt", + "menu_today": "Šodien", + "menu_next_period": "Nākamais periods", + "menu_previous_period": "Iepriekšējais periods", + "account_scope_label": "Rāda {address}. Pārslēgt kontu", + "account_scope_menu": "Pārslēgt kontu", + "merge_links": "Saites", + "merge_photo": "Foto", + "merge_kept": "Saglabājas", + "import_show": "Rādīt" + }, "desktopEdit": { "edit": "Rediģēt", "edit_named": "Rediģēt {name}", diff --git a/locales/mn/common.json b/locales/mn/common.json index 5638c0661..58af27e1d 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Хуанли…", "import_contacts": "Харилцагчид…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Шинэчлэх", + "menu_today": "Өнөөдөр", + "menu_next_period": "Дараагийн үе", + "menu_previous_period": "Өмнөх үе", + "account_scope_label": "{address}-г харуулж байна. Бүртгэл солих", + "account_scope_menu": "Бүртгэл солих", + "merge_links": "Холбоосууд", + "merge_photo": "Зураг", + "merge_kept": "Хадгалагдана", + "import_show": "Харуулах" + }, "desktopEdit": { "edit": "Засах", "edit_named": "{name} засах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 875523efe..ecad3c884 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalender…", "import_contacts": "Kontakter…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Oppdater", + "menu_today": "I dag", + "menu_next_period": "Neste periode", + "menu_previous_period": "Forrige periode", + "account_scope_label": "Viser {address}. Bytt konto", + "account_scope_menu": "Bytt konto", + "merge_links": "Lenker", + "merge_photo": "Bilde", + "merge_kept": "Beholdes", + "import_show": "Vis" + }, "desktopEdit": { "edit": "Rediger", "edit_named": "Rediger {name}", diff --git a/locales/nl/common.json b/locales/nl/common.json index c24f86f62..5a4b4d8e9 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Agenda…", "import_contacts": "Contacten…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Vernieuwen", + "menu_today": "Vandaag", + "menu_next_period": "Volgende periode", + "menu_previous_period": "Vorige periode", + "account_scope_label": "Toont {address}. Ander account kiezen", + "account_scope_menu": "Ander account kiezen", + "merge_links": "Links", + "merge_photo": "Foto", + "merge_kept": "Blijft bewaard", + "import_show": "Tonen" + }, "desktopEdit": { "edit": "Bewerken", "edit_named": "{name} bewerken", diff --git a/locales/pl/common.json b/locales/pl/common.json index 90a292bbd..3f117e8e4 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalendarz…", "import_contacts": "Kontakty…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Odśwież", + "menu_today": "Dzisiaj", + "menu_next_period": "Następny okres", + "menu_previous_period": "Poprzedni okres", + "account_scope_label": "Wyświetlane: {address}. Przełącz konto", + "account_scope_menu": "Przełącz konto", + "merge_links": "Linki", + "merge_photo": "Zdjęcie", + "merge_kept": "Zostaje", + "import_show": "Pokaż" + }, "desktopEdit": { "edit": "Edytuj", "edit_named": "Edytuj {name}", diff --git a/locales/pt/common.json b/locales/pt/common.json index f38b2bc69..77ef679e5 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendário…", "import_contacts": "Contactos…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Atualizar", + "menu_today": "Hoje", + "menu_next_period": "Próximo período", + "menu_previous_period": "Período anterior", + "account_scope_label": "A mostrar {address}. Mudar de conta", + "account_scope_menu": "Mudar de conta", + "merge_links": "Ligações", + "merge_photo": "Foto", + "merge_kept": "Mantém-se", + "import_show": "Mostrar" + }, "desktopEdit": { "edit": "Editar", "edit_named": "Editar {name}", diff --git a/locales/ro/common.json b/locales/ro/common.json index e0e5ef680..49a41b6b8 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Calendar…", "import_contacts": "Contacte…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Reîmprospătează", + "menu_today": "Astăzi", + "menu_next_period": "Perioada următoare", + "menu_previous_period": "Perioada anterioară", + "account_scope_label": "Se afișează {address}. Schimbă contul", + "account_scope_menu": "Schimbă contul", + "merge_links": "Linkuri", + "merge_photo": "Fotografie", + "merge_kept": "Se păstrează", + "import_show": "Afișează" + }, "desktopEdit": { "edit": "Editează", "edit_named": "Editează {name}", diff --git a/locales/ru/common.json b/locales/ru/common.json index 761f4d4dd..645099a3e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Календарь…", "import_contacts": "Контакты…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Обновить", + "menu_today": "Сегодня", + "menu_next_period": "Следующий период", + "menu_previous_period": "Предыдущий период", + "account_scope_label": "Показан {address}. Сменить учётную запись", + "account_scope_menu": "Сменить учётную запись", + "merge_links": "Ссылки", + "merge_photo": "Фото", + "merge_kept": "Сохранится", + "import_show": "Показать" + }, "desktopEdit": { "edit": "Изменить", "edit_named": "Изменить {name}", diff --git a/locales/sk/common.json b/locales/sk/common.json index 5b1572ca2..580d05569 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Kalendár…", "import_contacts": "Kontakty…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Obnoviť", + "menu_today": "Dnes", + "menu_next_period": "Ďalšie obdobie", + "menu_previous_period": "Predchádzajúce obdobie", + "account_scope_label": "Zobrazuje sa {address}. Prepnúť účet", + "account_scope_menu": "Prepnúť účet", + "merge_links": "Odkazy", + "merge_photo": "Fotka", + "merge_kept": "Zostane", + "import_show": "Zobraziť" + }, "desktopEdit": { "edit": "Upraviť", "edit_named": "Upraviť {name}", diff --git a/locales/th/common.json b/locales/th/common.json index 4e7043c82..f578d1ebd 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -7527,6 +7527,18 @@ "import_calendar": "ปฏิทิน…", "import_contacts": "ผู้ติดต่อ…" }, + "ux_p2_pim_cm": { + "menu_refresh": "รีเฟรช", + "menu_today": "วันนี้", + "menu_next_period": "ช่วงถัดไป", + "menu_previous_period": "ช่วงก่อนหน้า", + "account_scope_label": "กำลังแสดง {address} สลับบัญชี", + "account_scope_menu": "สลับบัญชี", + "merge_links": "ลิงก์", + "merge_photo": "รูปภาพ", + "merge_kept": "เก็บไว้", + "import_show": "แสดง" + }, "desktopEdit": { "edit": "แก้ไข", "edit_named": "แก้ไข {name}", diff --git a/locales/tr/common.json b/locales/tr/common.json index ac8dd7b92..46e5728d6 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Takvim…", "import_contacts": "Kişiler…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Yenile", + "menu_today": "Bugün", + "menu_next_period": "Sonraki dönem", + "menu_previous_period": "Önceki dönem", + "account_scope_label": "{address} gösteriliyor. Hesap değiştir", + "account_scope_menu": "Hesap değiştir", + "merge_links": "Bağlantılar", + "merge_photo": "Fotoğraf", + "merge_kept": "Korunur", + "import_show": "Göster" + }, "desktopEdit": { "edit": "Düzenle", "edit_named": "{name} dosyasını düzenle", diff --git a/locales/uk/common.json b/locales/uk/common.json index b0d90b0f8..0cc59c14a 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "Календар…", "import_contacts": "Контакти…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Оновити", + "menu_today": "Сьогодні", + "menu_next_period": "Наступний період", + "menu_previous_period": "Попередній період", + "account_scope_label": "Показано {address}. Змінити обліковий запис", + "account_scope_menu": "Змінити обліковий запис", + "merge_links": "Посилання", + "merge_photo": "Фото", + "merge_kept": "Залишиться", + "import_show": "Показати" + }, "desktopEdit": { "edit": "Редагувати", "edit_named": "Редагувати {name}", diff --git a/locales/vi/common.json b/locales/vi/common.json index 367c03dd8..482dc9c53 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -7531,6 +7531,18 @@ "import_calendar": "Lịch…", "import_contacts": "Danh bạ…" }, + "ux_p2_pim_cm": { + "menu_refresh": "Làm mới", + "menu_today": "Hôm nay", + "menu_next_period": "Khoảng thời gian sau", + "menu_previous_period": "Khoảng thời gian trước", + "account_scope_label": "Đang hiển thị {address}. Chuyển tài khoản", + "account_scope_menu": "Chuyển tài khoản", + "merge_links": "Liên kết", + "merge_photo": "Ảnh", + "merge_kept": "Được giữ lại", + "import_show": "Hiển thị" + }, "desktopEdit": { "edit": "Chỉnh sửa", "edit_named": "Chỉnh sửa {name}", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 0b3d88f49..d7eff31bf 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "行事曆…", "import_contacts": "聯絡人…" }, + "ux_p2_pim_cm": { + "menu_refresh": "重新整理", + "menu_today": "今天", + "menu_next_period": "下一個期間", + "menu_previous_period": "上一個期間", + "account_scope_label": "正在顯示 {address}。切換帳號", + "account_scope_menu": "切換帳號", + "merge_links": "連結", + "merge_photo": "相片", + "merge_kept": "保留", + "import_show": "顯示" + }, "desktopEdit": { "edit": "編輯", "edit_named": "編輯 {name}", diff --git a/locales/zh/common.json b/locales/zh/common.json index 7ac44e08e..435704805 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -7519,6 +7519,18 @@ "import_calendar": "日历…", "import_contacts": "联系人…" }, + "ux_p2_pim_cm": { + "menu_refresh": "刷新", + "menu_today": "今天", + "menu_next_period": "下一时段", + "menu_previous_period": "上一时段", + "account_scope_label": "正在显示 {address}。切换账户", + "account_scope_menu": "切换账户", + "merge_links": "链接", + "merge_photo": "照片", + "merge_kept": "保留", + "import_show": "显示" + }, "desktopEdit": { "edit": "编辑", "edit_named": "编辑 {name}", From 4b0b13009342209c195a963fc9bd908efb7f438d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:58:16 +0100 Subject: [PATCH 2/5] feat: show which account Contacts and Files are showing, by its address, and switch from there --- components/contacts/contacts-sidebar.tsx | 7 +- components/files/files-sidebar.tsx | 7 +- .../__tests__/account-scope-label.test.tsx | 74 +++++++++++++++++ components/layout/account-scope-label.tsx | 79 +++++++++++++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 components/layout/__tests__/account-scope-label.test.tsx create mode 100644 components/layout/account-scope-label.tsx diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 67060d001..ab482eb93 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -13,6 +13,7 @@ import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { getContactDisplayName, TRUSTED_SENDERS_BOOK_NAME } from "@/stores/contact-store"; import { useAccountStore } from "@/stores/account-store"; import { useCollectionNames } from "@/hooks/use-collection-names"; +import { AccountScopeLabel } from "@/components/layout/account-scope-label"; export type ContactCategory = "all" | "directory" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized"; @@ -283,7 +284,11 @@ export function ContactsSidebar({ {/* Header */} {/* Same h-14 box as the mail sidebar's account switcher, so the apps line up. */}
- {t("title")} + {/* Which account it shows, when there are several: the address, which switches. */} +
+ {t("title")} + +
{/* Same h-14 box as the mail sidebar's account switcher, so the apps line up. */}
- {t("title")} + {/* Which account it shows, when there are several: the address, which switches. */} +
+ {t("title")} + +
{canCreate && ( false, onShare: noop, + onCreateIn: noop, onRenameFolder: noop, onDeleteFolder: noop, onRevealRecent: noop, + onDownloadRecent: noop, onRemoveRecent: noop, + } as unknown as FilesSidebarProps; + return render(); +} + +function renderContacts() { + const noop = vi.fn(); + return render( + , + ); +} + +const account = (id: string, email: string, isDefault = false) => ({ + id, email, username: email, displayName: 'Same Name', label: 'Same Name', serverUrl: 'https://mail.example.org', + isDefault, isConnected: true, hasError: false, lastLoginAt: 0, avatarColor: '#888888', +}); + +describe('the account a screen shows', () => { + beforeEach(() => { + switchAccount.mockClear(); + useAccountStore.setState({ accounts: [account('a', 'a@example.org', true), account('b', 'b@example.org')] } as never); + useAuthStore.setState({ activeAccountId: 'b', switchAccount } as never); + }); + afterEach(() => { + cleanup(); + useAccountStore.setState({ accounts: [] } as never); + }); + + for (const [name, renderScreen] of [['Files', renderFiles], ['Contacts', renderContacts]] as const) { + it(`${name} names the active account by its address and switches from there`, () => { + renderScreen(); + const label = screen.getByTestId('account-scope'); + expect(label).toHaveTextContent('b@example.org'); + expect(label).not.toHaveTextContent('Same Name'); + act(() => { fireEvent.click(label); }); + const menu = screen.getByRole('menu'); + const options = within(menu).getAllByRole('menuitem'); + expect(options.map((o) => o.textContent)).toEqual(['a@example.org', 'b@example.org']); + act(() => { fireEvent.click(options[0]); }); + expect(switchAccount).toHaveBeenCalledWith('a'); + }); + } + + it('says nothing with one account', () => { + useAccountStore.setState({ accounts: [account('b', 'b@example.org', true)] } as never); + renderFiles(); + expect(screen.queryByTestId('account-scope')).toBeNull(); + }); +}); diff --git a/components/layout/account-scope-label.tsx b/components/layout/account-scope-label.tsx new file mode 100644 index 000000000..586a46947 --- /dev/null +++ b/components/layout/account-scope-label.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { Check, ChevronDown } from "@/components/icons"; +import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; +import { sortDefaultFirst } from "@/lib/account-utils"; +import { useDesktopPolicy } from "@/lib/desktop/policy"; +import { accountRefusedByDesktopPolicy } from "@/lib/desktop/refused-accounts"; +import { toUnicodeEmail } from "@/lib/idn"; +import { cn } from "@/lib/utils"; +import { useAccountStore, type AccountEntry } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; + +const addressOf = (account: AccountEntry) => toUnicodeEmail(account.email || account.username); + +/** + * Which account a screen that shows one account at a time (Contacts, Files) is + * showing, by its address, under the screen's title. A click lists the other + * signed-in accounts by address and switches. Nothing with a single account. + */ +export function AccountScopeLabel({ className }: { className?: string }) { + const t = useTranslations("ux_p2_pim_cm"); + const storedAccounts = useAccountStore((s) => s.accounts); + const devicePolicy = useDesktopPolicy(); + const accounts = useMemo( + () => sortDefaultFirst(devicePolicy.AllowedJmapServers.value ? storedAccounts.filter((account) => !accountRefusedByDesktopPolicy(account, devicePolicy)) : storedAccounts), + [storedAccounts, devicePolicy], + ); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const switchAccount = useAuthStore((s) => s.switchAccount); + const menu = useContextMenu(); + + const active = accounts.find((account) => account.id === activeAccountId); + if (accounts.length < 2 || !active) return null; + const address = addressOf(active); + + return ( + <> + + + {accounts.map((account) => ( + { + menu.closeContextMenu(); + void switchAccount(account.id); + }} + /> + ))} + + + ); +} From 5365b1a4612e40ca69da0c0f2d8befd5468f0c33 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:59:28 +0100 Subject: [PATCH 3/5] fix: lay out addresses in the merge preview as the card does, list every field the card keeps, and sort Merge with by name The contacts-app merge test now expects the address on its own lines instead of one comma-separated line, which was the bug. --- .../contact-merge-preview-ux-p2.test.tsx | 56 +++++++++++++++ .../contacts/__tests__/contacts-app.test.tsx | 3 +- components/contacts/contact-merge-dialog.tsx | 68 +++++++++++-------- 3 files changed, 96 insertions(+), 31 deletions(-) create mode 100644 components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx diff --git a/components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx b/components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx new file mode 100644 index 000000000..61ac6009d --- /dev/null +++ b/components/contacts/__tests__/contact-merge-preview-ux-p2.test.tsx @@ -0,0 +1,56 @@ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ContactCard } from '@/lib/jmap/types'; +import { ContactMergeDialog } from '../contact-merge-dialog'; + +// pim-p2-14 and pim-p2-15 (2026-11-04): the merge preview listed "Hauptstraße 5, +// Berlin, 10115, Germany" where the card shows "Hauptstraße 5" / "10115 Berlin", and +// left out the birthday the merge keeps. "Merge with…" listed every contact unsorted. + +afterEach(() => cleanup()); + +const anna = { + id: 'anna', kind: 'individual', name: { full: 'Anna Schmidt' }, + emails: { e: { address: 'anna@example.org' } }, + addresses: { + a: { + countryCode: 'DE', + components: [ + { kind: 'name', value: 'Hauptstraße' }, { kind: 'number', value: '5' }, + { kind: 'locality', value: 'Berlin' }, { kind: 'postcode', value: '10115' }, { kind: 'country', value: 'Germany' }, + ], + }, + }, + updated: '2026-11-01T10:00:00Z', +} as unknown as ContactCard; + +const annaOld = { + id: 'anna-old', kind: 'individual', name: { full: 'Anna Schmidt' }, + emails: { e: { address: 'anna.s@example.org' } }, + anniversaries: { b: { kind: 'birth', date: { '@type': 'PartialDate', year: 1990, month: 3, day: 14 } } }, + updated: '2025-01-01T10:00:00Z', +} as unknown as ContactCard; + +const named = (id: string, full: string) => ({ id, kind: 'individual', name: { full } }) as unknown as ContactCard; + +describe('the merge preview', () => { + it('lays the address out as the card does and lists the birthday it keeps', () => { + render(); + const preview = screen.getByTestId('merge-preview'); + const address = within(preview).getByText('group_adr_any').nextElementSibling as HTMLElement; + expect(address.textContent).toBe('Hauptstraße 5\n10115 Berlin\nGermany'); + const birthday = within(preview).getByText('detail.anniversary_birth').nextElementSibling as HTMLElement; + expect(birthday.textContent).toMatch(/1990/); + }); +}); + +describe('Merge with…', () => { + it('lists the contacts to pick by name', () => { + const candidates = [named('3', 'Jürgen'), named('1', 'Alice'), named('5', 'Zoë'), named('2', 'anna'), named('4', 'Bob')]; + render(); + const rows = within(screen.getByTestId('merge-pick-list')).getAllByRole('button'); + expect(rows.map((row) => row.textContent)).toEqual(['Alice', 'anna', 'Bob', 'Jürgen', 'Zoë']); + fireEvent.click(rows[0]); + expect(screen.getByTestId('merge-preview')).toBeInTheDocument(); + }); +}); diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index 59c673620..ab4c422ed 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -322,7 +322,8 @@ describe('ContactsApp', () => { const preview = await screen.findByTestId('merge-preview'); expect(within(preview).getByText('anna@example.net')).toBeInTheDocument(); expect(within(preview).getByText('anna@home.example.net')).toBeInTheDocument(); - expect(within(preview).getByText('Hauptstraße 1, Berlin')).toBeInTheDocument(); + // One line per address line, as the card shows it (pim-p2-14); getByText joins the lines with a space. + expect(within(preview).getByText('Hauptstraße 1 Berlin')).toBeInTheDocument(); expect(within(preview).getByText('Club')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'merge' })); diff --git a/components/contacts/contact-merge-dialog.tsx b/components/contacts/contact-merge-dialog.tsx index bc48e5e99..e3cf539f9 100644 --- a/components/contacts/contact-merge-dialog.tsx +++ b/components/contacts/contact-merge-dialog.tsx @@ -1,39 +1,20 @@ "use client"; import { useMemo, useState } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { Search } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { Dialog } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { formatAddressLines } from "@/lib/address-format"; +import { formatContactDate } from "@/lib/contact-dates"; import { cn } from "@/lib/utils"; -import type { ContactAddress, ContactCard } from "@/lib/jmap/types"; +import type { ContactCard } from "@/lib/jmap/types"; import { memberKeys, planMerge, type MergePlan } from "@/lib/contacts/merge"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; -/** One line for an address, as its parts and separators say. */ -export function addressLine(address: ContactAddress): string { - const components = address.components ?? []; - if (components.length > 0) { - let out = ""; - let lastWasValue = false; - for (const c of components) { - if (c.kind === "separator") { - out += c.value; - lastWasValue = false; - continue; - } - if (!c.value) continue; - if (lastWasValue) out += address.defaultSeparator ?? ", "; - out += c.value.replace(/\s*\n\s*/g, ", "); - lastWasValue = true; - } - return out; - } - const legacy = [address.street, [address.postcode, address.locality].filter(Boolean).join(" "), address.region, address.country] - .filter(Boolean).join(", "); - return legacy || address.full || address.fullAddress || ""; -} +/** Lines of one value, shown with whitespace-pre-line. */ +const NEWLINE = String.fromCharCode(10); interface ContactMergeDialogProps { /** Two or more cards: the dialog opens on what the merge keeps. */ @@ -69,11 +50,14 @@ function PickRow({ card, onPick }: { card: ContactCard; onPick: () => void }) { export function ContactMergeDialog({ cards: initialCards, pickFor, candidates = [], suggested = [], groups, onMerge, onClose }: ContactMergeDialogProps) { const t = useTranslations("ux_pim_cimport"); const tc = useTranslations("contacts"); + const tm = useTranslations("ux_p2_pim_cm"); + const locale = useLocale(); const [cards, setCards] = useState(initialCards && initialCards.length > 1 ? initialCards : null); const [query, setQuery] = useState(""); const [busy, setBusy] = useState(false); const plan = useMemo(() => (cards ? planMerge(cards) : null), [cards]); + const collator = useMemo(() => new Intl.Collator(locale, { sensitivity: "base", numeric: true }), [locale]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); @@ -81,8 +65,10 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates = return candidates .filter((c) => !suggestedIds.has(c.id)) .filter((c) => !q || `${getContactDisplayName(c)} ${Object.values(c.emails ?? {}).map((e) => e.address).join(" ")}`.toLowerCase().includes(q)) + // By name, as the contact list sorts, in the UI language's order. + .sort((a, b) => collator.compare(getContactDisplayName(a) || getContactPrimaryEmail(a) || "", getContactDisplayName(b) || getContactPrimaryEmail(b) || "")) .slice(0, 200); - }, [candidates, suggested, query]); + }, [candidates, suggested, query, collator]); if (!plan) { const name = pickFor ? getContactDisplayName(pickFor) || getContactPrimaryEmail(pickFor) : ""; @@ -135,11 +121,12 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates = .filter((g) => Object.entries(g.members ?? {}).some(([k, on]) => on && keys.has(k))) .map((g) => getContactDisplayName(g)) .filter(Boolean); - const rows: Array<{ label: string; values: string[] }> = [ + const rows: Array<{ label: string; values: string[]; multiline?: boolean }> = [ { label: t("group_name"), values: [getContactDisplayName(r)].filter(Boolean) }, { label: t("group_email"), values: Object.values(r.emails ?? {}).map((e) => e.address) }, { label: t("group_phone"), values: Object.values(r.phones ?? {}).map((p) => p.number) }, - { label: t("group_adr_any"), values: Object.values(r.addresses ?? {}).map(addressLine).filter(Boolean) }, + // Laid out as the card shows it ("Hauptstraße 5" / "10115 Berlin"), not the parts in stored order. + { label: t("group_adr_any"), values: Object.values(r.addresses ?? {}).map((a) => formatAddressLines(a, locale).join(NEWLINE)).filter(Boolean), multiline: true }, { label: t("group_org"), values: [ @@ -147,8 +134,29 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates = ...Object.values(r.titles ?? {}).map((ti) => ti.name), ].filter(Boolean), }, + { label: t("t_nickname"), values: Object.values(r.nicknames ?? {}).map((n) => n.name).filter(Boolean) }, + // Every date the kept card will have, each under its own name ("Birthday"). + ...(["birth", "wedding", "death", "other"] as const).map((kind) => ({ + label: tc(`detail.anniversary_${kind}`), + values: Object.values(r.anniversaries ?? {}).filter((a) => a.kind === kind && a.date).map((a) => formatContactDate(a.date, locale)), + })), + { + label: tc("detail.gender"), + values: [ + r.speakToAs?.grammaticalGender ? tc(`detail.gender_${r.speakToAs.grammaticalGender}`, { defaultValue: r.speakToAs.grammaticalGender }) : "", + ...Object.values(r.speakToAs?.pronouns ?? {}).map((p) => p.pronouns), + ].filter(Boolean), + }, + { label: tc("detail.language_label"), values: Object.values(r.preferredLanguages ?? {}).map((l) => l.language).filter(Boolean) }, + { label: tc("detail.personal_info"), values: Object.values(r.personalInfo ?? {}).map((pi) => pi.value).filter(Boolean) }, { label: t("t_web"), values: Object.values(r.onlineServices ?? {}).map((s) => s.uri || s.user || "").filter(Boolean) }, - { label: t("t_notes"), values: Object.values(r.notes ?? {}).map((n) => n.note).filter(Boolean) }, + { label: tm("merge_links"), values: [...Object.values(r.links ?? {}), ...Object.values(r.directories ?? {})].map((l) => l.uri).filter(Boolean) }, + { label: tc("detail.calendar_uri"), values: Object.values(r.calendars ?? {}).map((c) => c.uri).filter(Boolean) }, + { label: tc("detail.scheduling_uri"), values: Object.values(r.schedulingAddresses ?? {}).map((c) => c.uri).filter(Boolean) }, + { label: tc("detail.related_contacts"), values: Object.keys(r.relatedTo ?? {}) }, + { label: tc("detail.crypto_keys"), values: Object.keys(r.cryptoKeys ?? {}).length > 0 ? [tm("merge_kept")] : [] }, + { label: tm("merge_photo"), values: Object.keys(r.media ?? {}).length > 0 ? [tm("merge_kept")] : [] }, + { label: t("t_notes"), values: Object.values(r.notes ?? {}).map((n) => n.note).filter(Boolean), multiline: true }, { label: t("t_categories"), values: Object.keys(r.keywords ?? {}).filter((k) => r.keywords?.[k]) }, { label: t("groups"), values: groupNames }, ].filter((row) => row.values.length > 0); @@ -188,7 +196,7 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates =
{row.label}
{row.values.map((value, i) => ( -
{value}
+
{value}
))}
From 77986dddc93fc90780f62fc6f7bd2b1497f7d96d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:59:29 +0100 Subject: [PATCH 4/5] fix: mark a repeated address within an imported file as a duplicate, and end the contact import with a toast that shows them --- .../__tests__/contact-import-ux-p2.test.tsx | 45 +++++++++++++++++++ components/contacts/contact-import-dialog.tsx | 14 +++++- components/contacts/contacts-app.tsx | 16 +++++++ lib/__tests__/vcard.test.ts | 11 +++++ lib/vcard.ts | 17 ++++--- 5 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 components/contacts/__tests__/contact-import-ux-p2.test.tsx diff --git a/components/contacts/__tests__/contact-import-ux-p2.test.tsx b/components/contacts/__tests__/contact-import-ux-p2.test.tsx new file mode 100644 index 000000000..9bc0fc74f --- /dev/null +++ b/components/contacts/__tests__/contact-import-ux-p2.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ContactCard } from '@/lib/jmap/types'; +import { ContactImportDialog } from '../contact-import-dialog'; + +// pim-p2-15 (2026-11-04): a CSV with the same address on two rows imported both, +// and the import ended in a dialog page that only had Close. + +const CSV = [ + 'First Name,Last Name,E-mail Address', + 'Anna,Schmidt,anna@example.org', + 'Bob,Meyer,bob@example.org', + 'Anna,Schmidt,ANNA@example.org', + '', +].join('\r\n'); + +const file = () => new File([CSV], 'contacts-export.csv', { type: 'text/csv' }); + +describe('ContactImportDialog, pass 2', () => { + it('marks the second row with the same address as a duplicate, and imports the rest', async () => { + const onImport = vi.fn(async (cards: ContactCard[]) => cards.length); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'continue' })); + expect(await screen.findByText('Bob Meyer')).toBeInTheDocument(); + expect(screen.getAllByText('import.duplicate')).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: 'import.import_button' })); + await waitFor(() => expect(onImport).toHaveBeenCalled()); + const cards = onImport.mock.calls[0][0] as ContactCard[]; + expect(cards.map((c) => Object.values(c.emails ?? {})[0]?.address)).toEqual(['anna@example.org', 'bob@example.org']); + }); + + it('closes when done and leaves the word to the caller', async () => { + const onClose = vi.fn(); + const onImported = vi.fn(); + render( + cards.length} onClose={onClose} onImported={onImported} initialFile={file()} />, + ); + fireEvent.click(await screen.findByRole('button', { name: 'continue' })); + await screen.findByText('Bob Meyer'); + fireEvent.click(screen.getByRole('button', { name: 'import.import_button' })); + await waitFor(() => expect(onImported).toHaveBeenCalledWith(2, undefined)); + expect(onClose).toHaveBeenCalled(); + expect(screen.queryByText('import.success')).toBeNull(); + }); +}); diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index 9886cf576..598a43c60 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -26,6 +26,11 @@ interface ContactImportDialogProps { addressBooks?: AddressBook[]; /** The book open in Contacts: "Import into" starts there. */ defaultAddressBookId?: string; + /** + * Done: the dialog closes and the caller says so (a toast with Show) instead of a + * page of its own that only has Close. Without it the dialog shows the count. + */ + onImported?: (count: number, addressBookId?: string) => void; } /** The file's bytes; jsdom's File has no arrayBuffer(). */ @@ -52,6 +57,7 @@ export function ContactImportDialog({ initialFile, addressBooks = [], defaultAddressBookId, + onImported, }: ContactImportDialogProps) { const t = useTranslations("contacts"); const shownName = useCollectionNames(); @@ -194,7 +200,13 @@ export function ContactImportDialog({ setIsImporting(true); try { - const count = await onImport(toImport, addressBooks.length > 0 && bookId ? bookId : undefined); + const target = addressBooks.length > 0 && bookId ? bookId : undefined; + const count = await onImport(toImport, target); + if (onImported) { + onClose(); + onImported(count, target); + return; + } setResult(count); } catch (error) { console.error('Failed to import contacts:', error); diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 4e6656441..70d00e408 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -104,6 +104,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const linkSegments = useLiteLinkSegments('contacts', routeSegments); const shownName = useCollectionNames(); const t = useTranslations("contacts"); + const tImport = useTranslations("ux_p2_pim_cm"); const skeleton = useSkeletonLabels(); // Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note). const printOff = useStrictestDevices().noPrint; @@ -601,6 +602,19 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = addressBookId, ); }, [supportsSync, client, importContacts]); + // Done importing: a toast instead of a dialog page, and Show opens where they went. + const handleImported = useCallback((count: number, addressBookId?: string) => { + if (count <= 0) return; + toast.success(t("import.success", { count }), { + action: { + label: tImport("import_show"), + onClick: () => { + setSearchQuery(""); + handleSelectCategory(addressBookId ? { addressBookId } : "all"); + }, + }, + }); + }, [t, tImport, handleSelectCategory, setSearchQuery]); // "Import into" starts at the address book on screen. const openBookId = typeof activeCategory === "object" && "addressBookId" in activeCategory ? activeCategory.addressBookId : undefined; @@ -1679,6 +1693,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onClose={launchedFile.done} addressBooks={supportsSync ? writableBooks : []} defaultAddressBookId={openBookId} + onImported={handleImported} /> )} {showImportDialog && ( @@ -1688,6 +1703,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onClose={() => setShowImportDialog(false)} addressBooks={supportsSync ? writableBooks : []} defaultAddressBookId={openBookId} + onImported={handleImported} /> )} {(mergeCards || mergePickFor) && ( diff --git a/lib/__tests__/vcard.test.ts b/lib/__tests__/vcard.test.ts index 5f956778a..127a5221d 100644 --- a/lib/__tests__/vcard.test.ts +++ b/lib/__tests__/vcard.test.ts @@ -1177,6 +1177,17 @@ describe("detectDuplicates", () => { expect(dupes.get(0)).toBe("existing-1"); }); + // pim-p2-15 (2026-11-04): a CSV with the same address on two rows imported both. + it("marks a later card of the same file with the same address as a duplicate of the first", () => { + const incoming: ContactCard[] = [ + { id: "row-1", addressBookIds: {}, emails: { e0: { address: "anna@example.org" } } }, + { id: "row-2", addressBookIds: {}, emails: { e0: { address: "bob@example.org" } } }, + { id: "row-3", addressBookIds: {}, emails: { e0: { address: " Anna@Example.org" } } }, + ]; + const dupes = detectDuplicates([], incoming); + expect([...dupes.entries()]).toEqual([[2, "row-1"]]); + }); + it("returns empty map when no duplicates", () => { const existing: ContactCard[] = [ { diff --git a/lib/vcard.ts b/lib/vcard.ts index b52d69aca..6ed7b6231 100644 --- a/lib/vcard.ts +++ b/lib/vcard.ts @@ -1596,16 +1596,19 @@ export function detectDuplicates( } } + // A file can hold the same person twice (a CSV export often does): the first + // one counts, the later ones are duplicates of it, by its id. + const seenInFile = new Map(); incoming.forEach((card, idx) => { - if (card.emails) { - for (const e of Object.values(card.emails)) { - const match = existingEmails.get(e.address.toLowerCase()); - if (match) { - dupes.set(idx, match); - return; - } + const addresses = Object.values(card.emails ?? {}).map((e) => e.address?.trim().toLowerCase()).filter(Boolean) as string[]; + for (const address of addresses) { + const match = existingEmails.get(address) ?? seenInFile.get(address); + if (match) { + dupes.set(idx, match); + return; } } + for (const address of addresses) seenInFile.set(address, card.id); }); return dupes; From 112ff9040d38173f70654100861f0f515cc4197f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:01:47 +0100 Subject: [PATCH 5/5] test: check the account address on Contacts and Files, a CSV with a repeated person, and the merge preview on both servers The pass-1 import spec now expects the toast instead of the dialog's count page, and the merge address on its own lines. --- integration/tests/25-ux-pim-cimport.spec.ts | 8 +- integration/tests/26-ux-p2-pim-cm.spec.ts | 231 ++++++++++++++++++++ 2 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 integration/tests/26-ux-p2-pim-cm.spec.ts diff --git a/integration/tests/25-ux-pim-cimport.spec.ts b/integration/tests/25-ux-pim-cimport.spec.ts index 62db2e300..392ed6fb8 100644 --- a/integration/tests/25-ux-pim-cimport.spec.ts +++ b/integration/tests/25-ux-pim-cimport.spec.ts @@ -144,7 +144,8 @@ test.describe('Contacts import and merge (pim-11, pim-14)', () => { await expect(into).toHaveValue(work!.id); if (TAKE_SHOTS) await page.screenshot({ path: `${SHOTS}/pim-fix-11-after-preview.png` }); await dialog.getByRole('button', { name: 'Import', exact: true }).click(); - await expect(dialog.getByText('2 contacts imported')).toBeVisible({ timeout: 30000 }); + // pim-p2-15: the dialog closes and a toast says it. + await expect(page.getByRole('region', { name: 'Notifications' }).getByText('2 contacts imported')).toBeVisible({ timeout: 30000 }); await expect.poll(async () => (await cards(a, `${PREFIX} Import`)).length, { timeout: 30000 }).toBe(2); const imported = await cards(a, `${PREFIX} Import`); @@ -237,7 +238,10 @@ test.describe('Contacts import and merge (pim-11, pim-14)', () => { await expect(preview).toBeVisible(); await expect(preview.getByText(`merge-a-${RUN}@example.org`)).toBeVisible(); await expect(preview.getByText(`merge-b-${RUN}@example.org`)).toBeVisible(); - await expect(preview.getByText('Hauptstraße 1, 10115, Berlin')).toBeVisible(); + // pim-p2-14: laid out on its own lines as the card shows it, not one line of parts. + const addressCell = preview.locator('dd').filter({ hasText: 'Hauptstraße' }); + await expect(addressCell).toContainText('Berlin'); + await expect(addressCell).not.toContainText('Hauptstraße 1, 10115, Berlin'); await expect(preview.getByText(`${PREFIX} Group ${RUN}`)).toBeVisible(); if (TAKE_SHOTS) await page.screenshot({ path: `${SHOTS}/pim-fix-14-after.png` }); await page.getByRole('button', { name: 'Merge', exact: true }).click(); diff --git a/integration/tests/26-ux-p2-pim-cm.spec.ts b/integration/tests/26-ux-p2-pim-cm.spec.ts new file mode 100644 index 000000000..f3966ca79 --- /dev/null +++ b/integration/tests/26-ux-p2-pim-cm.spec.ts @@ -0,0 +1,231 @@ +import { test, expect, type Page } from '@playwright/test'; +import { contextOptions } from './helpers/ent'; +import { JmapClient } from './helpers/jmap'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Usability pass 2, pim-p2-11, p2-14 and p2-15, against a VM 106 Stalwart + * (1.0 and 0.16). Every write is checked on the server. + * + * - Contacts and Files name the account they show by its address, and switch from there. + * - A CSV with the same address on two rows imports one card, and the import ends in a toast. + * - The merge preview lays out the address as the card does and lists the birthday it keeps. + * + * Accounts: dso-ux-pim (A) and dso-ux-pim-b (B), password IT_DSO_PASSWORD. The spec + * makes its own cards, named "UXP2-cm …", and removes them again. + */ + +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +const A = 'dso-ux-pim@example.org'; +const B = 'dso-ux-pim-b@example.org'; +const CORE = 'urn:ietf:params:jmap:core'; +const CONTACTS = 'urn:ietf:params:jmap:contacts'; +const RUN = Date.now().toString(36); +const PREFIX = `UXP2-cm ${RUN}`; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Card = Record & { + id: string; + kind?: string; + name?: { full?: string; components?: Array<{ kind: string; value: string }> }; + emails?: Record; + addressBookIds?: Record; +}; + +const nameOf = (c: Card) => c.name?.full || (c.name?.components ?? []).filter((p) => p.kind !== 'separator').map((p) => p.value).join(' '); + +async function defaultBook(c: JmapClient): Promise { + const r = await c.request([['AddressBook/get', { accountId: c.accountId }, '0']], [CORE, CONTACTS]); + const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>; + return (list.find((b) => b.isDefault) ?? list[0]).id; +} + +async function cards(c: JmapClient): Promise { + const r = await c.request([ + ['ContactCard/query', { accountId: c.accountId, limit: 2000 }, 'q'], + ['ContactCard/get', { accountId: c.accountId, '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' } }, 'g'], + ], [CORE, CONTACTS]); + return (r.methodResponses[1][1].list as Card[]).filter((card) => nameOf(card).includes(PREFIX)); +} + +async function destroyOurs(c: JmapClient): Promise { + const ids = (await cards(c)).map((card) => card.id); + if (ids.length) await c.request([['ContactCard/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CONTACTS]); +} + +async function signIn(page: Page, email: string): Promise { + await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); + await page.fill('#username', email); + await page.fill('#password', PASSWORD); + await page.click('button[type="submit"]'); +} + +async function login(page: Page, email: string): Promise { + await neutralizeDevOverlay(page); + await page.goto('/'); + await signIn(page, email); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); +} + +/** The account the app has active, by its address, from the switcher. */ +async function activeEmail(page: Page): Promise { + const id = await accountSwitcher(page).getAttribute('data-active-account-id'); + if (!id) return null; + await accountSwitcher(page).click(); + const email = await page.locator(`[data-testid="account-option"][data-account-id="${id}"]`).first().getAttribute('data-account-email'); + await page.keyboard.press('Escape'); + return email; +} + +test.describe('Which account Contacts and Files show (pim-p2-11)', () => { + test('the address under the title names the account and switches it', async ({ browser }) => { + test.setTimeout(300_000); + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await accountSwitcher(page).click(); + await page.locator('[data-testid="add-account"]').click(); + await signIn(page, B); + await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(B); + + await page.goto('/contacts'); + const scope = page.getByTestId('account-scope'); + await expect(scope).toHaveText(B, { timeout: 60000 }); + await scope.click(); + const options = page.getByRole('menuitem'); + await expect(options.filter({ hasText: A })).toBeVisible(); + await expect(options.filter({ hasText: B })).toBeVisible(); + await options.filter({ hasText: A }).click(); + await expect(scope).toHaveText(A, { timeout: 60000 }); + await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(A); + + await page.goto('/files'); + await expect(scope).toHaveText(A, { timeout: 60000 }); + await scope.click(); + await page.getByRole('menuitem').filter({ hasText: B }).click(); + await expect(scope).toHaveText(B, { timeout: 60000 }); + await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(B); + await context.close(); + }); +}); + +test.describe('Contacts import and merge, pass 2 (pim-p2-14, pim-p2-15)', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + await destroyOurs(await JmapClient.connect(A, PASSWORD)); + }); + + test('a CSV with one person twice imports one card, and a toast says how many went in', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await page.goto('/contacts'); + await page.getByRole('button', { name: 'New', exact: true }).first().click(); + await page.getByRole('menuitem', { name: /^Import contacts/ }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + const csv = [ + 'First Name,Last Name,E-mail Address', + `Anna,${PREFIX} Twice,twice-${RUN}@example.org`, + `Bob,${PREFIX} Once,once-${RUN}@example.org`, + `Anna,${PREFIX} Twice,TWICE-${RUN}@example.org`, + '', + ].join('\r\n'); + await dialog.locator('input[type="file"]').setInputFiles({ name: 'contacts-export.csv', mimeType: 'text/csv', buffer: Buffer.from(csv, 'utf-8') }); + await dialog.getByRole('button', { name: 'Continue' }).click(); + await expect(dialog.getByText(`Bob ${PREFIX} Once`)).toBeVisible(); + // The second Anna is marked and left out. + await expect(dialog.getByText('Duplicate', { exact: true })).toHaveCount(1); + await dialog.getByRole('button', { name: 'Import', exact: true }).click(); + + const toastRegion = page.getByRole('region', { name: 'Notifications' }); + await expect(toastRegion.getByText('2 contacts imported')).toBeVisible({ timeout: 30000 }); + await expect(dialog).toBeHidden(); + await expect.poll(async () => (await cards(a)).length, { timeout: 30000 }).toBe(2); + const imported = await cards(a); + expect(imported.flatMap((c) => Object.values(c.emails ?? {}).map((e) => e.address.toLowerCase())).sort()) + .toEqual([`once-${RUN}@example.org`, `twice-${RUN}@example.org`]); + // Show opens where they went. + await toastRegion.getByRole('button', { name: 'Show' }).click(); + await page.getByPlaceholder(/Search contacts/).first().fill(PREFIX); + await expect(page.getByRole('option').filter({ hasText: PREFIX })).toHaveCount(2, { timeout: 30000 }); + await context.close(); + }); + + test('the merge preview shows the address as the card does and the birthday it keeps; Merge with is by name', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const book = await defaultBook(a); + const surname = `${PREFIX} Merge`; + const made = await a.request([['ContactCard/set', { + accountId: a.accountId, + create: { + one: { + '@type': 'Card', version: '1.0', kind: 'individual', addressBookIds: { [book]: true }, + name: { '@type': 'Name', components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: surname }], isOrdered: true }, + emails: { e0: { '@type': 'EmailAddress', address: `merge-a-${RUN}@example.org` } }, + addresses: { + a0: { + '@type': 'Address', countryCode: 'DE', isOrdered: true, defaultSeparator: ', ', contexts: { private: true }, + components: [ + { kind: 'name', value: 'Hauptstraße' }, { kind: 'separator', value: ' ' }, { kind: 'number', value: '5' }, + { kind: 'locality', value: 'Berlin' }, { kind: 'postcode', value: '10115' }, { kind: 'country', value: 'Germany' }, + ], + }, + }, + }, + two: { + '@type': 'Card', version: '1.0', kind: 'individual', addressBookIds: { [book]: true }, + name: { '@type': 'Name', components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: surname }], isOrdered: true }, + emails: { e0: { '@type': 'EmailAddress', address: `merge-b-${RUN}@example.org` } }, + anniversaries: { b0: { '@type': 'Anniversary', kind: 'birth', date: { '@type': 'PartialDate', year: 1990, month: 3, day: 14 } } }, + }, + zed: { + '@type': 'Card', version: '1.0', kind: 'individual', addressBookIds: { [book]: true }, + name: { '@type': 'Name', full: `Zed ${PREFIX} Picker` }, + }, + }, + }, '0']], [CORE, CONTACTS]); + const created = made.methodResponses[0][1].created ?? {}; + expect(Object.keys(created).sort(), JSON.stringify(made.methodResponses[0][1].notCreated)).toEqual(['one', 'two', 'zed']); + + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page, A); + await page.goto('/contacts'); + await page.getByPlaceholder(/Search contacts/).first().fill(surname); + const rows = page.getByRole('option').filter({ hasText: surname }); + await expect(rows).toHaveCount(2, { timeout: 60000 }); + + // Merge with…: the list to pick from goes by name. + await rows.first().click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Merge with…' }).click(); + const pickList = page.getByTestId('merge-pick-list'); + await expect(pickList).toBeVisible(); + // The suggested ones come first, in their own block; "All contacts" is the last block. + const all = (await pickList.locator(':scope > div').last().getByRole('button').allInnerTexts()).map((t) => t.split('\n')[0].trim()); + expect(all.length).toBeGreaterThan(1); + expect(all).toEqual([...all].sort((x, y) => x.localeCompare(y, 'en', { sensitivity: 'base', numeric: true }))); + await page.getByRole('button', { name: 'Cancel' }).click(); + + await rows.nth(0).click(); + await rows.nth(1).click({ modifiers: ['Control'] }); + await page.getByRole('button', { name: 'Merge…' }).first().click(); + const preview = page.getByTestId('merge-preview'); + await expect(preview).toBeVisible(); + const address = preview.locator('dd').filter({ hasText: 'Hauptstraße' }); + // Street, then postcode and city, then the country, each on its own line. + expect(await address.innerText()).toBe('Hauptstraße 5\n10115 Berlin\nGermany'); + const birthdayRow = preview.locator('div').filter({ has: page.locator('dt', { hasText: 'Birthday' }) }).last(); + await expect(birthdayRow.locator('dd')).toContainText('1990'); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(preview).toBeHidden(); + // Nothing merged: both cards are still there. + expect((await cards(a)).filter((c) => nameOf(c).includes(surname))).toHaveLength(2); + await context.close(); + }); +});