From 64785c11c0666b7edc8a067fee34b81ae1bdda4c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:45:32 +0100 Subject: [PATCH 01/15] fix: write the Contacts and Files settings labels in sentence case --- locales/en/common.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/locales/en/common.json b/locales/en/common.json index 4329039cf..2e1baa43b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2608,20 +2608,20 @@ "description": "Configure how files and folders are displayed" }, "default_view": { - "label": "Default View", + "label": "Default view", "description": "Choose between grid and list layout", "list": "List", "grid": "Grid" }, "default_sort": { - "label": "Default Sort", + "label": "Default sort", "description": "Choose the default sorting for files", "name": "Name", "size": "Size", "modified": "Modified" }, "sort_direction": { - "label": "Sort Direction", + "label": "Sort direction", "description": "Choose ascending or descending order", "ascending": "Ascending", "descending": "Descending" @@ -2631,15 +2631,15 @@ "description": "Configure file icon appearance" }, "show_icons": { - "label": "Show File Icons", + "label": "Show file icons", "description": "Display icons next to files and folders" }, "colored_icons": { - "label": "Colored Icons", + "label": "Colored icons", "description": "Use colorful icons instead of monochrome" }, "show_thumbnails": { - "label": "Show Thumbnails", + "label": "Show thumbnails", "description": "Display image previews instead of icons for image files" }, "behavior": { @@ -2647,7 +2647,7 @@ "description": "Configure file browser behavior" }, "show_hidden": { - "label": "Show Hidden Files", + "label": "Show hidden files", "description": "Display files and folders that start with a dot" }, "preview": { @@ -3531,7 +3531,7 @@ "file_too_large": "File is too large (max 5 MB)" }, "export": { - "title": "Export Contacts", + "title": "Export contacts", "success": "{count, plural, one {1 contact exported} other {# contacts exported}}", "exporting": "Exporting..." }, From 5f34700553fa257f313cf6a9ccae33433d15ca8d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:48:43 +0100 Subject: [PATCH 02/15] fix: stop the React warning about isPressed on every Contacts visit --- .../__tests__/contact-list-item.test.tsx | 22 ++++++++++++++++++- .../__tests__/contacts-sidebar.test.tsx | 14 ++++++++++++ components/contacts/contact-list-item.tsx | 2 +- components/contacts/contacts-sidebar.tsx | 2 +- hooks/use-long-press.ts | 11 ++++++++-- 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/components/contacts/__tests__/contact-list-item.test.tsx b/components/contacts/__tests__/contact-list-item.test.tsx index d45560265..5f658ac52 100644 --- a/components/contacts/__tests__/contact-list-item.test.tsx +++ b/components/contacts/__tests__/contact-list-item.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { ContactListItem } from '../contact-list-item'; import type { ContactCard } from '@/lib/jmap/types'; @@ -82,4 +82,24 @@ describe('ContactListItem', () => { const { container } = render(); expect(container.querySelector('[data-testid="avatar"]') || container.querySelector('.rounded-full')).toBeNull(); }); + + it('opens the row menu on long-press without leaking the press state onto the row', () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.useFakeTimers(); + try { + const onLongPress = vi.fn(); + const { container } = render(); + const row = container.firstElementChild!; + const touchStart = new Event('touchstart', { bubbles: true }); + Object.defineProperty(touchStart, 'touches', { value: [{ clientX: 5, clientY: 5 }] }); + act(() => { row.dispatchEvent(touchStart); }); + act(() => { vi.advanceTimersByTime(600); }); + expect(onLongPress).toHaveBeenCalledWith(row); + const unknownProp = errors.mock.calls.some((args) => args.some((a) => String(a).includes('isPressed'))); + expect(unknownProp).toBe(false); + } finally { + vi.useRealTimers(); + errors.mockRestore(); + } + }); }); diff --git a/components/contacts/__tests__/contacts-sidebar.test.tsx b/components/contacts/__tests__/contacts-sidebar.test.tsx index 30b3f73a2..c9939eb04 100644 --- a/components/contacts/__tests__/contacts-sidebar.test.tsx +++ b/components/contacts/__tests__/contacts-sidebar.test.tsx @@ -38,6 +38,20 @@ function renderSidebar(onComposeGroup = vi.fn()) { return onComposeGroup; } +// First in the file: React warns about an unknown DOM prop only once per run. +describe('ContactsSidebar — row props', () => { + it('keeps the long-press state off the row element', () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + renderSidebar(); + const unknownProp = errors.mock.calls.some((args) => args.some((a) => String(a).includes('isPressed'))); + expect(unknownProp).toBe(false); + } finally { + errors.mockRestore(); + } + }); +}); + describe('ContactsSidebar — compose to group', () => { beforeEach(() => vi.clearAllMocks()); diff --git a/components/contacts/contact-list-item.tsx b/components/contacts/contact-list-item.tsx index 28e90ad8e..08a95ab67 100644 --- a/components/contacts/contact-list-item.tsx +++ b/components/contacts/contact-list-item.tsx @@ -67,7 +67,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection, return (
({ onTouchStart, onTouchEnd, onTouchMove, onTouchCancel }), + [onTouchStart, onTouchEnd, onTouchMove, onTouchCancel] + ); + + return { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed, handlers }; } From 89200e44af253ea49429c1cf90960777d0a94986 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:49:38 +0100 Subject: [PATCH 03/15] fix: hide key hints in menus on touch screens --- components/ui/__tests__/context-menu-touch.test.tsx | 11 +++++++++++ components/ui/context-menu.tsx | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/components/ui/__tests__/context-menu-touch.test.tsx b/components/ui/__tests__/context-menu-touch.test.tsx index 2a518c982..09a8d9072 100644 --- a/components/ui/__tests__/context-menu-touch.test.tsx +++ b/components/ui/__tests__/context-menu-touch.test.tsx @@ -22,4 +22,15 @@ describe('ContextMenu rows on a touch screen', () => { expect(row.className).not.toMatch(/(^|\s)min-h-11/); } }); + + it('hides key hints under a coarse pointer only (R8)', () => { + render( + {}}> + {}} /> + , + ); + const hint = screen.getByText('F2'); + expect(hint.className).toContain('pointer-coarse:hidden'); + expect(hint.className).not.toMatch(/(^|\s)hidden/); + }); }); diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index 7c63f5898..c6f7e3674 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -560,8 +560,9 @@ export function ContextMenuItem({ ) : ( {label} )} + {/* Key hints mean nothing without a keyboard: hidden on touch (R8). */} {shortcut && ( - {shortcut} + {shortcut} )} ); From 6c63b6f5b80d691b12beaec266d7c6e8b1d73725 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:50:36 +0100 Subject: [PATCH 04/15] fix: name the group delete entry Delete group in the sidebar menu too --- components/contacts/__tests__/contacts-sidebar.test.tsx | 4 ++-- components/contacts/contacts-sidebar.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/contacts/__tests__/contacts-sidebar.test.tsx b/components/contacts/__tests__/contacts-sidebar.test.tsx index c9939eb04..3161a504a 100644 --- a/components/contacts/__tests__/contacts-sidebar.test.tsx +++ b/components/contacts/__tests__/contacts-sidebar.test.tsx @@ -59,9 +59,9 @@ describe('ContactsSidebar — compose to group', () => { renderSidebar(); fireEvent.contextMenu(screen.getByText('Team')); expect(screen.getByText('groups.send_email')).toBeInTheDocument(); - // and the existing Edit/Delete entries still render + // and the existing Edit/Delete entries still render, Delete named as in the group's own ⋮ expect(screen.getByText('groups.edit')).toBeInTheDocument(); - expect(screen.getByText('form.delete')).toBeInTheDocument(); + expect(screen.getByText('groups.delete')).toBeInTheDocument(); }); it('calls onComposeGroup(groupId, field) when a To/Cc/Bcc item is clicked', () => { diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 2b2933c31..961393afc 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -738,7 +738,7 @@ export function ContactsSidebar({ { closeGroupContextMenu(); onDeleteGroup?.(groupContextMenu.data!.id); From b882d4b4deec6015c8cfe833b512d11027fa50b8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:56:39 +0100 Subject: [PATCH 05/15] fix: say Moving, not Adding, in the Move to address book dialog --- .../address-book-picker-dialog.test.tsx | 18 ++++++++++++++++++ .../contacts/address-book-picker-dialog.tsx | 2 +- locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/et/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/th/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 31 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 components/contacts/__tests__/address-book-picker-dialog.test.tsx diff --git a/components/contacts/__tests__/address-book-picker-dialog.test.tsx b/components/contacts/__tests__/address-book-picker-dialog.test.tsx new file mode 100644 index 000000000..07e571b79 --- /dev/null +++ b/components/contacts/__tests__/address-book-picker-dialog.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { AddressBookPickerDialog } from '../address-book-picker-dialog'; +import type { AddressBook } from '@/lib/jmap/types'; + +// next-intl is mocked globally (t returns the key). +const books = [ + { id: 'b1', name: 'Personal' }, + { id: 'b2', name: 'Work' }, +] as unknown as AddressBook[]; + +describe('AddressBookPickerDialog', () => { + it('says the contacts are being moved, not added', () => { + render(); + expect(screen.getByText('bulk.moving_contacts')).toBeInTheDocument(); + expect(screen.queryByText('bulk.adding_contacts')).toBeNull(); + }); +}); diff --git a/components/contacts/address-book-picker-dialog.tsx b/components/contacts/address-book-picker-dialog.tsx index 96843f7a1..9855f3034 100644 --- a/components/contacts/address-book-picker-dialog.tsx +++ b/components/contacts/address-book-picker-dialog.tsx @@ -30,7 +30,7 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current isOpen={isOpen} onClose={onClose} title={t("address_books.move_title")} - description={t("bulk.adding_contacts", { count })} + description={t("bulk.moving_contacts", { count })} onSubmit={(e) => { e.preventDefault(); if (!choice) return; diff --git a/locales/ar/common.json b/locales/ar/common.json index 8362d7178..62ddb0757 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3546,6 +3546,7 @@ "choose_group": "اختر مجموعة", "new_group": "مجموعة جديدة…", "adding_contacts": "جارٍ إضافة {count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}", + "moving_contacts": "جارٍ نقل {count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}", "added_to_group": "تمت إضافة جهات الاتصال إلى المجموعة", "export": "تصدير", "clear": "إلغاء التحديد" diff --git a/locales/ca/common.json b/locales/ca/common.json index 93443e066..0237d930e 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3546,6 +3546,7 @@ "choose_group": "Trieu un grup", "new_group": "Grup nou…", "adding_contacts": "Afegint {count, plural, one {1 contacte} other {# contactes}}", + "moving_contacts": "Movent {count, plural, one {1 contacte} other {# contactes}}", "added_to_group": "Contactes afegits al grup", "export": "Exporta", "clear": "Neteja la selecció" diff --git a/locales/cs/common.json b/locales/cs/common.json index 9a36130c2..c7a66803c 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3544,6 +3544,7 @@ "choose_group": "Vyberte skupinu", "new_group": "Nová skupina…", "adding_contacts": "Přidávání {count, plural, one {1 kontaktu} few {# kontaktů} other {# kontaktů}}", + "moving_contacts": "Přesouvání {count, plural, one {1 kontaktu} few {# kontaktů} other {# kontaktů}}", "added_to_group": "Kontakty byly přidány do skupiny", "export": "Exportovat", "clear": "Zrušit výběr" diff --git a/locales/da/common.json b/locales/da/common.json index 3a02cdc78..9ce02dd5e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3544,6 +3544,7 @@ "choose_group": "Vælg en gruppe", "new_group": "Ny gruppe…", "adding_contacts": "Tilføjer {count, plural, one {1 kontakt} other {# kontakter}}", + "moving_contacts": "Flytter {count, plural, one {1 kontakt} other {# kontakter}}", "added_to_group": "Kontakter tilføjet til gruppe", "export": "Eksportér", "clear": "Ryd markering" diff --git a/locales/de/common.json b/locales/de/common.json index c48ca51e2..90b78557f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3544,6 +3544,7 @@ "choose_group": "Gruppe wählen", "new_group": "Neue Gruppe…", "adding_contacts": "{count, plural, one {1 Kontakt} other {# Kontakte}} hinzufügen", + "moving_contacts": "{count, plural, one {1 Kontakt} other {# Kontakte}} verschieben", "added_to_group": "Kontakte zur Gruppe hinzugefügt", "export": "Exportieren", "clear": "Auswahl aufheben" diff --git a/locales/en/common.json b/locales/en/common.json index 2e1baa43b..f98d5ff90 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3546,6 +3546,7 @@ "choose_group": "Choose a group", "new_group": "New group…", "adding_contacts": "Adding {count, plural, one {1 contact} other {# contacts}}", + "moving_contacts": "Moving {count, plural, one {1 contact} other {# contacts}}", "added_to_group": "Contacts added to group", "export": "Export", "clear": "Clear selection" diff --git a/locales/es/common.json b/locales/es/common.json index 681722895..0df9c31b3 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3544,6 +3544,7 @@ "choose_group": "Elegir un grupo", "new_group": "Nuevo grupo…", "adding_contacts": "Agregando {count, plural, one {1 contacto} other {# contactos}}", + "moving_contacts": "Moviendo {count, plural, one {1 contacto} other {# contactos}}", "added_to_group": "Contactos agregados al grupo", "export": "Exportar", "clear": "Limpiar selección" diff --git a/locales/et/common.json b/locales/et/common.json index 6677299c0..67fa7f290 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -3546,6 +3546,7 @@ "choose_group": "Vali grupp", "new_group": "Uus rühm…", "adding_contacts": "Lisamine: {count, plural, one {1 kontakt} other {# kontakti}}", + "moving_contacts": "Teisaldamine: {count, plural, one {1 kontakt} other {# kontakti}}", "added_to_group": "Kontaktid lisati gruppi", "export": "Eksport", "clear": "Tühista valik" diff --git a/locales/fa/common.json b/locales/fa/common.json index 32557ba10..08619181e 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3546,6 +3546,7 @@ "choose_group": "انتخاب گروه", "new_group": "گروه جدید…", "adding_contacts": "افزودن {count, plural, one {۱ مخاطب} other {# مخاطب}}", + "moving_contacts": "انتقال {count, plural, one {۱ مخاطب} other {# مخاطب}}", "added_to_group": "مخاطبین به گروه اضافه شدند", "export": "خروجی", "clear": "پاک کردن انتخاب" diff --git a/locales/fr/common.json b/locales/fr/common.json index 45c46b169..c76481a71 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3544,6 +3544,7 @@ "choose_group": "Choisir un groupe", "new_group": "Nouveau groupe…", "adding_contacts": "Ajout de {count, plural, one {1 contact} other {# contacts}}", + "moving_contacts": "Déplacement de {count, plural, one {1 contact} other {# contacts}}", "added_to_group": "Contacts ajoutés au groupe", "export": "Exporter", "clear": "Effacer la sélection" diff --git a/locales/he/common.json b/locales/he/common.json index af15c6fe9..a2228c31e 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3448,6 +3448,7 @@ "choose_group": "בחר קבוצה", "new_group": "קבוצה חדשה…", "adding_contacts": "מוסיף {count, plural, one {איש קשר אחד} other {# אנשי קשר}}", + "moving_contacts": "מעביר {count, plural, one {איש קשר אחד} other {# אנשי קשר}}", "added_to_group": "אנשי קשר נוספו לקבוצה", "export": "ייצוא", "clear": "נקה בחירה" diff --git a/locales/hu/common.json b/locales/hu/common.json index 4aa36c35c..fadee2bbf 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3546,6 +3546,7 @@ "choose_group": "Csoport kiválasztása", "new_group": "Új csoport…", "adding_contacts": "{count, plural, one {1 névjegy} other {# névjegy}} hozzáadása", + "moving_contacts": "{count, plural, one {1 névjegy} other {# névjegy}} áthelyezése", "added_to_group": "Névjegyek hozzáadva a csoporthoz", "export": "Exportálás", "clear": "Kijelölés megszüntetése" diff --git a/locales/it/common.json b/locales/it/common.json index 4d5ec0030..f87973ef8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3544,6 +3544,7 @@ "choose_group": "Scegli un gruppo", "new_group": "Nuovo gruppo…", "adding_contacts": "Aggiunta di {count, plural, one {1 contatto} other {# contatti}}", + "moving_contacts": "Spostamento di {count, plural, one {1 contatto} other {# contatti}}", "added_to_group": "Contatti aggiunti al gruppo", "export": "Esporta", "clear": "Cancella selezione" diff --git a/locales/ja/common.json b/locales/ja/common.json index 5d6d4771c..8250a57f2 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3544,6 +3544,7 @@ "choose_group": "グループを選択", "new_group": "新しいグループ…", "adding_contacts": "{count, plural, other {#件の連絡先}}を追加", + "moving_contacts": "{count, plural, other {#件の連絡先}}を移動", "added_to_group": "連絡先をグループに追加しました", "export": "エクスポート", "clear": "選択を解除" diff --git a/locales/ko/common.json b/locales/ko/common.json index 5f5e78516..4f037cce1 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3544,6 +3544,7 @@ "choose_group": "그룹 선택", "new_group": "새 그룹…", "adding_contacts": "{count}개의 연락처를 추가하는 중...", + "moving_contacts": "연락처 {count}개 이동", "added_to_group": "연락처가 그룹에 추가되었어요", "export": "내보내기", "clear": "선택 취소" diff --git a/locales/lv/common.json b/locales/lv/common.json index 4abab7cdc..fb09d2815 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3536,6 +3536,7 @@ "choose_group": "Izvēlēties grupu", "new_group": "Jauna grupa…", "adding_contacts": "Pievieno {count, plural, one {1 kontaktu} other {# kontaktus}}", + "moving_contacts": "Pārvieto {count, plural, one {1 kontaktu} other {# kontaktus}}", "added_to_group": "Kontakti pievienoti grupai", "export": "Eksportēt", "clear": "Notīrīt atlasi" diff --git a/locales/mn/common.json b/locales/mn/common.json index 6e29449b6..5e97ed96a 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3546,6 +3546,7 @@ "choose_group": "Бүлэг сонгох", "new_group": "Шинэ бүлэг…", "adding_contacts": "{count, plural, one {1 контакт} other {# контакт}} нэмэж байна", + "moving_contacts": "{count, plural, one {1 контакт} other {# контакт}} зөөж байна", "added_to_group": "Харилцагчдыг группт нэмсэн", "export": "Экспорт хийх", "clear": "Сонголтыг арилгах" diff --git a/locales/nb/common.json b/locales/nb/common.json index 316709528..e67ab6745 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3546,6 +3546,7 @@ "choose_group": "Velg en gruppe", "new_group": "Ny gruppe…", "adding_contacts": "Legger til {count, plural, one {1 kontakt} other {# kontakter}}", + "moving_contacts": "Flytter {count, plural, one {1 kontakt} other {# kontakter}}", "added_to_group": "Kontaktene er lagt til i gruppen", "export": "Eksporter", "clear": "Fjern valg" diff --git a/locales/nl/common.json b/locales/nl/common.json index a9d8f202d..d60f5c317 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3544,6 +3544,7 @@ "choose_group": "Kies een groep", "new_group": "Nieuwe groep…", "adding_contacts": "{count, plural, one {1 contact} other {# contacten}} toevoegen", + "moving_contacts": "{count, plural, one {1 contact} other {# contacten}} verplaatsen", "added_to_group": "Contacten aan groep toegevoegd", "export": "Exporteren", "clear": "Selectie wissen" diff --git a/locales/pl/common.json b/locales/pl/common.json index 69c043ef9..78f85294a 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3544,6 +3544,7 @@ "choose_group": "Wybierz grupę", "new_group": "Nowa grupa…", "adding_contacts": "Dodawanie {count, plural, one {1 kontaktu} other {# kontaktów}}", + "moving_contacts": "Przenoszenie {count, plural, one {1 kontaktu} other {# kontaktów}}", "added_to_group": "Kontakty dodano do grupy", "export": "Eksportuj", "clear": "Wyczyść zaznaczenie" diff --git a/locales/pt/common.json b/locales/pt/common.json index 34febc6cd..621cf81fd 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3544,6 +3544,7 @@ "choose_group": "Escolher um grupo", "new_group": "Novo grupo…", "adding_contacts": "Adicionando {count, plural, one {1 contato} other {# contatos}}", + "moving_contacts": "Movendo {count, plural, one {1 contato} other {# contatos}}", "added_to_group": "Contatos adicionados ao grupo", "export": "Exportar", "clear": "Limpar seleção" diff --git a/locales/ro/common.json b/locales/ro/common.json index c888e1c34..d41e81db6 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3546,6 +3546,7 @@ "choose_group": "Alegeți un grup", "new_group": "Grup nou…", "adding_contacts": "Adăugare {count, plural, one {1 contact} few {# contacte} other {# de contacte}}", + "moving_contacts": "Mutare {count, plural, one {1 contact} few {# contacte} other {# de contacte}}", "added_to_group": "Contacte adăugate la grup", "export": "Export", "clear": "Șterge selecția" diff --git a/locales/ru/common.json b/locales/ru/common.json index 26248fc12..d2023690e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3544,6 +3544,7 @@ "choose_group": "Выбрать группу", "new_group": "Новая группа…", "adding_contacts": "Добавляем {count, plural, one {1 контакт} other {# контактов}}", + "moving_contacts": "Перемещение {count, plural, one {# контакта} other {# контактов}}", "added_to_group": "Контакты добавлены в группу", "export": "Экспорт", "clear": "Очистить выбор" diff --git a/locales/sk/common.json b/locales/sk/common.json index 5f78df7b6..cef23a425 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3546,6 +3546,7 @@ "choose_group": "Vyberte skupinu", "new_group": "Nová skupina…", "adding_contacts": "Pridávanie {count, plural, one {1 kontaktu} other {# kontaktov}}", + "moving_contacts": "Presúvanie {count, plural, one {1 kontaktu} other {# kontaktov}}", "added_to_group": "Kontakty boli pridané do skupiny", "export": "Exportovať", "clear": "Zrušiť výber" diff --git a/locales/th/common.json b/locales/th/common.json index 191606baa..91f685019 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2670,6 +2670,7 @@ "choose_group": "เลือกกลุ่ม", "new_group": "กลุ่มใหม่…", "adding_contacts": "กำลังเพิ่มผู้ติดต่อ {count, plural, one {1 รายการ} other {# รายการ}}", + "moving_contacts": "กำลังย้ายผู้ติดต่อ {count, plural, one {1 รายการ} other {# รายการ}}", "added_to_group": "เพิ่มผู้ติดต่อในกลุ่มแล้ว", "export": "ส่งออก", "clear": "ล้างการเลือก" diff --git a/locales/tr/common.json b/locales/tr/common.json index ccb1b0544..75fc462b8 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3544,6 +3544,7 @@ "choose_group": "Grup seçin", "new_group": "Yeni grup…", "adding_contacts": "{count, plural, one {1 kişi} other {# kişi}} ekleniyor", + "moving_contacts": "{count, plural, one {1 kişi} other {# kişi}} taşınıyor", "added_to_group": "Kişiler gruba eklendi", "export": "Dışa Aktar", "clear": "Seçimi temizle" diff --git a/locales/uk/common.json b/locales/uk/common.json index 735d06df1..9fa60c275 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3544,6 +3544,7 @@ "choose_group": "Виберіть групу", "new_group": "Нова група…", "adding_contacts": "Додавання {count, plural, one {1 contact} other {# contacts}}", + "moving_contacts": "Переміщення {count, plural, one {# контакту} other {# контактів}}", "added_to_group": "Контакти додано до групи", "export": "Експорт", "clear": "Очистити вибір" diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 904bc88de..3137404d4 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3546,6 +3546,7 @@ "choose_group": "選擇群組", "new_group": "新增群組…", "adding_contacts": "正在新增{count, plural, one {1 位聯絡人} other {# 位聯絡人}}", + "moving_contacts": "正在移動{count, plural, one {1 位聯絡人} other {# 位聯絡人}}", "added_to_group": "聯絡人已新增至群組", "export": "匯出", "clear": "清除選取" diff --git a/locales/zh/common.json b/locales/zh/common.json index c0d17e304..84308793d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3544,6 +3544,7 @@ "choose_group": "选择一个组", "new_group": "新建群组…", "adding_contacts": "正在添加{count, plural, one {1 位联系人} other {# 位联系人}}", + "moving_contacts": "正在移动{count, plural, one {1 位联系人} other {# 位联系人}}", "added_to_group": "联系人已添加至群组", "export": "导出", "clear": "清除选择" From 330627cec172b668e675478a5b8e08eb0453ce55 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:04:42 +0100 Subject: [PATCH 06/15] fix: name the contact or group in the delete confirm and say what is lost --- .../contacts-delete-confirm.test.tsx | 113 ++++++++++++++++++ components/contacts/contacts-app.tsx | 56 +++++---- locales/ar/common.json | 7 +- locales/ca/common.json | 7 +- locales/cs/common.json | 7 +- locales/da/common.json | 7 +- locales/de/common.json | 7 +- locales/en/common.json | 7 +- locales/es/common.json | 7 +- locales/et/common.json | 7 +- locales/fa/common.json | 7 +- locales/fr/common.json | 7 +- locales/he/common.json | 7 +- locales/hu/common.json | 7 +- locales/it/common.json | 7 +- locales/ja/common.json | 7 +- locales/ko/common.json | 7 +- locales/lv/common.json | 7 +- locales/mn/common.json | 7 +- locales/nb/common.json | 7 +- locales/nl/common.json | 7 +- locales/pl/common.json | 7 +- locales/pt/common.json | 7 +- locales/ro/common.json | 7 +- locales/ru/common.json | 7 +- locales/sk/common.json | 7 +- locales/th/common.json | 7 +- locales/tr/common.json | 7 +- locales/uk/common.json | 7 +- locales/zh-TW/common.json | 7 +- locales/zh/common.json | 7 +- 31 files changed, 227 insertions(+), 145 deletions(-) create mode 100644 components/contacts/__tests__/contacts-delete-confirm.test.tsx diff --git a/components/contacts/__tests__/contacts-delete-confirm.test.tsx b/components/contacts/__tests__/contacts-delete-confirm.test.tsx new file mode 100644 index 000000000..54fc25d59 --- /dev/null +++ b/components/contacts/__tests__/contacts-delete-confirm.test.tsx @@ -0,0 +1,113 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ContactCard } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// The delete confirms must name what goes (R3), so this file's t() shows the +// values it was given: "delete_named_title {"name":"Alice Smith"}". +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => + values ? `${key} ${JSON.stringify(values)}` : key, + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (d: Date | string) => String(d), + relativeTime: (d: Date | string) => String(d), + number: (n: number) => String(n), + }), +})); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), back: vi.fn() }), + useParams: () => ({ locale: 'en' }), + usePathname: () => '/en/contacts', + useSearchParams: () => new URLSearchParams(), +})); +vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () =>
- + } /> {selection.length <= 1 && }
{selection.length <= 1 && ( @@ -160,7 +160,7 @@ export function FileDetailsPanel({ label={t("type")} value={resource.isDirectory || resource.isSymlink ? kindOf(resource, t) : (resource.contentType || kindOf(resource, t))} /> - {!resource.isDirectory && !resource.isSymlink && } + {!resource.isDirectory && !resource.isSymlink && } />} {resource.lastModified && } @@ -170,7 +170,7 @@ export function FileDetailsPanel({ ); } -function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { +function Row({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) { return (
{label}
diff --git a/components/files/file-item-parts.tsx b/components/files/file-item-parts.tsx index 2b201fc3e..37a33b43e 100644 --- a/components/files/file-item-parts.tsx +++ b/components/files/file-item-parts.tsx @@ -3,8 +3,10 @@ import { useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { Share2, Users } from "@/components/icons"; -import { cn } from "@/lib/utils"; +import { cn, formatFileSize } from "@/lib/utils"; import { getEffectiveTimeZone } from "@/lib/timezone"; +import { getEffectiveLocale } from "@/i18n/detect-locale"; +import { useSettingsStore } from "@/stores/settings-store"; import type { FileResource } from "@/stores/file-store"; import { getFileIconByName, getFolderIcon, getSymlinkIcon, isImageFile, type FileIconSize } from "@/components/files/file-icons"; @@ -124,17 +126,33 @@ export function ShareBadge({ resource, t, className }: { return null; } +/** + * A size that keeps its number before its unit ("6.7 KB") in a right-to-left + * layout too, where the bidi algorithm would otherwise show "KB 6.7". + */ +export function FileSize({ bytes }: { bytes: number }) { + return {formatFileSize(bytes)}; +} + +/** Dates follow the app's language and 12/24-hour setting, as Mail's list does, not the browser's. */ +function dateLocale(): { locale: string; hour12: boolean } { + const locale = getEffectiveLocale(); + return { locale: locale === "en" ? "en-US" : locale, hour12: useSettingsStore.getState().timeFormat === "12h" }; +} + /** Full date and time, for tooltips and the details panel. */ export function formatFullDate(value: string): string { if (!value) return ""; try { - return new Date(value).toLocaleString(undefined, { + const { locale, hour12 } = dateLocale(); + return new Date(value).toLocaleString(locale, { timeZone: getEffectiveTimeZone(), year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", + hour12, }); } catch { return value; @@ -149,12 +167,13 @@ export function formatShortDate(value: string, now = new Date()): string { const timeZone = getEffectiveTimeZone(); const dayKey = (d: Date) => d.toLocaleDateString("en-CA", { timeZone }); try { + const { locale, hour12 } = dateLocale(); if (dayKey(date) === dayKey(now)) { - return date.toLocaleTimeString(undefined, { timeZone, hour: "2-digit", minute: "2-digit" }); + return date.toLocaleTimeString(locale, { timeZone, hour: "2-digit", minute: "2-digit", hour12 }); } const sameYear = date.toLocaleDateString("en-CA", { timeZone, year: "numeric" }) === now.toLocaleDateString("en-CA", { timeZone, year: "numeric" }); - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString(locale, { timeZone, day: "numeric", month: "short", diff --git a/components/files/file-picker-dialog.tsx b/components/files/file-picker-dialog.tsx index 55a94e275..e49320ca0 100644 --- a/components/files/file-picker-dialog.tsx +++ b/components/files/file-picker-dialog.tsx @@ -175,7 +175,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr {node.name} {detail && {detail}} - {formatFileSize(node.size)} + {formatFileSize(node.size)} ); }; diff --git a/components/files/file-views.tsx b/components/files/file-views.tsx index f3d23336d..ce627400a 100644 --- a/components/files/file-views.tsx +++ b/components/files/file-views.tsx @@ -3,11 +3,11 @@ import { useRef } from "react"; import { useTranslations } from "next-intl"; import { ArrowUp, ArrowDown, MoreVertical, Download, Share2, Star, Check } from "@/components/icons"; -import { cn, formatFileSize } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { IconButton } from "@/components/ui/icon-button"; import type { FileResource } from "@/stores/file-store"; import { - ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate, + FileSize, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate, type IconOptions, } from "@/components/files/file-item-parts"; import type { SortDir, SortKey } from "@/components/files/files-header"; @@ -271,7 +271,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
{note && <>{note} · } {formatShortDate(resource.lastModified)} - {!resource.isDirectory && !resource.isSymlink && <> · {formatFileSize(resource.contentLength)}} + {!resource.isDirectory && !resource.isSymlink && <> · }
)}
@@ -280,7 +280,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS {!touch && formatShortDate(resource.lastModified)}
- {!touch && (resource.isDirectory || resource.isSymlink ? "—" : formatFileSize(resource.contentLength))} + {!touch && (resource.isDirectory || resource.isSymlink ? "—" : )}
{!touch && !resource.isDirectory && !resource.isSymlink && ( @@ -482,7 +482,7 @@ function FileTile({ resource, isSelected, isFocused, isCut, selecting, touch, ic
- {resource.isSymlink ? "" : `${formatFileSize(resource.contentLength)} · `}{formatShortDate(resource.lastModified)} + {!resource.isSymlink && <> · }{formatShortDate(resource.lastModified)}
From 94029e5aa236d4c676637afb1d8c170b053338ac Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:28:07 +0100 Subject: [PATCH 13/15] fix: ask before the nav rail or the phone tab bar leaves a contact or group form with changes --- .../contacts/__tests__/contacts-app.test.tsx | 24 ++++++ components/contacts/contacts-app.tsx | 22 +++-- .../navigation-rail-leave-guard.test.tsx | 52 +++++++++++ components/layout/navigation-rail.tsx | 6 +- hooks/use-close-on-back.ts | 8 ++ lib/__tests__/leave-guard.test.ts | 86 +++++++++++++++++++ lib/leave-guard.ts | 78 +++++++++++++++++ 7 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 components/layout/__tests__/navigation-rail-leave-guard.test.tsx create mode 100644 lib/__tests__/leave-guard.test.ts create mode 100644 lib/leave-guard.ts diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index a69a0e1d4..95d65033a 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -42,6 +42,7 @@ function getRegisteredGroups(): CommandGroup[] { import { useAuthStore } from '@/stores/auth-store'; import { useContactStore } from '@/stores/contact-store'; import { useToastStore } from '@/stores/toast-store'; +import { interceptLeave } from '@/lib/leave-guard'; export const alice: ContactCard = { id: 'c-alice', @@ -146,6 +147,29 @@ describe('ContactsApp', () => { expect([...useContactStore.getState().selectedContactIds].sort()).toEqual(['c-bob', 'c-carol']); }); + it('has the rail and tab bar ask before they leave a contact form with changes', async () => { + renderContacts(); + fireEvent.click(screen.getByText('Alice Smith')); + fireEvent.click(screen.getByRole('button', { name: /detail.edit|edit/ })); + fireEvent.change(screen.getByPlaceholderText('middle_name'), { target: { value: 'Marie' } }); + + // A rail link wired as the real one is: interceptLeave first. + const navigated = vi.fn(); + const link = document.createElement('a'); + link.addEventListener('click', (e) => { if (!interceptLeave(e)) navigated(); }); + document.body.appendChild(link); + try { + act(() => { link.click(); }); + const dialog = await screen.findByRole('alertdialog'); + expect(within(dialog).getByText('form.discard_title')).toBeInTheDocument(); + expect(navigated).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'form.discard' })); + await waitFor(() => expect(navigated).toHaveBeenCalledOnce()); + } finally { + link.remove(); + } + }); + it('leaves an untouched contact form without asking', () => { renderContacts(); fireEvent.click(screen.getByText('Alice Smith')); diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 3e88086d2..e4ed9c6b5 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -52,6 +52,7 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; import { useDocumentTitle } from "@/hooks/use-document-title"; import { useRegisterCommands } from "@/lib/command-registry"; import { useCloseOnBack } from "@/hooks/use-close-on-back"; +import { useLeaveGuard } from "@/lib/leave-guard"; import { useDeleteCategory } from "@/hooks/use-delete-category"; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; @@ -172,18 +173,23 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = useEffect(() => { if (view !== "create" && view !== "edit" && view !== "group-create" && view !== "group-edit") setEditorDirty(false); }, [view, setEditorDirty]); + const askDiscard = useCallback(() => confirmDialog({ + title: t("form.discard_title"), + message: t("form.discard_message"), + confirmText: t("form.discard"), + cancelText: t("form.keep_editing"), + variant: "destructive", + }), [confirmDialog, t]); const confirmDiscard = useCallback(async () => { if (!editorDirtyRef.current) return true; - const discard = await confirmDialog({ - title: t("form.discard_title"), - message: t("form.discard_message"), - confirmText: t("form.discard"), - cancelText: t("form.keep_editing"), - variant: "destructive", - }); + const discard = await askDiscard(); if (discard) setEditorDirty(false); return discard; - }, [confirmDialog, t, setEditorDirty]); + }, [askDiscard, setEditorDirty]); + // The rail and the phone tab bar ask too, before they leave Contacts. Only + // ask there: leaving unmounts the form, and clearing it first would let the + // Back guard below take its history step back over the navigation. + useLeaveGuard(() => editorDirtyRef.current, askDiscard); const guard = (action: (...args: A) => unknown) => (...args: A): void => { if (!editorDirtyRef.current) { action(...args); diff --git a/components/layout/__tests__/navigation-rail-leave-guard.test.tsx b/components/layout/__tests__/navigation-rail-leave-guard.test.tsx new file mode 100644 index 000000000..3bc5f9ae7 --- /dev/null +++ b/components/layout/__tests__/navigation-rail-leave-guard.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// An editor with unsaved changes registers a leave guard; the rail and the +// phone tab bar ask it before they leave the app (R5). + +vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false })); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }), + usePathname: () => '/contacts', + Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => {children}, +})); + +const { NavigationRail } = await import('../navigation-rail'); +const { useUpdateStore } = await import('@/stores/update-store'); +const { registerLeaveGuard } = await import('@/lib/leave-guard'); + +let off: (() => void) | null = null; +beforeEach(() => { + useUpdateStore.setState({ startPolling: vi.fn() } as never); +}); +afterEach(() => { off?.(); off = null; }); + +describe.each([ + ['desktop rail', { orientation: 'vertical', collapsed: true }], + ['phone tab bar', { orientation: 'horizontal', collapsed: false }], +] as const)('%s', (_name, props) => { + it('asks before leaving an editor with unsaved changes, and stays on "Keep editing"', async () => { + const confirm = vi.fn(async () => false); + off = registerLeaveGuard({ isDirty: () => true, confirm }); + render(); + const mail = screen.getAllByRole('link', { name: 'mail' })[0]; + const click = fireEvent.click(mail); + // fireEvent returns false when the default (navigating) was prevented. + expect(click).toBe(false); + expect(confirm).toHaveBeenCalledOnce(); + }); + + it('leaves without asking when nothing is unsaved', () => { + const confirm = vi.fn(async () => false); + off = registerLeaveGuard({ isDirty: () => false, confirm }); + render(); + fireEvent.click(screen.getAllByRole('link', { name: 'mail' })[0]); + expect(confirm).not.toHaveBeenCalled(); + }); +}); diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index c3c0af96b..8a0c854e9 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -27,6 +27,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useStalwartAdmin } from "@/hooks/use-stalwart-admin"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { getPathPrefix, withBasePath } from "@/lib/browser-navigation"; +import { interceptLeave } from "@/lib/leave-guard"; import { Avatar } from "@/components/ui/avatar"; import { toUnicodeEmail } from "@/lib/idn"; import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages"; @@ -351,6 +352,7 @@ export function NavigationRail({ const handleNavClick = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => (e: React.MouseEvent) => { + if (interceptLeave(e)) return; if (onNavigate) { const intercepted = onNavigate(itemId); if (intercepted !== false) { @@ -421,7 +423,7 @@ export function NavigationRail({ { if (activeAppId) onCloseInlineApp?.(); }} + onClick={(e) => { if (interceptLeave(e)) return; if (activeAppId) onCloseInlineApp?.(); }} data-plugin-page-link={`${page.pluginId}/${page.id}`} title={page.pluginName} className={cn( @@ -633,7 +635,7 @@ export function NavigationRail({ { if (activeAppId) onCloseInlineApp?.(); }} + onClick={(e) => { if (interceptLeave(e)) return; if (activeAppId) onCloseInlineApp?.(); }} data-plugin-page-link={`${page.pluginId}/${page.id}`} className={cn( "relative flex items-center gap-2.5 rounded-md transition-colors duration-150", diff --git a/hooks/use-close-on-back.ts b/hooks/use-close-on-back.ts index ba8079478..01c02f7ba 100644 --- a/hooks/use-close-on-back.ts +++ b/hooks/use-close-on-back.ts @@ -10,6 +10,14 @@ const STATE_KEY = "__closeOnBack"; // would pop it and close the layer at once. let pendingHistoryBack: Promise | null = null; +/** + * Resolves once the Back a closed layer took to remove its history entry has + * landed. A navigation started before then would be undone by it. + */ +export function historySettled(): Promise { + return pendingHistoryBack ?? Promise.resolve(); +} + const currentEntry = () => (window.history.state as Record | null)?.[STATE_KEY]; interface UseCloseOnBackOptions { diff --git a/lib/__tests__/leave-guard.test.ts b/lib/__tests__/leave-guard.test.ts new file mode 100644 index 000000000..80661c2e7 --- /dev/null +++ b/lib/__tests__/leave-guard.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useCloseOnBack } from '@/hooks/use-close-on-back'; +import { confirmLeave, hasUnsavedChanges, interceptLeave, registerLeaveGuard } from '@/lib/leave-guard'; + +const cleanups: Array<() => void> = []; +afterEach(() => { cleanups.splice(0).forEach((off) => off()); }); + +function guard(dirty: { value: boolean }, answer: boolean) { + const confirm = vi.fn(async () => { + if (answer) dirty.value = false; + return answer; + }); + cleanups.push(registerLeaveGuard({ isDirty: () => dirty.value, confirm })); + return confirm; +} + +function link() { + const a = document.createElement('a'); + const onClick = vi.fn((e: MouseEvent) => { interceptLeave(e as unknown as { preventDefault(): void; currentTarget: EventTarget | null }); }); + a.addEventListener('click', onClick); + document.body.appendChild(a); + cleanups.push(() => a.remove()); + return { a, onClick }; +} + +describe('leave guard', () => { + it('lets a click through when nothing is unsaved', () => { + guard({ value: false }, true); + const e = { preventDefault: vi.fn(), currentTarget: null }; + expect(interceptLeave(e)).toBe(false); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + it('stops the click, asks, and clicks again once the changes are let go', async () => { + const confirm = guard({ value: true }, true); + const { a, onClick } = link(); + const click = new MouseEvent('click', { cancelable: true }); + a.dispatchEvent(click); + expect(click.defaultPrevented).toBe(true); + expect(confirm).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(onClick).toHaveBeenCalledTimes(2)); + expect(hasUnsavedChanges()).toBe(false); + }); + + it('stays put when the user keeps editing', async () => { + const confirm = guard({ value: true }, false); + const { a, onClick } = link(); + a.dispatchEvent(new MouseEvent('click', { cancelable: true })); + await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce()); + await Promise.resolve(); + expect(onClick).toHaveBeenCalledTimes(1); + expect(hasUnsavedChanges()).toBe(true); + }); + + it('leaves only once the asking dialog has taken its history entry back off', async () => { + // The confirm dialog holds a history entry while it is open (Back closes + // it) and steps Back off it as it closes. Leaving before that Back lands + // would be undone by it. + const dialog = renderHook((props: { open: boolean }) => useCloseOnBack({ onBack: () => {}, active: props.open }), { + initialProps: { open: true }, + }); + await vi.waitFor(() => expect((window.history.state as Record | null)?.__closeOnBack).toBeTruthy()); + const order: string[] = []; + window.addEventListener('popstate', () => order.push('back landed'), { once: true }); + cleanups.push(registerLeaveGuard({ + isDirty: () => true, + confirm: async () => { dialog.rerender({ open: false }); return true; }, + })); + const a = document.createElement('a'); + a.addEventListener('click', (e) => { if (!interceptLeave(e)) order.push('left'); }); + document.body.appendChild(a); + cleanups.push(() => { a.remove(); dialog.unmount(); }); + a.click(); + await vi.waitFor(() => expect(order).toContain('left')); + expect(order).toEqual(['back landed', 'left']); + }); + + it('asks only the editors that have changes', async () => { + const clean = guard({ value: false }, true); + const dirty = guard({ value: true }, true); + expect(await confirmLeave()).toBe(true); + expect(clean).not.toHaveBeenCalled(); + expect(dirty).toHaveBeenCalledOnce(); + }); +}); diff --git a/lib/leave-guard.ts b/lib/leave-guard.ts new file mode 100644 index 000000000..6e927031a --- /dev/null +++ b/lib/leave-guard.ts @@ -0,0 +1,78 @@ +import { useEffect, useRef } from "react"; +import { historySettled } from "@/hooks/use-close-on-back"; + +/** + * Leave guard (R5): an editor with unsaved changes registers here, and the + * shell's own navigation (the desktop rail, the phone tab bar) asks it + * before leaving. The editor's in-app exits (Cancel, another item, Back) + * ask through the editor's own code; this covers what the editor can't see. + */ +export interface LeaveGuard { + /** True while there is something to lose. Read on every navigation. */ + isDirty: () => boolean; + /** + * Ask the user; resolves true when the changes may be dropped. Only ask: + * the navigation that follows unmounts the editor, and clearing its state + * first can run its own clean-up (a Back guard's history step) before the + * navigation lands. The guard isn't asked again for that navigation. + */ + confirm: () => Promise; +} + +const guards = new Set(); +// Set while the guarded click is replayed, so it isn't stopped again. +let replaying = false; +let asking = false; + +export function registerLeaveGuard(guard: LeaveGuard): () => void { + guards.add(guard); + return () => { guards.delete(guard); }; +} + +export function hasUnsavedChanges(): boolean { + for (const guard of guards) if (guard.isDirty()) return true; + return false; +} + +/** Asks each editor with changes in turn; true when all of them may be left. */ +export async function confirmLeave(): Promise { + for (const guard of [...guards]) { + if (guard.isDirty() && !(await guard.confirm())) return false; + } + return true; +} + +/** + * For the click handler of a link or button that leaves the current app: + * when an editor has unsaved changes, stops the click, asks, and clicks the + * same element again once the user lets the changes go. Returns true when + * it stopped the click; the handler should then do nothing else. + */ +export function interceptLeave(e: { preventDefault(): void; currentTarget: EventTarget | null }): boolean { + if (replaying || !hasUnsavedChanges()) return false; + e.preventDefault(); + const target = e.currentTarget instanceof HTMLElement ? e.currentTarget : null; + if (asking) return true; + asking = true; + void confirmLeave().then(async (leave) => { + asking = false; + if (!leave || !target) return; + // The dialog that asked steps Back off its own history entry as it + // closes; a navigation started before that lands is undone by it. + await new Promise((resolve) => setTimeout(resolve, 0)); + await historySettled(); + replaying = true; + try { target.click(); } finally { replaying = false; } + }); + return true; +} + +/** Registers the calling editor's guard while it is mounted. */ +export function useLeaveGuard(isDirty: () => boolean, confirm: () => Promise): void { + const latest = useRef({ isDirty, confirm }); + useEffect(() => { latest.current = { isDirty, confirm }; }); + useEffect(() => registerLeaveGuard({ + isDirty: () => latest.current.isDirty(), + confirm: () => latest.current.confirm(), + }), []); +} From 2f790a2d75ef65fafdf1508f4a6488d55cf5bb68 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:30:21 +0100 Subject: [PATCH 14/15] fix: keep toasts above the Save and Cancel buttons of the contact and group forms --- .../contacts/__tests__/contact-form.test.tsx | 18 ++++++++++++++++++ components/contacts/contact-form.tsx | 5 ++++- components/contacts/contact-group-form.tsx | 5 ++++- components/ui/__tests__/toast-layout.test.tsx | 7 +++++++ components/ui/toast.tsx | 5 +++-- hooks/use-bottom-controls.ts | 5 +++-- 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index c6d304ba2..e6c6570a0 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -1,6 +1,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ContactForm } from '../contact-form'; +import { ContactGroupForm } from '../contact-group-form'; import type { ContactCard, ContactMedia } from '@/lib/jmap/types'; import { useAuthStore } from '@/stores/auth-store'; import { findContactPhotoEntry } from '@/stores/contact-store'; @@ -106,6 +107,23 @@ describe('ContactForm', () => { expect(onDirtyChange).toHaveBeenLastCalledWith(true); }); + it.each([ + ['contact form', () => ], + ['group form', () => ], + ])('keeps toasts off the %s footer by registering it as a bottom bar', (_name, form) => { + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + () => ({ left: 0, top: window.innerHeight - 72, width: 600, height: 72, right: 600, bottom: window.innerHeight, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect, + ); + try { + const { unmount } = render(form()); + expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('72px'); + unmount(); + expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe(''); + } finally { + rect.mockRestore(); + } + }); + describe('photos (P0-3)', () => { // The same PHOTO as ContactCard/get returns it on each version. const photo0_16_23: ContactMedia = { kind: 'photo', uri: 'data:image/jpeg;base64,/9j/', mediaType: 'image/jpeg' }; diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 995eb1f2d..e98603380 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -10,6 +10,7 @@ import { Avatar } from "@/components/ui/avatar"; import { findContactPhotoEntry, getContactPhotoRef, type ContactPhotoRef } from "@/stores/contact-store"; import type { ContactSaveOptions } from "@/lib/contact-media"; import { cn } from "@/lib/utils"; +import { useBottomControls } from "@/hooks/use-bottom-controls"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types"; interface EmailEntry { @@ -463,6 +464,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const [initialFields] = useState(fields); const dirty = fields !== initialFields || photoEdit.kind !== "unchanged"; useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); + const footerRef = useBottomControls(); const handlePhotoSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -1194,7 +1196,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
-
+ {/* Toasts sit above Save and Cancel, not on them. */} +
diff --git a/components/contacts/contact-group-form.tsx b/components/contacts/contact-group-form.tsx index faf41feff..21389ed69 100644 --- a/components/contacts/contact-group-form.tsx +++ b/components/contacts/contact-group-form.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Avatar } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; +import { useBottomControls } from "@/hooks/use-bottom-controls"; import type { ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; @@ -44,6 +45,7 @@ export function ContactGroupForm({ const [initial] = useState(() => ({ name, members: [...selectedIds].sort().join(",") })); const dirty = name !== initial.name || [...selectedIds].sort().join(",") !== initial.members; useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); + const footerRef = useBottomControls(); const filteredIndividuals = useMemo(() => { if (!memberSearch) return individuals; @@ -195,7 +197,8 @@ export function ContactGroupForm({ )}
-
+ {/* Toasts sit above Save and Cancel, not on them. */} +
diff --git a/components/ui/__tests__/toast-layout.test.tsx b/components/ui/__tests__/toast-layout.test.tsx index 5fc4a8cb9..5068e33a2 100644 --- a/components/ui/__tests__/toast-layout.test.tsx +++ b/components/ui/__tests__/toast-layout.test.tsx @@ -41,6 +41,13 @@ describe('toast layout', () => { expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20'); }); + it('sits above a registered bottom bar on the desktop too, such as a form footer', () => { + render(); + const container = screen.getByRole('status'); + expect(container).toHaveClass('bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))]'); + expect(container).not.toHaveClass('bottom-5'); + }); + it('names the close button and hides it on a phone', () => { render(); act(() => { toast.success('Saved'); }); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index d2e1b91af..5d43a493c 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -317,8 +317,9 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
says how far from the bottom of the * window the topmost of them reaches, and toasts sit above it. */ From f91bee132f653ee42dda07a29fd73eadee516243 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:43:54 +0100 Subject: [PATCH 15/15] fix: keep the contact form's name fields readable on phones and tablets --- components/contacts/contact-form.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index e98603380..b2ebe7b04 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -704,7 +704,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
-
+
{error && (
{error} @@ -824,10 +824,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
) : ( <> -
+ {/* Two columns in a narrow pane (title and first name, last name and + suffix), one row once there is room, so no name gets cut. */} +
- setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" /> + setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="@lg:w-20" />
@@ -839,7 +841,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
- setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" /> + setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="@lg:w-20" />
{/* Either name is enough (or an organization), so neither is starred. */}