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: () => }));
+vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
+vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
+vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
+vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
+vi.mock('@/hooks/use-sidebar-apps', () => ({
+ useSidebarApps: () => ({
+ showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
+ handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
+ }),
+}));
+
+import { ContactsApp } from '../contacts-app';
+import { useAuthStore } from '@/stores/auth-store';
+import { useContactStore } from '@/stores/contact-store';
+
+const alice: ContactCard = {
+ id: 'c-alice',
+ addressBookIds: {},
+ name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
+ emails: { e0: { address: 'alice@example.com' } },
+};
+const bob: ContactCard = {
+ id: 'c-bob',
+ addressBookIds: {},
+ name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
+ emails: { e0: { address: 'bob@example.com' } },
+};
+const team = {
+ id: 'g-team',
+ kind: 'group',
+ addressBookIds: {},
+ name: { full: 'Team' },
+ members: {},
+} as unknown as ContactCard;
+
+const FAKE_CLIENT = { supportsPrincipals: () => false } as unknown as IJMAPClient;
+
+function renderContacts(selected: string[] = []) {
+ act(() => {
+ useAuthStore.setState({ isAuthenticated: true, client: FAKE_CLIENT, isLoading: false });
+ useContactStore.setState({
+ contacts: [alice, bob, team],
+ addressBooks: [],
+ selectedContactId: null,
+ selectedContactIds: new Set(selected),
+ searchQuery: '',
+ supportsSync: false,
+ contactsLoaded: true,
+ });
+ });
+ return render( );
+}
+
+describe('Contacts delete confirms', () => {
+ afterEach(() => {
+ act(() => {
+ useAuthStore.setState({ isAuthenticated: false, client: null });
+ useContactStore.setState({ contacts: [], selectedContactId: null, selectedContactIds: new Set() });
+ });
+ });
+
+ it('names the contact and says what is lost', async () => {
+ renderContacts();
+ fireEvent.contextMenu(screen.getByText('Alice Smith'));
+ fireEvent.click(screen.getByText('context_menu.delete'));
+ const dialog = await screen.findByRole('alertdialog');
+ expect(within(dialog).getByText('delete_named_title {"name":"Alice Smith"}')).toBeInTheDocument();
+ expect(within(dialog).getByText('delete_details_message')).toBeInTheDocument();
+ });
+
+ it('counts the contacts when several are selected', async () => {
+ renderContacts(['c-alice', 'c-bob']);
+ fireEvent.contextMenu(screen.getByText('Alice Smith'));
+ fireEvent.click(screen.getByRole('menuitem', { name: 'bulk.delete' }));
+ const dialog = await screen.findByRole('alertdialog');
+ expect(within(dialog).getByText('bulk.delete_confirm {"count":2}')).toBeInTheDocument();
+ expect(within(dialog).getByText('delete_details_message')).toBeInTheDocument();
+ });
+
+ it('names the group and says its contacts are kept', async () => {
+ renderContacts();
+ fireEvent.contextMenu(screen.getAllByText('Team')[0]);
+ fireEvent.click(screen.getByText('groups.delete'));
+ const dialog = await screen.findByRole('alertdialog');
+ expect(within(dialog).getByText('delete_named_title {"name":"Team"}')).toBeInTheDocument();
+ expect(within(dialog).getByText('groups.delete_message')).toBeInTheDocument();
+ });
+});
diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx
index 6af969db5..3e88086d2 100644
--- a/components/contacts/contacts-app.tsx
+++ b/components/contacts/contacts-app.tsx
@@ -529,14 +529,31 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
setView("edit");
};
- const deleteContactById = useCallback(async (contactId: string) => {
- const confirmed = await confirmDialog({
- title: t("delete_confirm_title"),
- message: t("delete_confirm"),
+ // R3: the confirm names what goes ("Delete Alice Smith?", "Delete 3
+ // contacts?") and says what is lost with it.
+ const confirmDeleteContacts = useCallback((ids: string[]) => {
+ const only = ids.length === 1 ? contacts.find((c) => c.id === ids[0]) : undefined;
+ const name = only ? getContactDisplayName(only).trim() : "";
+ return confirmDialog({
+ title: name ? t("delete_named_title", { name }) : t("bulk.delete_confirm", { count: ids.length }),
+ message: t("delete_details_message"),
confirmText: t("form.delete"),
variant: "destructive",
});
- if (!confirmed) return;
+ }, [contacts, confirmDialog, t]);
+ const confirmDeleteGroup = useCallback((groupId: string) => {
+ const group = contacts.find((c) => c.id === groupId);
+ const name = group ? getContactDisplayName(group).trim() : "";
+ return confirmDialog({
+ title: name ? t("delete_named_title", { name }) : t("groups.delete_confirm_title"),
+ message: t("groups.delete_message"),
+ confirmText: t("form.delete"),
+ variant: "destructive",
+ });
+ }, [contacts, confirmDialog, t]);
+
+ const deleteContactById = useCallback(async (contactId: string) => {
+ if (!(await confirmDeleteContacts([contactId]))) return;
try {
const deleted = supportsSync && client
@@ -550,7 +567,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
console.error('Failed to delete contact:', error);
toast.error(t("toast.error_delete"));
}
- }, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
+ }, [confirmDeleteContacts, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
const handleDelete = async () => {
if (!selectedContact) return;
@@ -773,13 +790,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
}, [openComposeInApp]);
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
- const confirmed = await confirmDialog({
- title: t("groups.delete_confirm_title"),
- message: t("groups.delete_confirm"),
- confirmText: t("form.delete"),
- variant: "destructive",
- });
- if (!confirmed) return;
+ if (!(await confirmDeleteGroup(groupId))) return;
try {
await deleteGroup(supportsSync && client ? client : null, groupId);
@@ -793,18 +804,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
console.error('Failed to delete group:', error);
toast.error(t("toast.error_delete"));
}
- }, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]);
+ }, [confirmDeleteGroup, deleteGroup, supportsSync, client, selectedGroupId, t]);
const handleDeleteGroup = async () => {
if (!selectedGroup) return;
-
- const confirmed = await confirmDialog({
- title: t("groups.delete_confirm_title"),
- message: t("groups.delete_confirm"),
- confirmText: t("form.delete"),
- variant: "destructive",
- });
- if (!confirmed) return;
+ if (!(await confirmDeleteGroup(selectedGroup.id))) return;
try {
await deleteGroup(supportsSync && client ? client : null, selectedGroup.id);
@@ -855,13 +859,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const handleBulkDelete = async () => {
if (selectedContactIds.size === 0) return;
- const confirmed = await confirmDialog({
- title: t("bulk.delete_confirm_title"),
- message: t("bulk.delete_confirm", { count: selectedContactIds.size }),
- confirmText: t("bulk.delete"),
- variant: "destructive",
- });
- if (!confirmed) return;
+ if (!(await confirmDeleteContacts(Array.from(selectedContactIds)))) return;
try {
if (!(await bulkDeleteContacts(
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 62ddb0757..45cade1b7 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "حاول تعديل المرشحات أو مسحها",
"clear_search": "مسح البحث",
"import_vcard": "استيراد vCard",
- "delete_confirm_title": "حذف جهة الاتصال",
- "delete_confirm": "هل أنت متأكد أنك تريد حذف جهة الاتصال هذه؟",
+ "delete_named_title": "حذف {name}؟",
+ "delete_details_message": "ستُحذف عناوين البريد الإلكتروني وأرقام الهواتف والملاحظات وكل التفاصيل الأخرى. لا يمكنك التراجع عن ذلك.",
"local_mode": "تُخزَّن جهات الاتصال محليًا (الخادم لا يدعم جهات اتصال JMAP)",
"back_to_contacts": "العودة إلى جهات الاتصال",
"back_to_email": "العودة إلى البريد",
@@ -3497,7 +3497,7 @@
"edit": "تعديل المجموعة",
"empty": "لا توجد مجموعات بعد",
"delete_confirm_title": "حذف المجموعة",
- "delete_confirm": "هل أنت متأكد أنك تريد حذف هذه المجموعة؟",
+ "delete_message": "ستُحذف المجموعة. تبقى جهات الاتصال التي فيها.",
"name_label": "اسم المجموعة",
"name_placeholder": "مثال: فريق، عائلة",
"name_required": "اسم المجموعة مطلوب",
@@ -3539,7 +3539,6 @@
"selected": "{count, plural, one {تم تحديد واحدة} other {تم تحديد #}}",
"select_all": "تحديد الكل",
"delete": "حذف",
- "delete_confirm_title": "حذف جهات الاتصال",
"delete_confirm": "حذف {count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}؟",
"deleted": "{count, plural, one {تم حذف جهة اتصال واحدة} other {تم حذف # جهة اتصال}}",
"add_to_group": "إضافة إلى مجموعة",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 0237d930e..08e21dba5 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Proveu d'ajustar o netejar els filtres",
"clear_search": "Neteja la cerca",
"import_vcard": "Importa vCard",
- "delete_confirm_title": "Suprimeix el contacte",
- "delete_confirm": "Segur que voleu suprimir aquest contacte?",
+ "delete_named_title": "Voleu suprimir {name}?",
+ "delete_details_message": "Se suprimiran les adreces electròniques, els números de telèfon, les notes i la resta de dades. No es pot desfer.",
"local_mode": "Els contactes es desen localment (el servidor no admet JMAP Contacts)",
"back_to_contacts": "Torna als contactes",
"back_to_email": "Torna al correu",
@@ -3497,7 +3497,7 @@
"edit": "Edita el grup",
"empty": "Encara no hi ha grups",
"delete_confirm_title": "Suprimeix el grup",
- "delete_confirm": "Segur que voleu suprimir aquest grup?",
+ "delete_message": "Se suprimirà el grup. Els contactes que conté es conserven.",
"name_label": "Nom del grup",
"name_placeholder": "p. ex., Equip, Família",
"name_required": "El nom del grup és obligatori",
@@ -3539,7 +3539,6 @@
"selected": "{count, plural, one {1 seleccionat} other {# seleccionats}}",
"select_all": "Selecciona-ho tot",
"delete": "Suprimeix",
- "delete_confirm_title": "Suprimeix els contactes",
"delete_confirm": "Voleu suprimir {count, plural, one {1 contacte} other {# contactes}}?",
"deleted": "{count, plural, one {1 contacte suprimit} other {# contactes suprimits}}",
"add_to_group": "Afegeix al grup",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index c7a66803c..f062ede5d 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Zkuste filtry upravit nebo zrušit",
"clear_search": "Vymazat hledání",
"import_vcard": "Importovat vCard",
- "delete_confirm_title": "Odstranit kontakt",
- "delete_confirm": "Opravdu chcete odstranit tento kontakt?",
+ "delete_named_title": "Odstranit {name}?",
+ "delete_details_message": "Smažou se e-mailové adresy, telefonní čísla, poznámky a všechny další údaje. Tuto akci nelze vrátit.",
"local_mode": "Kontakty jsou uloženy lokálně (server nepodporuje JMAP Contacts)",
"back_to_contacts": "Zpět na kontakty",
"back_to_email": "Zpět na e-mail",
@@ -3495,7 +3495,7 @@
"edit": "Upravit skupinu",
"empty": "Žádné skupiny",
"delete_confirm_title": "Odstranit skupinu",
- "delete_confirm": "Opravdu chcete odstranit tuto skupinu?",
+ "delete_message": "Skupina bude smazána. Kontakty v ní zůstanou zachovány.",
"name_label": "Název skupiny",
"name_placeholder": "např. Tým, Rodina",
"name_required": "Název skupiny je vyžadován",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {1 vybrán} few {# vybrány} other {# vybráno}}",
"select_all": "Vybrat vše",
"delete": "Odstranit",
- "delete_confirm_title": "Odstranit kontakty",
"delete_confirm": "Odstranit {count, plural, one {1 kontakt} few {# kontakty} other {# kontaktů}}?",
"deleted": "{count, plural, one {Odstraněn 1 kontakt} few {Odstraněny # kontakty} other {Odstraněno # kontaktů}}",
"add_to_group": "Přidat do skupiny",
diff --git a/locales/da/common.json b/locales/da/common.json
index 9ce02dd5e..9e4ea669b 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Prøv at justere eller rydde filtre",
"clear_search": "Ryd søgning",
"import_vcard": "Importér vCard",
- "delete_confirm_title": "Slet kontakt",
- "delete_confirm": "Er du sikker på, at du vil slette denne kontakt?",
+ "delete_named_title": "Slet {name}?",
+ "delete_details_message": "E-mailadresser, telefonnumre, noter og alle andre oplysninger bliver slettet. Det kan ikke fortrydes.",
"local_mode": "Kontakter gemmes lokalt (serveren understøtter ikke JMAP-kontakter)",
"back_to_contacts": "Tilbage til kontakter",
"back_to_email": "Tilbage til e-mail",
@@ -3495,7 +3495,7 @@
"edit": "Redigér gruppe",
"empty": "Ingen grupper endnu",
"delete_confirm_title": "Slet gruppe",
- "delete_confirm": "Er du sikker på, at du vil slette denne gruppe?",
+ "delete_message": "Gruppen bliver slettet. Kontakterne i den bevares.",
"name_label": "Gruppenavn",
"name_placeholder": "f.eks. Team, Familie",
"name_required": "Gruppenavn er påkrævet",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {1 valgt} other {# valgte}}",
"select_all": "Vælg alle",
"delete": "Slet",
- "delete_confirm_title": "Slet kontakter",
"delete_confirm": "Slet {count, plural, one {1 kontakt} other {# kontakter}}?",
"deleted": "{count, plural, one {1 kontakt slettet} other {# kontakter slettet}}",
"add_to_group": "Tilføj til gruppe",
diff --git a/locales/de/common.json b/locales/de/common.json
index 90b78557f..02b25e1aa 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Passen Sie die Filter an oder entfernen Sie sie",
"clear_search": "Suche löschen",
"import_vcard": "vCard importieren",
- "delete_confirm_title": "Kontakt löschen",
- "delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?",
+ "delete_named_title": "{name} löschen?",
+ "delete_details_message": "E-Mail-Adressen, Telefonnummern, Notizen und alle weiteren Angaben werden gelöscht. Das lässt sich nicht rückgängig machen.",
"local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)",
"back_to_contacts": "Zurück zu Kontakten",
"back_to_email": "Zurück zur E-Mail",
@@ -3495,7 +3495,7 @@
"edit": "Gruppe bearbeiten",
"empty": "Keine Gruppen",
"delete_confirm_title": "Gruppe löschen",
- "delete_confirm": "Möchten Sie diese Gruppe wirklich löschen?",
+ "delete_message": "Die Gruppe wird gelöscht. Die Kontakte darin bleiben erhalten.",
"name_label": "Gruppenname",
"name_placeholder": "z.B. Team, Familie",
"name_required": "Gruppenname ist erforderlich",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {1 ausgewählt} other {# ausgewählt}}",
"select_all": "Alle auswählen",
"delete": "Löschen",
- "delete_confirm_title": "Kontakte löschen",
"delete_confirm": "{count, plural, one {1 Kontakt} other {# Kontakte}} löschen?",
"deleted": "{count, plural, one {1 Kontakt gelöscht} other {# Kontakte gelöscht}}",
"add_to_group": "Zur Gruppe hinzufügen",
diff --git a/locales/en/common.json b/locales/en/common.json
index f98d5ff90..99ef0beb6 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Try adjusting or clearing filters",
"clear_search": "Clear search",
"import_vcard": "Import vCard",
- "delete_confirm_title": "Delete contact",
- "delete_confirm": "Are you sure you want to delete this contact?",
+ "delete_named_title": "Delete {name}?",
+ "delete_details_message": "Email addresses, phone numbers, notes and all other details will be deleted. You can't undo this.",
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
"back_to_contacts": "Back to contacts",
"back_to_email": "Back to email",
@@ -3497,7 +3497,7 @@
"edit": "Edit group",
"empty": "No groups yet",
"delete_confirm_title": "Delete group",
- "delete_confirm": "Are you sure you want to delete this group?",
+ "delete_message": "The group will be deleted. The contacts in it are kept.",
"name_label": "Group name",
"name_placeholder": "e.g., Team, Family",
"name_required": "Group name is required",
@@ -3539,7 +3539,6 @@
"selected": "{count, plural, one {1 selected} other {# selected}}",
"select_all": "Select all",
"delete": "Delete",
- "delete_confirm_title": "Delete contacts",
"delete_confirm": "Delete {count, plural, one {1 contact} other {# contacts}}?",
"deleted": "{count, plural, one {1 contact deleted} other {# contacts deleted}}",
"add_to_group": "Add to group",
diff --git a/locales/es/common.json b/locales/es/common.json
index 0df9c31b3..78a67b11c 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Ajusta o limpia los filtros",
"clear_search": "Borrar búsqueda",
"import_vcard": "Importar vCard",
- "delete_confirm_title": "Eliminar contacto",
- "delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?",
+ "delete_named_title": "¿Eliminar {name}?",
+ "delete_details_message": "Se eliminarán las direcciones de correo, los teléfonos, las notas y todos los demás datos. No se puede deshacer.",
"local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)",
"back_to_contacts": "Volver a contactos",
"back_to_email": "Volver al correo",
@@ -3495,7 +3495,7 @@
"edit": "Editar grupo",
"empty": "No hay grupos",
"delete_confirm_title": "Eliminar grupo",
- "delete_confirm": "¿Estás seguro de que quieres eliminar este grupo?",
+ "delete_message": "Se eliminará el grupo. Los contactos que contiene se conservan.",
"name_label": "Nombre del grupo",
"name_placeholder": "ej. Equipo, Familia",
"name_required": "El nombre del grupo es obligatorio",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {1 seleccionado} other {# seleccionados}}",
"select_all": "Seleccionar todo",
"delete": "Eliminar",
- "delete_confirm_title": "Eliminar contactos",
"delete_confirm": "¿Eliminar {count, plural, one {1 contacto} other {# contactos}}?",
"deleted": "{count, plural, one {1 contacto eliminado} other {# contactos eliminados}}",
"add_to_group": "Agregar al grupo",
diff --git a/locales/et/common.json b/locales/et/common.json
index 67fa7f290..146dfbed8 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Proovi filtreid muuta või need tühjendada",
"clear_search": "Tühjenda otsing",
"import_vcard": "Impordi vCard",
- "delete_confirm_title": "Kustuta kontakt",
- "delete_confirm": "Kas oled kindel, et soovid selle kontakti kustutada?",
+ "delete_named_title": "Kustutada {name}?",
+ "delete_details_message": "E-posti aadressid, telefoninumbrid, märkmed ja kõik muud andmed kustutatakse. Seda ei saa tagasi võtta.",
"local_mode": "Kontaktid salvestatakse kohalikult (server ei toeta JMAP Contacts funktsiooni)",
"back_to_contacts": "Tagasi kontaktide juurde",
"back_to_email": "Tagasi e-posti juurde",
@@ -3497,7 +3497,7 @@
"edit": "Muuda gruppi",
"empty": "Gruppe veel pole",
"delete_confirm_title": "Kustuta grupp",
- "delete_confirm": "Kas oled kindel, et soovid selle grupi kustutada?",
+ "delete_message": "Grupp kustutatakse. Selles olevad kontaktid jäävad alles.",
"name_label": "Grupi nimi",
"name_placeholder": "nt meeskond, pere",
"name_required": "Grupi nimi on kohustuslik",
@@ -3539,7 +3539,6 @@
"selected": "{count, plural, one {1 valitud} other {# valitud}}",
"select_all": "Vali kõik",
"delete": "Kustuta",
- "delete_confirm_title": "Kustuta kontaktid",
"delete_confirm": "Kustutada {count, plural, one {1 kontakt} other {# kontakti}}?",
"deleted": "{count, plural, one {Kustutati 1 kontakt} other {Kustutati # kontakti}}",
"add_to_group": "Lisa gruppi",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 08619181e..d5bf2c343 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "فیلترها را تنظیم یا پاک کنید",
"clear_search": "پاک کردن جستجو",
"import_vcard": "وارد کردن vCard",
- "delete_confirm_title": "حذف مخاطب",
- "delete_confirm": "آیا از حذف این مخاطب اطمینان دارید؟",
+ "delete_named_title": "{name} حذف شود؟",
+ "delete_details_message": "نشانیهای ایمیل، شمارهتلفنها، یادداشتها و همهٔ جزئیات دیگر حذف میشوند. این کار قابل بازگشت نیست.",
"local_mode": "مخاطبین به صورت محلی ذخیره میشوند (سرور از JMAP Contacts پشتیبانی نمیکند)",
"back_to_contacts": "بازگشت به مخاطبین",
"back_to_email": "بازگشت به ایمیل",
@@ -3497,7 +3497,7 @@
"edit": "ویرایش گروه",
"empty": "هنوز گروهی نیست",
"delete_confirm_title": "حذف گروه",
- "delete_confirm": "آیا از حذف این گروه اطمینان دارید؟",
+ "delete_message": "گروه حذف میشود. مخاطبان داخل آن باقی میمانند.",
"name_label": "نام گروه",
"name_placeholder": "مثال: تیم، خانواده",
"name_required": "نام گروه الزامی است",
@@ -3539,7 +3539,6 @@
"selected": "{count, plural, one {۱ انتخاب شده} other {# انتخاب شده}}",
"select_all": "انتخاب همه",
"delete": "حذف",
- "delete_confirm_title": "حذف مخاطبین",
"delete_confirm": "حذف {count, plural, one {۱ مخاطب} other {# مخاطب}}؟",
"deleted": "{count, plural, one {۱ مخاطب حذف شد} other {# مخاطب حذف شد}}",
"add_to_group": "افزودن به گروه",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index c76481a71..c775b4eb1 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Ajustez ou effacez les filtres",
"clear_search": "Effacer la recherche",
"import_vcard": "Importer vCard",
- "delete_confirm_title": "Supprimer le contact",
- "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?",
+ "delete_named_title": "Supprimer {name} ?",
+ "delete_details_message": "Les adresses e-mail, numéros de téléphone, notes et toutes les autres informations seront supprimés. Cette action est irréversible.",
"local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)",
"back_to_contacts": "Retour aux contacts",
"back_to_email": "Retour à l'e-mail",
@@ -3495,7 +3495,7 @@
"edit": "Modifier le groupe",
"empty": "Aucun groupe",
"delete_confirm_title": "Supprimer le groupe",
- "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce groupe ?",
+ "delete_message": "Le groupe sera supprimé. Les contacts qu'il contient sont conservés.",
"name_label": "Nom du groupe",
"name_placeholder": "ex. Équipe, Famille",
"name_required": "Le nom du groupe est requis",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {1 sélectionné} other {# sélectionnés}}",
"select_all": "Tout sélectionner",
"delete": "Supprimer",
- "delete_confirm_title": "Supprimer les contacts",
"delete_confirm": "Supprimer {count, plural, one {1 contact} other {# contacts}} ?",
"deleted": "{count, plural, one {1 contact supprimé} other {# contacts supprimés}}",
"add_to_group": "Ajouter au groupe",
diff --git a/locales/he/common.json b/locales/he/common.json
index a2228c31e..b4d19146c 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -3172,8 +3172,8 @@
"empty_search_hint": "נסה מונח חיפוש אחר",
"clear_search": "נקה חיפוש",
"import_vcard": "ייבוא vCard",
- "delete_confirm_title": "מחק איש קשר",
- "delete_confirm": "האם אתה בטוח שברצונך למחוק איש קשר זה?",
+ "delete_named_title": "למחוק את {name}?",
+ "delete_details_message": "כתובות הדוא״ל, מספרי הטלפון, ההערות וכל שאר הפרטים יימחקו. לא ניתן לבטל פעולה זו.",
"local_mode": "אנשי הקשר מאוחסנים באופן מקומי (שרת אינו תומך באנשי קשר ב-JMAP)",
"back_to_contacts": "חזור לאנשי קשר",
"tabs": {
@@ -3399,7 +3399,7 @@
"edit": "ערוך קבוצה",
"empty": "עדיין אין קבוצות",
"delete_confirm_title": "מחק את הקבוצה",
- "delete_confirm": "האם אתה בטוח שברצונך למחוק את הקבוצה הזו?",
+ "delete_message": "הקבוצה תימחק. אנשי הקשר שבה יישמרו.",
"name_label": "שם הקבוצה",
"name_placeholder": "למשל, צוות, משפחה",
"name_required": "נדרש שם הקבוצה",
@@ -3441,7 +3441,6 @@
"selected": "{count, plural, one {נבחר אחד} other {נבחרו #}}",
"select_all": "בחר הכל",
"delete": "לִמְחוֹק",
- "delete_confirm_title": "מחק אנשי קשר",
"delete_confirm": "למחוק {count, plural, one {איש קשר אחד} other {# אנשי קשר}}?",
"deleted": "{count, plural, one {נמחק איש קשר אחד} other {נמחקו # אנשי קשר}}",
"add_to_group": "הוסף לקבוצה",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index fadee2bbf..969d8cda9 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Próbáld módosítani vagy törölni a szűrőket",
"clear_search": "Keresés törlése",
"import_vcard": "vCard importálása",
- "delete_confirm_title": "Névjegy törlése",
- "delete_confirm": "Biztosan törölni szeretnéd ezt a névjegyet?",
+ "delete_named_title": "Törlöd: {name}?",
+ "delete_details_message": "Az e-mail-címek, telefonszámok, jegyzetek és minden más adat törlődik. Ez nem vonható vissza.",
"local_mode": "A névjegyek helyben tárolódnak (a szerver nem támogatja a JMAP Névjegyeket)",
"back_to_contacts": "Vissza a névjegyekhez",
"back_to_email": "Vissza az e-mailekhez",
@@ -3497,7 +3497,7 @@
"edit": "Csoport szerkesztése",
"empty": "Még nincsenek csoportok",
"delete_confirm_title": "Csoport törlése",
- "delete_confirm": "Biztosan törölni szeretnéd ezt a csoportot?",
+ "delete_message": "A csoport törlődik. A benne lévő névjegyek megmaradnak.",
"name_label": "Csoport neve",
"name_placeholder": "pl. Csapat, Család",
"name_required": "A csoport neve kötelező",
@@ -3539,7 +3539,6 @@
"selected": "{count, plural, one {1 kijelölve} other {# kijelölve}}",
"select_all": "Összes kijelölése",
"delete": "Törlés",
- "delete_confirm_title": "Névjegyek törlése",
"delete_confirm": "Törlöd {count, plural, one {az 1 névjegyet} other {a # névjegyet}}?",
"deleted": "{count, plural, one {1 névjegy törölve} other {# névjegy törölve}}",
"add_to_group": "Hozzáadás a csoporthoz",
diff --git a/locales/it/common.json b/locales/it/common.json
index f87973ef8..d5122b5cf 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Modifica o cancella i filtri",
"clear_search": "Cancella ricerca",
"import_vcard": "Importa vCard",
- "delete_confirm_title": "Elimina contatto",
- "delete_confirm": "Sei sicuro di voler eliminare questo contatto?",
+ "delete_named_title": "Eliminare {name}?",
+ "delete_details_message": "Indirizzi email, numeri di telefono, note e tutti gli altri dati verranno eliminati. L'operazione non può essere annullata.",
"local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)",
"back_to_contacts": "Torna ai contatti",
"back_to_email": "Torna all'e-mail",
@@ -3495,7 +3495,7 @@
"edit": "Modifica gruppo",
"empty": "Nessun gruppo",
"delete_confirm_title": "Elimina gruppo",
- "delete_confirm": "Sei sicuro di voler eliminare questo gruppo?",
+ "delete_message": "Il gruppo verrà eliminato. I contatti che contiene vengono mantenuti.",
"name_label": "Nome del gruppo",
"name_placeholder": "es. Team, Famiglia",
"name_required": "Il nome del gruppo è obbligatorio",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {1 selezionato} other {# selezionati}}",
"select_all": "Seleziona tutto",
"delete": "Elimina",
- "delete_confirm_title": "Elimina contatti",
"delete_confirm": "Eliminare {count, plural, one {1 contatto} other {# contatti}}?",
"deleted": "{count, plural, one {1 contatto eliminato} other {# contatti eliminati}}",
"add_to_group": "Aggiungi al gruppo",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 8250a57f2..17c3ce055 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "フィルターを調整またはクリアしてください",
"clear_search": "検索をクリア",
"import_vcard": "vCardをインポート",
- "delete_confirm_title": "連絡先を削除",
- "delete_confirm": "この連絡先を削除してもよろしいですか?",
+ "delete_named_title": "「{name}」を削除しますか?",
+ "delete_details_message": "メールアドレス、電話番号、メモなど、すべての情報が削除されます。この操作は元に戻せません。",
"local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)",
"back_to_contacts": "連絡先に戻る",
"back_to_email": "メールに戻る",
@@ -3495,7 +3495,7 @@
"edit": "グループを編集",
"empty": "グループがありません",
"delete_confirm_title": "グループを削除",
- "delete_confirm": "このグループを削除してもよろしいですか?",
+ "delete_message": "グループは削除されます。グループ内の連絡先は残ります。",
"name_label": "グループ名",
"name_placeholder": "例:チーム、家族",
"name_required": "グループ名は必須です",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, other {#件選択中}}",
"select_all": "すべて選択",
"delete": "削除",
- "delete_confirm_title": "連絡先を削除",
"delete_confirm": "{count, plural, other {#件の連絡先}}を削除しますか?",
"deleted": "{count, plural, other {#件の連絡先を削除しました}}",
"add_to_group": "グループに追加",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 4f037cce1..a3d70d889 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "필터를 조정하거나 지워보세요",
"clear_search": "검색 지우기",
"import_vcard": "vCard 가져오기",
- "delete_confirm_title": "연락처 삭제",
- "delete_confirm": "정말 이 연락처를 삭제할까요?",
+ "delete_named_title": "{name}을(를) 삭제할까요?",
+ "delete_details_message": "이메일 주소, 전화번호, 메모 등 모든 정보가 삭제됩니다. 되돌릴 수 없습니다.",
"local_mode": "연락처가 로컬에 저장돼요 (서버가 JMAP Contacts를 지원하지 않아요)",
"back_to_contacts": "연락처로 돌아가기",
"back_to_email": "이메일로 돌아가기",
@@ -3495,7 +3495,7 @@
"edit": "그룹 수정",
"empty": "아직 그룹이 없어요",
"delete_confirm_title": "그룹 삭제",
- "delete_confirm": "정말 이 그룹을 삭제할까요?",
+ "delete_message": "그룹이 삭제됩니다. 그룹에 있던 연락처는 유지됩니다.",
"name_label": "그룹 이름",
"name_placeholder": "예: 팀, 가족",
"name_required": "그룹 이름이 필요해요",
@@ -3537,7 +3537,6 @@
"selected": "{count}개 선택됨",
"select_all": "모두 선택",
"delete": "삭제",
- "delete_confirm_title": "연락처 삭제",
"delete_confirm": "선택한 {count}개의 연락처를 삭제할까요?",
"deleted": "{count}개의 연락처가 삭제되었어요",
"add_to_group": "그룹에 추가",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index fb09d2815..913b7a897 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -3248,8 +3248,8 @@
"empty_filtered_hint": "Pielāgojiet vai notīriet filtrus",
"clear_search": "Notīrīt meklēšanu",
"import_vcard": "vCard imports",
- "delete_confirm_title": "Dzēst kontaktu",
- "delete_confirm": "Vai tiešām vēlaties dzēst šo kontaktu?",
+ "delete_named_title": "Vai dzēst {name}?",
+ "delete_details_message": "Tiks dzēstas e-pasta adreses, tālruņa numuri, piezīmes un visa pārējā informācija. To nevar atsaukt.",
"local_mode": "Kontakti tiek glabāti lokāli (serveris neatbalsta JMAP Contacts)",
"back_to_contacts": "Atpakaļ pie kontaktiem",
"back_to_email": "Atpakaļ pie e-pasta",
@@ -3487,7 +3487,7 @@
"edit": "Rediģēt grupu",
"empty": "Grupu vēl nav",
"delete_confirm_title": "Dzēst grupu",
- "delete_confirm": "Vai tiešām vēlaties dzēst šo grupu?",
+ "delete_message": "Grupa tiks dzēsta. Tajā esošie kontakti tiks saglabāti.",
"name_label": "Grupas nosaukums",
"name_placeholder": "piem., Komanda, Ģimene",
"name_required": "Grupas nosaukums ir obligāts",
@@ -3529,7 +3529,6 @@
"selected": "Atlasīts {count, plural, one {1 kontakts} other {# kontakti}}",
"select_all": "Atlasīt visus",
"delete": "Dzēst",
- "delete_confirm_title": "Dzēst kontaktus",
"delete_confirm": "Vai dzēst {count, plural, one {1 kontaktu} other {# kontaktus}}?",
"deleted": "Izdzēsts {count, plural, one {1 kontakts} other {# kontakti}}",
"add_to_group": "Pievienot grupai",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 5e97ed96a..8a51a86ed 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Шүүлтүүрийг тохируулж эсвэл цэвэрлэж үзнэ үү",
"clear_search": "Хайлтыг арилгах",
"import_vcard": "vCard импортлох",
- "delete_confirm_title": "Харилцагчийг устгах",
- "delete_confirm": "Та энэ харилцагчийг устгахдаа итгэлтэй байна уу?",
+ "delete_named_title": "{name}-г устгах уу?",
+ "delete_details_message": "Имэйл хаяг, утасны дугаар, тэмдэглэл болон бусад бүх мэдээлэл устгагдана. Үүнийг буцаах боломжгүй.",
"local_mode": "Харилцагчдыг дотооддоо хадгалдаг (сервер нь JMAP Contacts-ийг дэмждэггүй)",
"back_to_contacts": "Харилцагч руу буцах",
"back_to_email": "Имэйл рүү буцах",
@@ -3497,7 +3497,7 @@
"edit": "Засварлах групп",
"empty": "Одоохондоо бүлэг байхгүй",
"delete_confirm_title": "Бүлгийг устгах",
- "delete_confirm": "Та энэ бүлгийг устгахдаа итгэлтэй байна уу?",
+ "delete_message": "Бүлэг устгагдана. Доторх харилцагчид хадгалагдана.",
"name_label": "Бүлгийн нэр",
"name_placeholder": "жишээ нь, Баг, Гэр бүл",
"name_required": "Бүлгийн нэр шаардлагатай",
@@ -3539,7 +3539,6 @@
"selected": "{count, plural, one {1 сонгогдсон} other {# сонгогдсон}}",
"select_all": "Бүгдийг нь сонго",
"delete": "Устгах",
- "delete_confirm_title": "Харилцагчдыг устгах",
"delete_confirm": "{count, plural, one {1 контакт} other {# контакт}} устгах уу?",
"deleted": "{count, plural, one {1 контакт устгагдлаа} other {# контакт устгагдлаа}}",
"add_to_group": "Бүлэгт нэмэх",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index e67ab6745..9423c2ac3 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Prøv å endre eller fjerne filtrene",
"clear_search": "Tøm søket",
"import_vcard": "Importer vCard",
- "delete_confirm_title": "Slett kontakt",
- "delete_confirm": "Er du sikker på at du vil slette denne kontakten?",
+ "delete_named_title": "Vil du slette {name}?",
+ "delete_details_message": "E-postadresser, telefonnumre, notater og alle andre opplysninger blir slettet. Dette kan ikke angres.",
"local_mode": "Kontakter lagres lokalt (serveren støtter ikke JMAP-kontakter)",
"back_to_contacts": "Tilbake til kontaktene",
"back_to_email": "Tilbake til e-post",
@@ -3497,7 +3497,7 @@
"edit": "Rediger gruppe",
"empty": "Ingen grupper ennå",
"delete_confirm_title": "Slett gruppe",
- "delete_confirm": "Er du sikker på at du vil slette denne gruppen?",
+ "delete_message": "Gruppen blir slettet. Kontaktene i den beholdes.",
"name_label": "Gruppenavn",
"name_placeholder": "f.eks. team, familie",
"name_required": "Gruppenavn må fylles ut",
@@ -3539,7 +3539,6 @@
"selected": "{count, plural, one {1 valgt} other {# valgte}}",
"select_all": "Velg alle",
"delete": "Slett",
- "delete_confirm_title": "Slett kontakter",
"delete_confirm": "Vil du slette {count, plural, one {1 kontakt} other {# kontakter}}?",
"deleted": "{count, plural, one {1 kontakt slettet} other {# kontakter slettet}}",
"add_to_group": "Legg til i gruppe",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index d60f5c317..88401375a 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Pas filters aan of wis ze",
"clear_search": "Zoekopdracht wissen",
"import_vcard": "vCard importeren",
- "delete_confirm_title": "Contact verwijderen",
- "delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?",
+ "delete_named_title": "{name} verwijderen?",
+ "delete_details_message": "E-mailadressen, telefoonnummers, notities en alle andere gegevens worden verwijderd. Dit kan niet ongedaan worden gemaakt.",
"local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)",
"back_to_contacts": "Terug naar contacten",
"back_to_email": "Terug naar e-mail",
@@ -3495,7 +3495,7 @@
"edit": "Groep bewerken",
"empty": "Geen groepen",
"delete_confirm_title": "Groep verwijderen",
- "delete_confirm": "Weet u zeker dat u deze groep wilt verwijderen?",
+ "delete_message": "De groep wordt verwijderd. De contacten erin blijven bewaard.",
"name_label": "Groepsnaam",
"name_placeholder": "bijv. Team, Familie",
"name_required": "Groepsnaam is vereist",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {1 geselecteerd} other {# geselecteerd}}",
"select_all": "Alles selecteren",
"delete": "Verwijderen",
- "delete_confirm_title": "Contacten verwijderen",
"delete_confirm": "{count, plural, one {1 contact} other {# contacten}} verwijderen?",
"deleted": "{count, plural, one {1 contact verwijderd} other {# contacten verwijderd}}",
"add_to_group": "Aan groep toevoegen",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 78f85294a..c4382b241 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Dostosuj lub wyczyść filtry",
"clear_search": "Wyczyść wyszukiwanie",
"import_vcard": "Importuj vCard",
- "delete_confirm_title": "Usuń kontakt",
- "delete_confirm": "Czy na pewno chcesz usunąć ten kontakt?",
+ "delete_named_title": "Usunąć {name}?",
+ "delete_details_message": "Adresy e-mail, numery telefonów, notatki i wszystkie inne dane zostaną usunięte. Tej operacji nie można cofnąć.",
"local_mode": "Kontakty są przechowywane lokalnie (serwer nie obsługuje JMAP Contacts)",
"back_to_contacts": "Powrót do kontaktów",
"back_to_email": "Powrót do wiadomości",
@@ -3495,7 +3495,7 @@
"edit": "Edytuj grupę",
"empty": "Brak grup",
"delete_confirm_title": "Usuń grupę",
- "delete_confirm": "Czy na pewno chcesz usunąć tę grupę?",
+ "delete_message": "Grupa zostanie usunięta. Kontakty w niej pozostaną.",
"name_label": "Nazwa grupy",
"name_placeholder": "np. Zespół, Rodzina",
"name_required": "Nazwa grupy jest wymagana",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {1 zaznaczony} other {# zaznaczonych}}",
"select_all": "Zaznacz wszystko",
"delete": "Usuń",
- "delete_confirm_title": "Usuń kontakty",
"delete_confirm": "Usunąć {count, plural, one {1 kontakt} other {# kontaktów}}?",
"deleted": "{count, plural, one {Usunięto 1 kontakt} other {Usunięto # kontaktów}}",
"add_to_group": "Dodaj do grupy",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 621cf81fd..97634f545 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Ajuste ou limpe os filtros",
"clear_search": "Limpar pesquisa",
"import_vcard": "Importar vCard",
- "delete_confirm_title": "Excluir contato",
- "delete_confirm": "Tem certeza de que deseja excluir este contato?",
+ "delete_named_title": "Excluir {name}?",
+ "delete_details_message": "Endereços de e-mail, telefones, notas e todos os outros dados serão excluídos. Não é possível desfazer.",
"local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)",
"back_to_contacts": "Voltar aos contatos",
"back_to_email": "Voltar ao e-mail",
@@ -3495,7 +3495,7 @@
"edit": "Editar grupo",
"empty": "Nenhum grupo",
"delete_confirm_title": "Excluir grupo",
- "delete_confirm": "Tem certeza de que deseja excluir este grupo?",
+ "delete_message": "O grupo será excluído. Os contatos nele são mantidos.",
"name_label": "Nome do grupo",
"name_placeholder": "ex. Equipe, Família",
"name_required": "O nome do grupo é obrigatório",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {1 selecionado} other {# selecionados}}",
"select_all": "Selecionar tudo",
"delete": "Excluir",
- "delete_confirm_title": "Excluir contatos",
"delete_confirm": "Excluir {count, plural, one {1 contato} other {# contatos}}?",
"deleted": "{count, plural, one {1 contato excluído} other {# contatos excluídos}}",
"add_to_group": "Adicionar ao grupo",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index d41e81db6..f596774e8 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Încercați să modificați sau să ștergeți filtrele",
"clear_search": "Șterge căutarea",
"import_vcard": "ImportvCard",
- "delete_confirm_title": "Șterge contactul",
- "delete_confirm": "Sunteți sigur că doriți să ștergeți acest contact?",
+ "delete_named_title": "Șterge {name}?",
+ "delete_details_message": "Adresele de e-mail, numerele de telefon, notițele și toate celelalte detalii vor fi șterse. Acțiunea nu poate fi anulată.",
"local_mode": "Contactele sunt stocate local (serverul nu acceptă contactele dJMAP)",
"back_to_contacts": "Înapoi la contacte",
"back_to_email": "Înapoi la e-mail",
@@ -3497,7 +3497,7 @@
"edit": "Editare grup",
"empty": "Nu există încă grupuri",
"delete_confirm_title": "Șterge grupul",
- "delete_confirm": "Sunteți sigur că doriți să ștergeți acest grup?",
+ "delete_message": "Grupul va fi șters. Contactele din el se păstrează.",
"name_label": "Numele grupului",
"name_placeholder": "de exemplu, Echipa, Familia",
"name_required": "Numele grupului este obligatoriu",
@@ -3539,7 +3539,6 @@
"selected": "{count, plural, one {1 selectat} few {# selectat} other {# de selectat}}",
"select_all": "Selectează tot",
"delete": "Șterge",
- "delete_confirm_title": "Ștergeți contactele",
"delete_confirm": "Șterge {count, plural, one {1 contact} few {# contacte} other {# de contacte}}?",
"deleted": "{count, plural, one {1 contact șters} few {# contacte șterse} other {# de contacte șterse}}",
"add_to_group": "Adaugă la grup",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index d2023690e..091b8bf35 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Измените или очистите фильтры",
"clear_search": "Очистить поиск",
"import_vcard": "Импорт vCard",
- "delete_confirm_title": "Удалить контакт",
- "delete_confirm": "Вы уверены, что хотите удалить этот контакт?",
+ "delete_named_title": "Удалить {name}?",
+ "delete_details_message": "Адреса электронной почты, номера телефонов, заметки и все остальные данные будут удалены. Это действие нельзя отменить.",
"local_mode": "Контакты хранятся локально (сервер не поддерживает JMAP Contacts)",
"back_to_contacts": "Вернуться к контактам",
"back_to_email": "Вернуться к письму",
@@ -3495,7 +3495,7 @@
"edit": "Редактировать группу",
"empty": "Групп пока нет",
"delete_confirm_title": "Удалить группу",
- "delete_confirm": "Вы уверены, что хотите удалить эту группу?",
+ "delete_message": "Группа будет удалена. Контакты из неё сохранятся.",
"name_label": "Название группы",
"name_placeholder": "напр., Команда, Семья",
"name_required": "Название группы обязательно",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {1 выбран} other {# выбрано}}",
"select_all": "Выбрать все",
"delete": "Удалить",
- "delete_confirm_title": "Удалить контакты",
"delete_confirm": "Удалить {count, plural, one {1 контакт} other {# контактов}}?",
"deleted": "{count, plural, one {1 контакт удалён} other {# контактов удалено}}",
"add_to_group": "Добавить в группу",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index cef23a425..e0d8e0443 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Skúste filtre upraviť alebo zrušiť",
"clear_search": "Vymazať hľadanie",
"import_vcard": "Importovať vCard",
- "delete_confirm_title": "Odstrániť kontakt",
- "delete_confirm": "Naozaj chcete odstrániť tento kontakt?",
+ "delete_named_title": "Odstrániť {name}?",
+ "delete_details_message": "Odstránia sa e-mailové adresy, telefónne čísla, poznámky a všetky ďalšie údaje. Túto akciu nie je možné vrátiť.",
"local_mode": "Kontakty sú uložené lokálne (server nepodporuje JMAP Contacts)",
"back_to_contacts": "Späť na kontakty",
"back_to_email": "Späť na e-mail",
@@ -3497,7 +3497,7 @@
"edit": "Upraviť skupinu",
"empty": "Žiadne skupiny",
"delete_confirm_title": "Odstrániť skupinu",
- "delete_confirm": "Naozaj chcete odstrániť túto skupinu?",
+ "delete_message": "Skupina sa odstráni. Kontakty v nej zostanú zachované.",
"name_label": "Názov skupiny",
"name_placeholder": "napr. Tím, Rodina",
"name_required": "Názov skupiny je povinný",
@@ -3539,7 +3539,6 @@
"selected": "{count, plural, one {1 vybraný} other {# vybraných}}",
"select_all": "Vybrať všetko",
"delete": "Odstrániť",
- "delete_confirm_title": "Odstrániť kontakty",
"delete_confirm": "Odstrániť {count, plural, one {1 kontakt} other {# kontaktov}}?",
"deleted": "{count, plural, one {Odstránený 1 kontakt} other {Odstránených # kontaktov}}",
"add_to_group": "Pridať do skupiny",
diff --git a/locales/th/common.json b/locales/th/common.json
index 91f685019..8273c3e7c 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -2380,8 +2380,8 @@
"empty_filtered_hint": "ลองปรับเปลี่ยนหรือล้างตัวกรอง",
"clear_search": "ล้างการค้นหา",
"import_vcard": "นำเข้า vCard",
- "delete_confirm_title": "ลบผู้ติดต่อ",
- "delete_confirm": "คุณแน่ใจหรือไม่ว่าต้องการลบผู้ติดต่อนี้?",
+ "delete_named_title": "ลบ {name} หรือไม่?",
+ "delete_details_message": "ที่อยู่อีเมล หมายเลขโทรศัพท์ บันทึก และรายละเอียดอื่นทั้งหมดจะถูกลบ คุณไม่สามารถเลิกทำได้",
"local_mode": "ผู้ติดต่อจะถูกจัดเก็บไว้ในเครื่อง (เซิร์ฟเวอร์ไม่รองรับการซิงค์สมุดที่อยู่)",
"back_to_contacts": "กลับไปยังผู้ติดต่อ",
"back_to_email": "กลับไปยังอีเมล",
@@ -2621,7 +2621,7 @@
"edit": "แก้ไขกลุ่ม",
"empty": "ยังไม่มีกลุ่มผู้ติดต่อ",
"delete_confirm_title": "ลบกลุ่ม",
- "delete_confirm": "คุณแน่ใจหรือไม่ว่าต้องการลบกลุ่มนี้?",
+ "delete_message": "กลุ่มจะถูกลบ ผู้ติดต่อในกลุ่มจะยังคงอยู่",
"name_label": "ชื่อกลุ่ม",
"name_placeholder": "เช่น ทีมงาน, ครอบครัว",
"name_required": "จำเป็นต้องระบุชื่อกลุ่ม",
@@ -2663,7 +2663,6 @@
"selected": "{count, plural, one {เลือกไว้ 1 รายการ} other {เลือกไว้ # รายการ}}",
"select_all": "เลือกทั้งหมด",
"delete": "ลบ",
- "delete_confirm_title": "ลบผู้ติดต่อ",
"delete_confirm": "ลบผู้ติดต่อ {count, plural, one {1 รายการ} other {# รายการ}} หรือไม่?",
"deleted": "{count, plural, one {ลบผู้ติดต่อแล้ว 1 รายการ} other {ลบผู้ติดต่อแล้ว # รายการ}}",
"add_to_group": "เพิ่มในกลุ่ม",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 75fc462b8..fa6b51bac 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Filtreleri ayarlamayı veya temizlemeyi deneyin",
"clear_search": "Aramayı temizle",
"import_vcard": "vCard İçe Aktar",
- "delete_confirm_title": "Kişiyi sil",
- "delete_confirm": "Bu kişiyi silmek istediğinizden emin misiniz?",
+ "delete_named_title": "{name} silinsin mi?",
+ "delete_details_message": "E-posta adresleri, telefon numaraları, notlar ve diğer tüm bilgiler silinecek. Bu işlem geri alınamaz.",
"local_mode": "Kişiler yerel olarak saklanıyor (sunucu JMAP Kişilerini desteklemiyor)",
"back_to_contacts": "Kişilere geri dön",
"back_to_email": "E-postaya geri dön",
@@ -3495,7 +3495,7 @@
"edit": "Grubu Düzenle",
"empty": "Henüz grup yok",
"delete_confirm_title": "Grubu sil",
- "delete_confirm": "Bu grubu silmek istediğinizden emin misiniz?",
+ "delete_message": "Grup silinecek. İçindeki kişiler korunur.",
"name_label": "Grup Adı",
"name_placeholder": "ör. Takım, Aile",
"name_required": "Grup adı gerekli",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {1 seçildi} other {# seçildi}}",
"select_all": "Tümünü seç",
"delete": "Sil",
- "delete_confirm_title": "Kişileri sil",
"delete_confirm": "{count, plural, one {1 kişi} other {# kişi}} silinsin mi?",
"deleted": "{count, plural, one {1 kişi silindi} other {# kişi silindi}}",
"add_to_group": "Gruba ekle",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 9fa60c275..189fb608c 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "Змініть або очистіть фільтри",
"clear_search": "Очистити пошук",
"import_vcard": "Імпорт vCard",
- "delete_confirm_title": "Видалити контакт",
- "delete_confirm": "Ви впевнені, що хочете видалити цей контакт?",
+ "delete_named_title": "Видалити {name}?",
+ "delete_details_message": "Адреси електронної пошти, номери телефонів, нотатки та всі інші дані буде видалено. Цю дію не можна скасувати.",
"local_mode": "Контакти зберігаються локально (сервер не підтримує контакти JMAP)",
"back_to_contacts": "Назад до контактів",
"back_to_email": "Назад до листа",
@@ -3495,7 +3495,7 @@
"edit": "Редагувати групу",
"empty": "Груп ще немає",
"delete_confirm_title": "Видалити групу",
- "delete_confirm": "Ви впевнені, що хочете видалити цю групу?",
+ "delete_message": "Групу буде видалено. Контакти в ній збережуться.",
"name_label": "Назва групи",
"name_placeholder": "наприклад, Команда, Сім’я",
"name_required": "Потрібно вказати назву групи",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {1 вибрано} few {# вибрано} many {# вибрано} other {# вибрано}}",
"select_all": "Вибрати все",
"delete": "Видалити",
- "delete_confirm_title": "Видалити контакти",
"delete_confirm": "Видалити {count, plural, one {1 contact} other {# contacts}}?",
"deleted": "{count, plural, one {1 контакт видалено} few {# контакти видалено} many {# контактів видалено} other {# контактів видалено}}",
"add_to_group": "Додати до групи",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 3137404d4..8cfd182d5 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "請調整或清除篩選條件",
"clear_search": "清除搜尋",
"import_vcard": "匯入 vCard",
- "delete_confirm_title": "刪除聯絡人",
- "delete_confirm": "您確定要刪除此聯絡人嗎?",
+ "delete_named_title": "刪除「{name}」?",
+ "delete_details_message": "電子郵件地址、電話號碼、備註及所有其他資訊都將被刪除。此動作無法復原。",
"local_mode": "聯絡人儲存在本機(伺服器不支援 JMAP 聯絡人)",
"back_to_contacts": "回到聯絡人",
"back_to_email": "回到郵件",
@@ -3497,7 +3497,7 @@
"edit": "編輯群組",
"empty": "尚無群組",
"delete_confirm_title": "刪除群組",
- "delete_confirm": "您確定要刪除該群組嗎?",
+ "delete_message": "此群組將被刪除。其中的聯絡人會保留。",
"name_label": "群組名稱",
"name_placeholder": "例如,團隊、家庭",
"name_required": "群組名稱為必填欄位",
@@ -3539,7 +3539,6 @@
"selected": "{count, plural, one {已選取 1 項} other {已選取 # 項}}",
"select_all": "全部選取",
"delete": "刪除",
- "delete_confirm_title": "刪除聯絡人",
"delete_confirm": "刪除{count, plural, one {1 位聯絡人} other {# 位聯絡人}}?",
"deleted": "{count, plural, one {已刪除 1 位聯絡人} other {已刪除 # 位聯絡人}}",
"add_to_group": "新增至群組",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 84308793d..e35a3d54b 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -3256,8 +3256,8 @@
"empty_filtered_hint": "请调整或清除筛选条件",
"clear_search": "清除搜索",
"import_vcard": "导入 vCard",
- "delete_confirm_title": "删除联系人",
- "delete_confirm": "您确定要删除此联系人吗?",
+ "delete_named_title": "删除“{name}”?",
+ "delete_details_message": "电子邮件地址、电话号码、备注及所有其他信息都将被删除。此操作无法撤销。",
"local_mode": "联系人存储在本地(服务器不支持 JMAP 联系人)",
"back_to_contacts": "返回联系人",
"back_to_email": "返回邮件",
@@ -3495,7 +3495,7 @@
"edit": "编辑群组",
"empty": "还没有群组",
"delete_confirm_title": "删除群组",
- "delete_confirm": "您确定要删除该群组吗?",
+ "delete_message": "该群组将被删除。其中的联系人会保留。",
"name_label": "群组名称",
"name_placeholder": "例如,团队、家庭",
"name_required": "群组名称为必填项",
@@ -3537,7 +3537,6 @@
"selected": "{count, plural, one {已选择 1 项} other {已选择 # 项}}",
"select_all": "全选",
"delete": "删除",
- "delete_confirm_title": "删除联系人",
"delete_confirm": "删除{count, plural, one {1 位联系人} other {# 位联系人}}?",
"deleted": "{count, plural, one {已删除 1 位联系人} other {已删除 # 位联系人}}",
"add_to_group": "添加到群组",
From a80a434cca0593065039834d0434f1e1eaaac04a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:05:40 +0100
Subject: [PATCH 07/15] fix: translate the contact counts in the Ukrainian
delete and add-to-group texts
---
locales/uk/common.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 189fb608c..4e9f7a3fe 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -3537,12 +3537,12 @@
"selected": "{count, plural, one {1 вибрано} few {# вибрано} many {# вибрано} other {# вибрано}}",
"select_all": "Вибрати все",
"delete": "Видалити",
- "delete_confirm": "Видалити {count, plural, one {1 contact} other {# contacts}}?",
+ "delete_confirm": "Видалити {count, plural, one {# контакт} few {# контакти} many {# контактів} other {# контакту}}?",
"deleted": "{count, plural, one {1 контакт видалено} few {# контакти видалено} many {# контактів видалено} other {# контактів видалено}}",
"add_to_group": "Додати до групи",
"choose_group": "Виберіть групу",
"new_group": "Нова група…",
- "adding_contacts": "Додавання {count, plural, one {1 contact} other {# contacts}}",
+ "adding_contacts": "Додавання {count, plural, one {# контакту} other {# контактів}}",
"moving_contacts": "Переміщення {count, plural, one {# контакту} other {# контактів}}",
"added_to_group": "Контакти додано до групи",
"export": "Експорт",
From bae76bcc4ee2c64306a4bd5dc26bb98ce1e29f9c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:09:57 +0100
Subject: [PATCH 08/15] fix: keep a category typed without Enter when the
contact is saved
---
.../contacts/__tests__/contact-form.test.tsx | 20 +++++++++++++
components/contacts/contact-form.tsx | 28 +++++++++++++------
2 files changed, 40 insertions(+), 8 deletions(-)
diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx
index 1f763d853..c6d304ba2 100644
--- a/components/contacts/__tests__/contact-form.test.tsx
+++ b/components/contacts/__tests__/contact-form.test.tsx
@@ -86,6 +86,26 @@ describe('ContactForm', () => {
expect(savedData.media).toBeNull();
});
+ it('adds a category typed without Enter when the contact is saved, in the spelling of an existing one', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render( );
+
+ fireEvent.click(screen.getByText('categories'));
+ fireEvent.change(screen.getByPlaceholderText('categories_placeholder'), { target: { value: 'friends, Gym ' } });
+ fireEvent.submit(screen.getByText('save').closest('form')!);
+
+ await waitFor(() => expect(onSave).toHaveBeenCalledOnce());
+ expect(onSave.mock.calls[0][0].keywords).toEqual({ Friends: true, Gym: true });
+ });
+
+ it('counts a typed category as a change, so leaving asks first', () => {
+ const onDirtyChange = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('categories'));
+ fireEvent.change(screen.getByPlaceholderText('categories_placeholder'), { target: { value: 'Gym' } });
+ expect(onDirtyChange).toHaveBeenLastCalledWith(true);
+ });
+
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 41e4903b3..995eb1f2d 100644
--- a/components/contacts/contact-form.tsx
+++ b/components/contacts/contact-form.tsx
@@ -398,6 +398,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const [keywordsStr, setKeywordsStr] = useState(
contact?.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]).join(", ") : ""
);
+ // Text typed in the Categories field but not added with Enter yet: Save
+ // adds it, so it isn't lost.
+ const [categoryDraft, setCategoryDraft] = useState("");
const [note, setNote] = useState(
contact?.notes ? Object.values(contact.notes)[0]?.note || "" : ""
@@ -455,7 +458,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const fields = JSON.stringify([
prefix, givenName, additionalName, surname, suffix, nickname, emails, phones, organization,
department, isOrg, jobTitle, role, addresses, onlineServices, anniversaries, personalInfoEntries,
- keywordsStr, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId,
+ keywordsStr, categoryDraft, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId,
]);
const [initialFields] = useState(fields);
const dirty = fields !== initialFields || photoEdit.kind !== "unchanged";
@@ -614,11 +617,14 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
});
const keywordsMap: Record = {};
- if (keywordsStr.trim()) {
- keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
- keywordsMap[k] = true;
- });
- }
+ keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
+ keywordsMap[k] = true;
+ });
+ // A typed category that matches an existing one takes its spelling.
+ categoryDraft.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
+ const existing = (allKeywords || []).find(kw => kw.toLowerCase() === k.toLowerCase());
+ keywordsMap[existing ?? k] = true;
+ });
// Only send `kind` when this form owns the answer: switching a card between
// person and organization. Leave other kinds (group, location, ...) untouched.
@@ -1126,6 +1132,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
void;
+ /** The text typed but not added yet; the form adds it on Save. */
+ inputValue: string;
+ onInputChange: (value: string) => void;
allKeywords: string[];
placeholder: string;
hint: string;
@@ -1215,7 +1228,6 @@ function CategoryComboBox({
}) {
const t = useTranslations("contacts.form");
const [isOpen, setIsOpen] = useState(false);
- const [inputValue, setInputValue] = useState("");
const inputRef = useRef(null);
// Parse current keywords from comma-separated string
@@ -1243,7 +1255,7 @@ function CategoryComboBox({
const next = [...currentKeywords, trimmed].join(", ");
onChange(next);
setInputValue("");
- }, [currentKeywords, onChange]);
+ }, [currentKeywords, onChange, setInputValue]);
const removeKeyword = useCallback((keyword: string) => {
const next = currentKeywords.filter(k => k !== keyword).join(", ");
From cb4f4c9168bc5fa68143ea2100b2e5d5140fd21c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:13:19 +0100
Subject: [PATCH 09/15] fix: add the open contact to the selection on
Ctrl+click and Shift+click in Contacts
---
.../contacts/__tests__/contacts-app.test.tsx | 15 +++++++++++++++
components/contacts/contact-list.tsx | 6 ++++++
2 files changed, 21 insertions(+)
diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx
index 830519bfd..a69a0e1d4 100644
--- a/components/contacts/__tests__/contacts-app.test.tsx
+++ b/components/contacts/__tests__/contacts-app.test.tsx
@@ -131,6 +131,21 @@ describe('ContactsApp', () => {
expect(useContactStore.getState().selectedContactId).toBe('c-bob');
});
+ it('adds the open contact to the selection on Ctrl+click, as Files does', () => {
+ renderContacts();
+ fireEvent.click(screen.getByText('Alice Smith'));
+ fireEvent.click(screen.getByText('Bob Jones'), { ctrlKey: true });
+ expect([...useContactStore.getState().selectedContactIds].sort()).toEqual(['c-alice', 'c-bob']);
+ });
+
+ it('selects from the open contact on Shift+click', () => {
+ const carol: ContactCard = { ...bob, id: 'c-carol', name: { full: 'Carol King' }, emails: { e0: { address: 'carol@example.com' } } };
+ renderContacts([alice, bob, carol]);
+ fireEvent.click(screen.getByText('Bob Jones'));
+ fireEvent.click(screen.getByText('Carol King'), { shiftKey: true });
+ expect([...useContactStore.getState().selectedContactIds].sort()).toEqual(['c-bob', 'c-carol']);
+ });
+
it('leaves an untouched contact form without asking', () => {
renderContacts();
fireEvent.click(screen.getByText('Alice Smith'));
diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx
index 6833b8d1e..6f2304f2c 100644
--- a/components/contacts/contact-list.tsx
+++ b/components/contacts/contact-list.tsx
@@ -720,11 +720,17 @@ export function ContactList({
density={density}
selectedContactIds={selectedContactIds}
onClick={(e) => {
+ // The open contact counts as selected, as a clicked file does
+ // in Files: Ctrl/⌘+click and Shift+click build on it.
+ const startFromOpen = !hasSelection && !!selectedContactId
+ && selectedContactId !== contact.id && sortedIds.includes(selectedContactId);
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
+ if (startFromOpen) onToggleSelection(selectedContactId);
onToggleSelection(contact.id);
} else if (e.shiftKey) {
e.preventDefault();
+ if (startFromOpen) onToggleSelection(selectedContactId);
onSelectRangeContacts(contact.id, sortedIds);
} else {
if (hasSelection) onClearSelection();
From 10420b8d96faa1057e471f35b755bc07715c1fc0 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:18:14 +0100
Subject: [PATCH 10/15] fix: show the real path of a folder picked in the Files
tree, never the hidden trash
---
stores/__tests__/file-store.test.ts | 41 ++++++++++++++++++++
stores/file-store.ts | 60 ++++++++++++++++++++++++++---
2 files changed, 96 insertions(+), 5 deletions(-)
diff --git a/stores/__tests__/file-store.test.ts b/stores/__tests__/file-store.test.ts
index ec499372f..f79954b91 100644
--- a/stores/__tests__/file-store.test.ts
+++ b/stores/__tests__/file-store.test.ts
@@ -4,6 +4,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
import { jmapError } from '@/lib/jmap/jmap-error';
import { fileErrorKind } from '@/lib/file-errors';
+import { TRASH_LOCATION } from '@/lib/files-trash';
// Minimal in-memory FileNode backend that models the JMAP hierarchy via
// parentId links (the behaviour issue #379 asks for).
@@ -854,6 +855,46 @@ describe('file-store folder tree and moves', () => {
});
});
+ describe('breadcrumb trail', () => {
+ const tree = () => makeMockClient([
+ dir('docs', 'Documents', null),
+ dir('photos', 'Photos', null),
+ dir('trips', 'Trips', 'photos'),
+ dir('trash', '.Trash', null),
+ ]);
+ const trail = () => useFileStore.getState().pathStack.map(s => s.name);
+
+ it('follows the real ancestors when a folder is picked in the tree, not the folder open before', async () => {
+ useFileStore.getState().initClient(tree());
+ await useFileStore.getState().navigate(null);
+ await useFileStore.getState().navigate('docs', 'Documents');
+ await useFileStore.getState().navigate('trips', 'Trips');
+ expect(useFileStore.getState().currentPath).toBe('/Photos/Trips');
+ expect(trail()).toEqual(['', 'Photos', 'Trips']);
+
+ // Alt+Up goes to the real parent.
+ await useFileStore.getState().navigateUp();
+ expect(useFileStore.getState().currentParentId).toBe('photos');
+ expect(useFileStore.getState().currentPath).toBe('/Photos');
+ });
+
+ it('leaves the Trash out of the trail of a folder opened from it', async () => {
+ useFileStore.getState().initClient(tree());
+ await useFileStore.getState().navigate(null);
+ await useFileStore.getState().navigate(TRASH_LOCATION, '.Trash');
+ await useFileStore.getState().navigate('photos', 'Photos');
+ expect(useFileStore.getState().currentPath).toBe('/Photos');
+ expect(trail()).toEqual(['', 'Photos']);
+ });
+
+ it('sets the full trail once the listing arrives, when the folder list was not loaded yet', async () => {
+ useFileStore.getState().initClient(tree());
+ await useFileStore.getState().navigate('trips', 'Trips');
+ expect(useFileStore.getState().currentPath).toBe('/Photos/Trips');
+ expect(useFileStore.getState().pathStack.map(s => s.id)).toEqual([null, 'photos', 'trips']);
+ });
+ });
+
it('keeps every folder from the listing for the tree, at any depth', async () => {
const client = makeMockClient([
dir('a', 'A', null),
diff --git a/stores/file-store.ts b/stores/file-store.ts
index a9834448e..ce5b1256b 100644
--- a/stores/file-store.ts
+++ b/stores/file-store.ts
@@ -501,6 +501,37 @@ function isUnder(path: string, base: string): boolean {
return path === base || path.startsWith(base + '/');
}
+type PathStep = { id: string | null; name: string };
+
+/**
+ * The breadcrumb trail of an own-drive folder from its real ancestors: Home,
+ * then each folder down to `id`. null when the chain can't be followed (a
+ * parent isn't in `folders`) or runs through the hidden trash folder, which
+ * never shows in the breadcrumb.
+ */
+function ancestorStack(
+ folders: readonly { id: string; name: string; parentId?: string | null }[],
+ id: string,
+ trashId?: string,
+): PathStep[] | null {
+ const byId = new Map(folders.map(f => [f.id, f]));
+ const trail: PathStep[] = [];
+ const seen = new Set();
+ let at = byId.get(id);
+ while (at) {
+ if (seen.has(at.id) || at.id === trashId) return null;
+ seen.add(at.id);
+ trail.unshift({ id: at.id, name: at.name });
+ if (at.parentId == null) return [{ id: null, name: '' }, ...trail];
+ at = byId.get(at.parentId);
+ }
+ return null;
+}
+
+function sameStack(a: PathStep[], b: PathStep[]): boolean {
+ return a.length === b.length && a.every((s, i) => s.id === b[i].id && s.name === b[i].name);
+}
+
function buildPathFromStack(stack: { id: string | null; name: string }[]): string {
if (stack.length <= 1) return '/';
return '/' + stack.slice(1).map(s => s.name).join('/');
@@ -842,7 +873,7 @@ export const useFileStore = create((set, get) => ({
},
navigate: async (parentId: string | null, name?: string) => {
- const { client, pathStack, currentParentId: previousParentId, hasListed } = get();
+ const { client, pathStack, currentParentId: previousParentId, hasListed, allFolders: knownFolders } = get();
if (!client) return;
// A refresh lists the same folder again; plugins only hear about a new one.
const opensFolder = !hasListed || parentId !== previousParentId;
@@ -859,16 +890,23 @@ export const useFileStore = create((set, get) => ({
} else if (isTrashLocation(parentId)) {
newStack = [{ id: null, name: '' }, { id: TRASH_LOCATION, name: TRASH_FOLDER_NAME }];
} else {
- // Check if navigating to a parent in the stack
+ // An own-drive folder: the trail is its real ancestors, wherever it was
+ // opened from (the tree, a favorite, the Trash). A folder of a shared
+ // drive builds on the trail openSharedNode set up.
+ const known = isCrossAccountId(parentId) ? null : ancestorStack(knownFolders, parentId);
const existingIdx = pathStack.findIndex(s => s.id === parentId);
- if (existingIdx >= 0) {
+ if (known) {
+ newStack = known;
+ } else if (existingIdx >= 0) {
newStack = pathStack.slice(0, existingIdx + 1);
} else {
- newStack = [...pathStack, { id: parentId, name: name || parentId }];
+ // The Trash and shared-files pages are no folder to be inside of.
+ const base = pathStack.filter(s => s.id === null || (!isTrashLocation(s.id) && sharedFilesOwner(s.id) === null));
+ newStack = [...base, { id: parentId, name: name || parentId }];
}
}
- const newPath = buildPathFromStack(newStack);
+ let newPath = buildPathFromStack(newStack);
set({ pathStack: newStack, currentPath: newPath });
try { localStorage.setItem('files-last-parent-id', parentId || ''); } catch { /* ignore */ }
@@ -907,6 +945,18 @@ export const useFileStore = create((set, get) => ({
// The owner's folders above a shared one stay out of the tree as well.
const allFolders = allNodes.filter(n => isFolder(n) && !n.discoverableOnly && !inTrash.has(n.id)).map(nodeToResource);
+ // The folder list was stale or empty (first visit, a link): set the
+ // trail again from the fresh one, unless another folder opened since.
+ if (parentId !== null && !isCrossAccountId(parentId) && !isTrashLocation(parentId)
+ && sharedFilesOwner(parentId) === null && get().currentParentId === parentId) {
+ const fresh = ancestorStack(allNodes.filter(n => isFolder(n)), parentId, trash?.id);
+ if (fresh && !sameStack(fresh, get().pathStack)) {
+ newPath = buildPathFromStack(fresh);
+ set({ pathStack: fresh, currentPath: newPath });
+ try { localStorage.setItem('files-path-stack', JSON.stringify(fresh)); } catch { /* ignore */ }
+ }
+ }
+
// Prune recent files whose backing node no longer exists on the server
// or is in the trash.
const { recentFiles } = get();
From 55fb5acef58f48fd1ed68aed789536e9f13a629f Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:19:46 +0100
Subject: [PATCH 11/15] fix: keep a file's Modified time on move, Undo and
restore in the demo
---
lib/__tests__/demo-client-files.test.ts | 22 ++++++++++++++++++++++
lib/demo/demo-client.ts | 6 ++++--
2 files changed, 26 insertions(+), 2 deletions(-)
create mode 100644 lib/__tests__/demo-client-files.test.ts
diff --git a/lib/__tests__/demo-client-files.test.ts b/lib/__tests__/demo-client-files.test.ts
new file mode 100644
index 000000000..9a4c9b873
--- /dev/null
+++ b/lib/__tests__/demo-client-files.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { DemoJMAPClient } from '@/lib/demo/demo-client';
+
+// A move, its Undo and a restore from the trash are all parentId updates;
+// none of them may make a file look edited.
+describe('DemoJMAPClient files', () => {
+ it('keeps a file\'s Modified time when it is moved or renamed', async () => {
+ const client = new DemoJMAPClient();
+ const folder = await client.createFileDirectory('Target', null);
+ const nodes = await client.listAllFileNodes();
+ const original = nodes.find((n) => n.blobId != null && n.modified)!;
+ const before = original.modified;
+
+ await client.updateFileNodes({ [original.id]: { parentId: folder.id } });
+ await client.updateFileNode(original.id, { name: 'renamed.txt' });
+
+ const after = (await client.getFileNodes([original.id]))[0];
+ expect(after.parentId).toBe(folder.id);
+ expect(after.name).toBe('renamed.txt');
+ expect(after.modified).toBe(before);
+ });
+});
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index 44bb9e3d7..f1790676e 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -1409,8 +1409,10 @@ export class DemoJMAPClient implements IJMAPClient {
}
async updateFileNode(id: string, updates: Partial>): Promise {
+ // A rename or move leaves `modified` alone, as on Stalwart: it dates the
+ // content, and a move or its Undo shouldn't make a file look edited.
const node = this.data.fileNodes.find(n => n.id === id);
- if (node) Object.assign(node, updates, { modified: new Date().toISOString() });
+ if (node) Object.assign(node, updates);
}
async updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }> {
@@ -1418,7 +1420,7 @@ export class DemoJMAPClient implements IJMAPClient {
for (const [id, patch] of Object.entries(updates)) {
const node = this.data.fileNodes.find(n => n.id === id);
if (node) {
- Object.assign(node, patch, { modified: new Date().toISOString() });
+ Object.assign(node, patch);
updated.push(id);
}
}
From d1e0fddfdc6026b974f7dfa9fe1cb7ba84ce14fa Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 10:24:55 +0100
Subject: [PATCH 12/15] fix: keep file sizes as number then unit in
right-to-left languages and write Files dates in the app's language
---
.../files/__tests__/file-item-parts.test.tsx | 27 +++++++++++++++++++
components/files/file-details-panel.tsx | 10 +++----
components/files/file-item-parts.tsx | 27 ++++++++++++++++---
components/files/file-picker-dialog.tsx | 2 +-
components/files/file-views.tsx | 10 +++----
5 files changed, 61 insertions(+), 15 deletions(-)
create mode 100644 components/files/__tests__/file-item-parts.test.tsx
diff --git a/components/files/__tests__/file-item-parts.test.tsx b/components/files/__tests__/file-item-parts.test.tsx
new file mode 100644
index 000000000..09c76a75a
--- /dev/null
+++ b/components/files/__tests__/file-item-parts.test.tsx
@@ -0,0 +1,27 @@
+import { render } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+
+let appLocale = 'ar';
+vi.mock('@/i18n/detect-locale', () => ({ getEffectiveLocale: () => appLocale }));
+
+import { FileSize, formatFullDate, formatShortDate } from '../file-item-parts';
+
+// The browser here is en-US; the app's language must win.
+describe('Files list dates and sizes', () => {
+ afterEach(() => { appLocale = 'ar'; });
+
+ it('writes dates in the app language, not the browser one', () => {
+ const now = new Date('2026-10-27T12:00:00Z');
+ expect(formatShortDate('2026-03-05T10:00:00Z', now)).toMatch(/مارس/);
+ expect(formatFullDate('2026-03-05T10:00:00Z')).toMatch(/مارس/);
+ appLocale = 'de';
+ expect(formatShortDate('2026-03-05T10:00:00Z', now)).toMatch(/März/);
+ });
+
+ it('keeps the number before the unit in a right-to-left layout', () => {
+ const { container } = render(
);
+ const size = container.querySelector('bdi')!;
+ expect(size).toHaveAttribute('dir', 'ltr');
+ expect(size.textContent).toBe('6.7 KB');
+ });
+});
diff --git a/components/files/file-details-panel.tsx b/components/files/file-details-panel.tsx
index c8e98a0a5..5636c64f2 100644
--- a/components/files/file-details-panel.tsx
+++ b/components/files/file-details-panel.tsx
@@ -4,12 +4,12 @@ import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { X, Download, Share2, Star, StarOff, FolderOpen, Eye, SquarePen, Files } from "@/components/icons";
import { Button } from "@/components/ui/button";
-import { cn, formatFileSize } from "@/lib/utils";
+import { cn } from "@/lib/utils";
import type { FileResource } from "@/stores/file-store";
import type { FileResourceView } from "@/lib/plugin-types";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import {
- ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
+ FileSize, ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
} from "@/components/files/file-item-parts";
interface FileDetailsPanelProps {
@@ -91,7 +91,7 @@ export function FileDetailsPanel({
{t("folder_summary", { folders, files })}
-
+
} />
{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. */}
+
{t("cancel")}
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. */}
+
{t("form.cancel")}
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. */}
+
{/* Either name is enough (or an organization), so neither is starred. */}