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/54] 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 3a888e11cc5e5eeda8466116fdd2c310545c73c7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:46:41 +0100 Subject: [PATCH 02/54] fix: open the import calendar dialog with the focus on the drop zone so Tab stays inside it --- .../calendar/__tests__/calendar-dialogs.test.tsx | 10 ++++++++++ components/calendar/ical-import-modal.tsx | 6 ++++++ components/ui/__tests__/dialog.test.tsx | 11 +++++++++++ components/ui/dialog.tsx | 4 +++- 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/components/calendar/__tests__/calendar-dialogs.test.tsx b/components/calendar/__tests__/calendar-dialogs.test.tsx index bc6b39924..4b408c769 100644 --- a/components/calendar/__tests__/calendar-dialogs.test.tsx +++ b/components/calendar/__tests__/calendar-dialogs.test.tsx @@ -61,6 +61,16 @@ describe('import dialog from the keyboard', () => { render(); expect(screen.getByRole('button', { name: /select_file/ })).toBeInTheDocument(); }); + + // It focused the hidden file input, which can't take the focus, so the + // focus stayed on the page behind and Tab walked the calendar. + it('opens with the focus on the drop zone, or on the address field for a link', () => { + const { unmount } = render(); + expect(document.activeElement).toBe(screen.getByRole('button', { name: /select_file/ })); + unmount(); + render(); + expect(document.activeElement).toBe(screen.getByLabelText('tab_url')); + }); }); describe('import preview', () => { diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 29967fc5f..7d27dc3ae 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -53,6 +53,9 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal const [urlInput, setUrlInput] = useState(initialUrl || ""); const [isFetchingUrl, setIsFetchingUrl] = useState(false); const fileInputRef = useRef(null); + // The dialog opens on the drop zone, or on the address field for a link. + const dropZoneRef = useRef(null); + const urlInputRef = useRef(null); const targetId = useId(); const validateFile = useCallback((file: File): string | null => { @@ -229,6 +232,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal onClose={close} title={t("title")} size="full-on-phone" + initialFocus={importMode === "url" ? urlInputRef : dropZoneRef} dirty={step !== "select" || urlInput.trim() !== (initialUrl ?? "")} footer={step !== "importing" ? ( <> @@ -281,6 +285,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal {importMode === "file" && ( <> + + + , + ); + expect(document.activeElement).toBe(screen.getByLabelText('name')); + }); + it('honours initialFocus', () => { function Host() { const target = useRef(null); diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 266f4f357..4512d1c74 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -53,8 +53,10 @@ const SIZES: Record = { "max-w-2xl max-sm:max-w-none max-sm:h-full max-sm:max-h-none max-sm:rounded-none max-sm:border-0", }; +// A field out of the tab order (the hidden input behind a file drop zone) +// can't take the focus: picking it left the focus on the page behind. const FIELD_SELECTOR = - 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]):not(:disabled), textarea:not(:disabled), select:not(:disabled), [contenteditable="true"]'; + 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([tabindex="-1"]):not(:disabled), textarea:not([tabindex="-1"]):not(:disabled), select:not([tabindex="-1"]):not(:disabled), [contenteditable="true"]'; /** * The one dialog shell (R4): a portal on at z-[60] over a dimmed 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 03/54] 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 1322f6c3f0f1dc92c55c531e7323d4f946f47fa7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:48:49 +0100 Subject: [PATCH 04/54] fix: switch between demo accounts without the app crashing --- lib/__tests__/push-bindings.test.ts | 36 +++++++++++++++++++++++++++++ lib/demo/demo-client.ts | 2 ++ lib/jmap/client-interface.ts | 2 ++ lib/push-bindings.ts | 5 ++-- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/push-bindings.test.ts b/lib/__tests__/push-bindings.test.ts index 5bb9f0300..0856fafa8 100644 --- a/lib/__tests__/push-bindings.test.ts +++ b/lib/__tests__/push-bindings.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { reconcilePushBindings, releasePushBindings, type PushBinding, type PushRole } from '../push-bindings'; +import { DemoJMAPClient } from '../demo/demo-client'; /** A client that counts what the reconciliation does to it. */ class FakeClient { @@ -98,6 +99,41 @@ describe('reconcilePushBindings', () => { expect(fresh.setups).toBe(1); }); + it('switches between demo logins, which have no live stream', () => { + const work = new DemoJMAPClient('work'); + const personal = new DemoJMAPClient('personal'); + const bindings = new Map(); + const roles: Array<[string, PushRole]> = []; + const onError = vi.fn(); + const run = (active: string) => reconcilePushBindings({ + bindings, + clients: new Map([['work', work], ['personal', personal]]), + activeAccountId: active, + listen: (c, role) => { roles.push([c === work ? 'work' : 'personal', role]); }, + onError, + }); + run('work'); + expect(() => run('personal')).not.toThrow(); + expect(() => run('work')).not.toThrow(); + expect(onError).not.toHaveBeenCalled(); + expect(bindings.get(work)?.role).toBe('active'); + expect(bindings.get(personal)?.role).toBe('background'); + }); + + it('treats a client without hasSSEStream as having no stream', () => { + const plain = (name: string) => ({ name, setupPushNotifications: vi.fn(), closePushNotifications: vi.fn() }); + const a = plain('a'); + const b = plain('b'); + const bindings = new Map, PushBinding>(); + const run = (active: string) => reconcilePushBindings({ + bindings, clients: new Map([['a', a], ['b', b]]), activeAccountId: active, listen: vi.fn(), + }); + run('a'); + expect(() => run('b')).not.toThrow(); + expect(bindings.get(b)?.role).toBe('active'); + expect(bindings.get(a)?.role).toBe('background'); + }); + it('releases everything on logout', () => { const { clients, bindings, run } = world(['a', 'b']); run(['a', 'b'], 'a'); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 44bb9e3d7..0c96c8e75 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -164,6 +164,8 @@ export class DemoJMAPClient implements IJMAPClient { setupPushNotifications(): boolean { return true; } closePushNotifications(): void { /* no-op in demo */ } + // Demo changes arrive through the state-change callback, not a live stream. + hasSSEStream(): boolean { return false; } onConnectionChange(callback: (connected: boolean) => void): void { this.connectionCallback = callback; } onRateLimit(): void { /* no-op in demo */ } isRateLimited(): boolean { return false; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 549d06060..d8669fc72 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -148,6 +148,8 @@ export interface IJMAPClient { // ── Push / state ────────────────────────────────────────────── setupPushNotifications(): boolean; closePushNotifications(): void; + /** Whether this client holds one of the tab's live event streams (vs. slow-polling or none). */ + hasSSEStream(): boolean; onConnectionChange(callback: (connected: boolean) => void): void; onRateLimit(callback: (rateLimited: boolean, retryAfterMs: number) => void): void; isRateLimited(): boolean; diff --git a/lib/push-bindings.ts b/lib/push-bindings.ts index f02a53cfa..0834ee304 100644 --- a/lib/push-bindings.ts +++ b/lib/push-bindings.ts @@ -17,7 +17,8 @@ export type PushRole = 'active' | 'background'; export interface PushBindable { setupPushNotifications(): unknown; closePushNotifications(): void; - hasSSEStream(): boolean; + /** Optional: a client without it (demo, tests) counts as having no live stream. */ + hasSSEStream?(): boolean; } export interface PushBinding { @@ -77,7 +78,7 @@ export function reconcilePushBindings({ if (!bound) { bind(client, accountId, role); } else if (bound.role !== role) { - if (role === 'active' && !client.hasSSEStream()) { + if (role === 'active' && !(typeof client.hasSSEStream === 'function' && client.hasSSEStream())) { // Newly active on a slow poll: take the stream from the login that // just stopped being active - re-bound as background below - so the // account the user is looking at still updates live. 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 05/54] 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 6eaaca5cfa020cb21e318dff066753a4dce4d982 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:50:23 +0100 Subject: [PATCH 06/54] fix: move the focus into an event's card once it shows, so Enter on an event lands inside it --- .../event-detail-popover-focus.test.tsx | 70 +++++++++++++++++++ components/calendar/event-detail-popover.tsx | 25 ++++++- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 components/calendar/__tests__/event-detail-popover-focus.test.tsx diff --git a/components/calendar/__tests__/event-detail-popover-focus.test.tsx b/components/calendar/__tests__/event-detail-popover-focus.test.tsx new file mode 100644 index 000000000..787cc35c8 --- /dev/null +++ b/components/calendar/__tests__/event-detail-popover-focus.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { EventDetailPopover } from '../event-detail-popover'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// CAL-13: Enter on an event opened the card but the focus stayed on the +// event: the card focused its first button while it was still hidden, +// before it had been placed. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (d: Date | string) => String(d), + relativeTime: (d: Date | string) => String(d), + number: (n: number) => String(n), + }), +})); + +const calendar = { + id: 'cal-1', + name: 'Work', + myRights: { mayWriteAll: true, mayWriteOwn: true }, +} as unknown as Calendar; + +const event = { + id: 'evt-1', + calendarIds: { 'cal-1': true }, + title: 'Review', + start: '2026-11-20T10:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + showWithoutTime: false, + participants: null, + alerts: null, +} as unknown as CalendarEvent; + +describe('EventDetailPopover focus', () => { + it('takes the focus once it is visible, and gives it back to the event on close', () => { + const opener = document.createElement('button'); + document.body.appendChild(opener); + opener.focus(); + const visibilityAtFocus: string[] = []; + const onFocusIn = (e: FocusEvent) => { + const card = (e.target as HTMLElement).closest('[role="dialog"]'); + if (card) visibilityAtFocus.push(card.style.visibility); + }; + document.addEventListener('focusin', onFocusIn); + const { unmount } = render( + , + ); + const card = screen.getByRole('dialog', { name: 'Review' }); + expect(card.contains(document.activeElement)).toBe(true); + expect(visibilityAtFocus).toEqual(['visible']); + unmount(); + document.removeEventListener('focusin', onFocusIn); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); +}); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index b99da1688..63399b897 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -253,17 +253,40 @@ export function EventDetailPopover({ return () => observer.disconnect(); }, [anchorRect, isMobile]); + // A card opened by click or Enter takes the focus, and gives it back to + // the event when it closes. useEffect(() => { if (!pinned) return; const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; const node = popoverRef.current; - node?.querySelector("button")?.focus({ preventScroll: true }); return () => { const active = document.activeElement; if (opener?.isConnected && (!active || active === document.body || node?.contains(active))) opener.focus({ preventScroll: true }); }; }, [pinned]); + // The focus goes in once the card is placed: before, it is hidden and + // can't take the focus, which stayed on the event. A card that just + // turned visible can still count as hidden for a frame, so it tries again + // on the next ones, unless the focus moved somewhere else meanwhile. + const placed = position !== null; + useEffect(() => { + const node = popoverRef.current; + if (!pinned || !placed || !node) return; + const from = document.activeElement; + let frame = 0; + let tries = 0; + const attempt = () => { + const active = document.activeElement; + if (node.contains(active) || active !== from) return; + const first = node.querySelector("button"); + first?.focus({ preventScroll: true }); + if (first && document.activeElement !== first && ++tries < 30) frame = requestAnimationFrame(attempt); + }; + attempt(); + return () => cancelAnimationFrame(frame); + }, [pinned, placed]); + useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); 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 07/54] 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 ce092ac1ab82719ee9a9a1d4f6998983ab718598 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:55:39 +0100 Subject: [PATCH 08/54] fix: stop adding blank lines to the external rules block on every filter save --- lib/sieve/__tests__/external-rules.test.ts | 18 ++++++++++++++++++ lib/sieve/generator.ts | 16 +++++++++------- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/lib/sieve/__tests__/external-rules.test.ts b/lib/sieve/__tests__/external-rules.test.ts index d734af1fd..4160023cb 100644 --- a/lib/sieve/__tests__/external-rules.test.ts +++ b/lib/sieve/__tests__/external-rules.test.ts @@ -287,6 +287,24 @@ describe('external rule preservation (issue #201)', () => { expect(externalAfter[0].name).toBe('Untouchable'); expect(externalAfter[0].conditions[0]).toMatchObject({ field: 'header', headerName: 'X-Spam' }); }); + + it('writes the same script on every save, with no blank lines piling up around external rules', () => { + const initial = `${generateScript([makeBulwarkRule({ name: 'Mine' })])}\n\n# rule:[First]\nif header :is "X-Spam" "yes" {\n discard;\n}\n\n\n# rule:[Second]\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n\n`; + const save = (script: string) => { + const parsed = parseScript(script); + return generateScript(parsed.rules, parsed.vacation, { externalRequires: parsed.externalRequires }); + }; + + const first = save(initial); + const second = save(first); + const third = save(second); + + expect(second).toBe(first); + expect(third).toBe(first); + expect(first).not.toMatch(/\n\n\n/); + expect(first).toContain('# --- External rules (managed outside Bulwark) ---\n# rule:[First]'); + expect(first).toContain('}\n\n# rule:[Second]'); + }); }); describe('Nextcloud Mail marker regions', () => { diff --git a/lib/sieve/generator.ts b/lib/sieve/generator.ts index f4cdf7c1d..de7e442f1 100644 --- a/lib/sieve/generator.ts +++ b/lib/sieve/generator.ts @@ -495,15 +495,17 @@ export function generateScript( lines.push('}'); } - // Append preserved external rules verbatim. Each rawBlock already carries its - // own leading comments and trailing whitespace from the source script. - if (externalRules.length > 0) { + // Append preserved external rules verbatim. Each rawBlock carries its own + // leading comments, but also the blank lines around it in the source + // script; trim those and put exactly one blank line between blocks, so a + // save writes the same script as the one before it. + const externalBlocks = externalRules + .map((ext) => (ext.rawBlock ?? '').trim()) + .filter((block) => block.length > 0); + if (externalBlocks.length > 0) { lines.push(''); lines.push('# --- External rules (managed outside Bulwark) ---'); - for (const ext of externalRules) { - if (!ext.rawBlock) continue; - lines.push(ext.rawBlock.replace(/\s+$/, '')); - } + lines.push(externalBlocks.join('\n\n')); } lines.push(''); 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 09/54] 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 86b56d490af86b1ec2c72eb6ad77f55cbb291774 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:01:34 +0100 Subject: [PATCH 10/54] fix: give tasks in the phone day view a full row with a tappable circle and drop the empty all-day block --- .../calendar-day-view-phone.test.tsx | 66 +++++++++++++++++++ components/calendar/calendar-day-view.tsx | 29 ++++---- components/calendar/task-chip.tsx | 23 +++++-- 3 files changed, 101 insertions(+), 17 deletions(-) create mode 100644 components/calendar/__tests__/calendar-day-view-phone.test.tsx diff --git a/components/calendar/__tests__/calendar-day-view-phone.test.tsx b/components/calendar/__tests__/calendar-day-view-phone.test.tsx new file mode 100644 index 000000000..c7365c4c4 --- /dev/null +++ b/components/calendar/__tests__/calendar-day-view-phone.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { CalendarDayView } from '../calendar-day-view'; +import type { Calendar, CalendarEvent, CalendarTask } from '@/lib/jmap/types'; + +// CAL-27 follow-up: the phone day view drew tasks as 10px chips with a +// circle too small to tap, and a busy neighbouring day left an empty +// all-day block of ~150px above the hours of a day with none. + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +const holiday = { + id: 'ev-1', calendarIds: { 'cal-1': true }, title: 'Holiday', start: '2026-09-10T00:00:00', + duration: 'P1D', showWithoutTime: true, +} as unknown as CalendarEvent; + +const task = { + id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-1': true }, + progress: 'needs-action', priority: 0, due: '2026-09-09', showWithoutTime: true, +} as unknown as CalendarTask; + +function renderDay(isMobile: boolean, tasks: CalendarTask[] = [task]) { + return render( + , + ); +} + +describe('day view on a phone', () => { + it('gives each task a full row with a 44px target for the circle', () => { + renderDay(true); + const chip = document.querySelector('[data-calendar-task="t1"]')!; + expect(chip).toHaveClass('min-h-11', 'text-xs'); + expect(within(chip).getByRole('button', { name: /mark_complete|Mark as complete/i })).toHaveClass('w-11', 'self-stretch'); + }); + + it('keeps the small shared chip on a wide screen', () => { + renderDay(false); + const chip = document.querySelector('[data-calendar-task="t1"]')!; + expect(chip).toHaveClass('h-full', 'text-[10px]'); + }); + + it('sizes the all-day row by the day in view, not by a busy neighbour', () => { + renderDay(true, []); + const today = document.querySelector('[data-day="2026-09-09"]')!; + const next = document.querySelector('[data-day="2026-09-10"]')!; + // The day in view has nothing all-day: no empty block under its date. + expect(within(today).queryByText(/all_day|All day/i)).toBeNull(); + expect(today).not.toHaveClass('h-0'); + // The neighbour's all-day event takes no height of its own. + expect(within(next).getByRole('button', { name: /Holiday/ })).toBeInTheDocument(); + expect(next).toHaveClass('h-0', 'min-h-full', 'overflow-hidden'); + expect(screen.getAllByRole('columnheader')).toHaveLength(3); + }); +}); diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index d927ee602..b2518068b 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -197,14 +197,6 @@ export function CalendarDayView({ return map; }, [days, eventsByDay, hours]); - const hasAllDayArea = useMemo( - () => days.some((day) => { - const key = format(day, "yyyy-MM-dd"); - return (eventsByDay.get(key)?.allDay.length ?? 0) > 0 || (tasksByDay.get(key)?.length ?? 0) > 0; - }), - [days, eventsByDay, tasksByDay], - ); - const scrollToFocus = useCallback(() => { const root = rootRef.current; if (!root) return; @@ -232,6 +224,10 @@ export function CalendarDayView({ // not used: browsers re-snap on their own when columns are prepended, // which would double the scroll correction.) const visibleKeyRef = useRef(null); + // The day in view: its all-day events and tasks size the row under the + // dates, so a busy neighbour doesn't leave an empty block above the hours. + const [visibleKey, setVisibleKey] = useState(() => dayKey(focus.date)); + useEffect(() => { setVisibleKey(dayKey(focus.date)); }, [focus.date, focus.nonce]); const scrollFrameRef = useRef(null); const snapTimerRef = useRef | null>(null); const handleStripScroll = useCallback(() => { @@ -255,6 +251,7 @@ export function CalendarDayView({ const key = dayKey(day); if (key === visibleKeyRef.current) return; visibleKeyRef.current = key; + setVisibleKey(key); onVisibleDateChange?.(day); }); }, [colWidth, colCount, days, onVisibleDateChange]); @@ -352,7 +349,16 @@ export function CalendarDayView({ const allDayEvents = eventsByDay.get(key)?.allDay ?? []; const dayTasks = tasksByDay.get(key) ?? []; return ( -
+
- {hasAllDayArea && ( + {(allDayEvents.length > 0 || dayTasks.length > 0) && (
{ @@ -414,12 +420,13 @@ export function CalendarDayView({
{/* The same chip as the month and week grids. */} {dayTasks.map((task) => ( -
+
task.calendarIds[c.id])} onToggleComplete={onToggleTaskComplete} onSelect={onSelectTask} + touch={isMobile} />
))} diff --git a/components/calendar/task-chip.tsx b/components/calendar/task-chip.tsx index ccea5a78d..7df48a12d 100644 --- a/components/calendar/task-chip.tsx +++ b/components/calendar/task-chip.tsx @@ -10,6 +10,8 @@ interface CalendarTaskChipProps { calendar?: Calendar; onToggleComplete?: (task: CalendarTask) => void; onSelect?: (task: CalendarTask) => void; + /** A full row for a finger: 44px tall, a bigger circle with a 44px hit area (phone day view). */ + touch?: boolean; className?: string; } @@ -17,7 +19,7 @@ interface CalendarTaskChipProps { * A task on its due day in the month and week grids (#1107). The circle * toggles completion; the title opens the task in the editor. */ -export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, className }: CalendarTaskChipProps) { +export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, touch = false, className }: CalendarTaskChipProps) { const t = useTranslations("calendar"); const isCompleted = task.progress === "completed"; const color = calendar?.color || "#3b82f6"; @@ -26,30 +28,39 @@ export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, c return (
- } @@ -137,7 +156,6 @@ export function SieveEditorModal({ onChange={(e) => { setScript(e.target.value); setValidationResult(null); - setShowSaveWarning(false); }} onKeyDown={handleKeyDown} className="flex-1 bg-background text-foreground font-mono text-sm p-2 resize-none focus:outline-none leading-[1.5rem]" @@ -174,17 +192,11 @@ export function SieveEditorModal({ )}
)} - - {showSaveWarning && ( -
- -

{t("save_warning")}

-
- )}
{discardDialog} + ); } diff --git a/locales/ar/common.json b/locales/ar/common.json index 8362d7178..2f558680b 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2523,12 +2523,12 @@ "script_content": "نص Sieve", "valid": "النص صالح", "invalid": "النص يحتوي على أخطاء", - "save_warning": "سيؤدي الحفظ إلى استبدال أي قواعد مرئية. لا يمكن التراجع عن هذا. انقر على حفظ مرة أخرى للتأكيد.", "validating": "جارٍ التحقق...", "validate": "تحقق", "cancel": "إلغاء", "save": "حفظ", - "confirm_save": "تأكيد الحفظ", + "overwrite_title": "استبدال قواعدك المرئية؟", + "overwrite_message": "هذا البرنامج النصي لا يحتوي على القواعد التي أنشأتها في المحرر المرئي. حفظه يستبدلها، ولا يمكن التراجع عن ذلك.", "validation_failed": "فشل طلب التحقق" }, "rule_summary": { diff --git a/locales/ca/common.json b/locales/ca/common.json index 93443e066..157df8e55 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2523,12 +2523,12 @@ "script_content": "Script Sieve", "valid": "L'script és vàlid", "invalid": "L'script té errors", - "save_warning": "En desar se sobreescriuran totes les regles visuals. Això no es pot desfer. Torneu a fer clic a Desa per confirmar-ho.", "validating": "Validant...", "validate": "Valida", "cancel": "Cancel·la", "save": "Desa", - "confirm_save": "Confirma el desament", + "overwrite_title": "Voleu substituir les regles visuals?", + "overwrite_message": "Aquest script no conté les regles que heu creat a l'editor visual. Si el deseu, les substituirà, i no es pot desfer.", "validation_failed": "Ha fallat la sol·licitud de validació" }, "rule_summary": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 9a36130c2..7dd7ed2d0 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2516,12 +2516,12 @@ "script_content": "Skript Sieve", "valid": "Skript je platný", "invalid": "Skript obsahuje chyby", - "save_warning": "Uložení přepíše všechna vizuální pravidla. Tuto akci nelze vrátit zpět. Potvrďte opětovným kliknutím na Uložit.", "validating": "Ověřování...", "validate": "Ověřit", "cancel": "Zrušit", "save": "Uložit", - "confirm_save": "Potvrdit uložení", + "overwrite_title": "Nahradit vizuální pravidla?", + "overwrite_message": "Tento skript neobsahuje pravidla, která jste vytvořili ve vizuálním editoru. Uložením je nahradíte a nelze to vrátit zpět.", "validation_failed": "Požadavek na ověření selhal" }, "rule_summary": { diff --git a/locales/da/common.json b/locales/da/common.json index 3a02cdc78..4ceb1001c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2519,12 +2519,12 @@ "script_content": "Sieve-script", "valid": "Scriptet er gyldigt", "invalid": "Scriptet har fejl", - "save_warning": "Lagring vil overskrive eventuelle visuelle regler. Dette kan ikke fortrydes. Klik Gem igen for at bekræfte.", "validating": "Validerer...", "validate": "Valider", "cancel": "Annuller", "save": "Gem", - "confirm_save": "Bekræft gem", + "overwrite_title": "Erstat dine visuelle regler?", + "overwrite_message": "Dette script indeholder ikke de regler, du har oprettet i den visuelle editor. Hvis du gemmer det, erstattes de, og det kan ikke fortrydes.", "validation_failed": "Valideringsanmodning mislykkedes" }, "rule_summary": { diff --git a/locales/de/common.json b/locales/de/common.json index c48ca51e2..0dad9198b 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2516,12 +2516,12 @@ "script_content": "Sieve-Skript", "valid": "Skript ist gültig", "invalid": "Skript enthält Fehler", - "save_warning": "Das Speichern überschreibt alle visuellen Regeln. Dies kann nicht rückgängig gemacht werden. Klicken Sie erneut auf Speichern zur Bestätigung.", "validating": "Wird validiert...", "validate": "Validieren", "cancel": "Abbrechen", "save": "Speichern", - "confirm_save": "Speichern bestätigen", + "overwrite_title": "Visuelle Regeln ersetzen?", + "overwrite_message": "Dieses Skript enthält die im visuellen Editor erstellten Regeln nicht mehr. Wenn Sie es speichern, ersetzt es diese Regeln. Das lässt sich nicht rückgängig machen.", "validation_failed": "Validierungsanfrage fehlgeschlagen" }, "rule_summary": { diff --git a/locales/en/common.json b/locales/en/common.json index 4329039cf..3ac81fd9e 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2523,12 +2523,12 @@ "script_content": "Sieve script", "valid": "Script is valid", "invalid": "Script has errors", - "save_warning": "Saving will overwrite any visual rules. This cannot be undone. Click Save again to confirm.", "validating": "Validating...", "validate": "Validate", "cancel": "Cancel", "save": "Save", - "confirm_save": "Save anyway", + "overwrite_title": "Replace your visual rules?", + "overwrite_message": "This script no longer holds the rules you made in the visual builder. Saving it replaces them, and this can't be undone.", "validation_failed": "Validation request failed" }, "rule_summary": { diff --git a/locales/es/common.json b/locales/es/common.json index 681722895..85c16acfd 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2516,12 +2516,12 @@ "script_content": "Script Sieve", "valid": "El script es válido", "invalid": "El script tiene errores", - "save_warning": "Guardar sobrescribirá todas las reglas visuales. Esta acción no se puede deshacer. Haga clic en Guardar de nuevo para confirmar.", "validating": "Validando...", "validate": "Validar", "cancel": "Cancelar", "save": "Guardar", - "confirm_save": "Confirmar guardado", + "overwrite_title": "¿Reemplazar las reglas visuales?", + "overwrite_message": "Este script ya no contiene las reglas que creaste en el editor visual. Al guardarlo se reemplazan y no se puede deshacer.", "validation_failed": "Error en la solicitud de validación" }, "rule_summary": { diff --git a/locales/et/common.json b/locales/et/common.json index 6677299c0..d192f473a 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -2523,12 +2523,12 @@ "script_content": "Sieve skript", "valid": "Skript on kehtiv", "invalid": "Skriptis on vigu", - "save_warning": "Salvestamine kirjutab kõik visuaalsed reeglid üle. Seda ei saa tagasi võtta. Kinnitamiseks klõpsa uuesti nuppu Salvesta.", "validating": "Valideerimine...", "validate": "Valideeri", "cancel": "Tühista", "save": "Salvesta", - "confirm_save": "Kinnita salvestamine", + "overwrite_title": "Kas asendada visuaalsed reeglid?", + "overwrite_message": "See skript ei sisalda enam visuaalses redaktoris loodud reegleid. Salvestamine asendab need ja seda ei saa tagasi võtta.", "validation_failed": "Valideerimispäring ebaõnnestus" }, "rule_summary": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 32557ba10..d4231bb19 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2523,12 +2523,12 @@ "script_content": "اسکریپت Sieve", "valid": "اسکریپت معتبر است", "invalid": "اسکریپت خطا دارد", - "save_warning": "ذخیره قوانین بصری را بازنویسی می‌کند. این عملیات قابل بازگشت نیست. برای تأیید دوباره ذخیره را کلیک کنید.", "validating": "در حال اعتبارسنجی...", "validate": "اعتبارسنجی", "cancel": "انصراف", "save": "ذخیره", - "confirm_save": "تأیید ذخیره", + "overwrite_title": "قوانین بصری جایگزین شوند؟", + "overwrite_message": "این اسکریپت دیگر قوانینی را که در ویرایشگر بصری ساخته‌اید ندارد. ذخیرهٔ آن جایگزین آن‌ها می‌شود و قابل بازگشت نیست.", "validation_failed": "درخواست اعتبارسنجی ناموفق بود" }, "rule_summary": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 45c46b169..6fe1b4638 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2516,12 +2516,12 @@ "script_content": "Script Sieve", "valid": "Le script est valide", "invalid": "Le script contient des erreurs", - "save_warning": "L'enregistrement écrasera toutes les règles visuelles. Cette action est irréversible. Cliquez à nouveau sur Enregistrer pour confirmer.", "validating": "Validation...", "validate": "Valider", "cancel": "Annuler", "save": "Enregistrer", - "confirm_save": "Confirmer l'enregistrement", + "overwrite_title": "Remplacer vos règles visuelles ?", + "overwrite_message": "Ce script ne contient plus les règles créées dans l'éditeur visuel. L'enregistrer les remplace, et c'est irréversible.", "validation_failed": "Échec de la requête de validation" }, "rule_summary": { diff --git a/locales/he/common.json b/locales/he/common.json index af15c6fe9..13e86d7ab 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2436,12 +2436,12 @@ "script_content": "תסריט מסננת", "valid": "הסקריפט תקף", "invalid": "בסקריפט יש שגיאות", - "save_warning": "שמירה תחליף את כל הכללים החזותיים. לא ניתן לבטל זאת. לחץ שוב על שמור כדי לאשר.", "validating": "מאמת...", "validate": "אימות", "cancel": "לְבַטֵל", "save": "שמור", - "confirm_save": "אשר את השמירה", + "overwrite_title": "להחליף את הכללים החזותיים?", + "overwrite_message": "הסקריפט הזה כבר לא מכיל את הכללים שיצרת בעורך החזותי. שמירה תחליף אותם, ואי אפשר לבטל זאת.", "validation_failed": "בקשת האימות נכשלה" }, "rule_summary": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 4aa36c35c..8155f7b83 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2519,12 +2519,12 @@ "script_content": "Sieve szkript", "valid": "A szkript érvényes", "invalid": "A szkript hibákat tartalmaz", - "save_warning": "A mentés felülír minden vizuális szabályt. Ez nem vonható vissza. Kattints újra a Mentés gombra a megerősítéshez.", "validating": "Ellenőrzés...", "validate": "Ellenőrzés", "cancel": "Mégse", "save": "Mentés", - "confirm_save": "Mentés megerősítése", + "overwrite_title": "Lecseréli a vizuális szabályokat?", + "overwrite_message": "Ez a szkript már nem tartalmazza a vizuális szerkesztőben létrehozott szabályokat. Mentéskor felülírja őket, és ez nem vonható vissza.", "validation_failed": "Ellenőrzési kérés sikertelen" }, "rule_summary": { diff --git a/locales/it/common.json b/locales/it/common.json index 4d5ec0030..41c2f8e3a 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2516,12 +2516,12 @@ "script_content": "Script Sieve", "valid": "Lo script è valido", "invalid": "Lo script contiene errori", - "save_warning": "Il salvataggio sovrascriverà tutte le regole visuali. Questa azione non può essere annullata. Fai clic su Salva di nuovo per confermare.", "validating": "Validazione...", "validate": "Valida", "cancel": "Annulla", "save": "Salva", - "confirm_save": "Conferma salvataggio", + "overwrite_title": "Sostituire le regole visive?", + "overwrite_message": "Questo script non contiene più le regole create nell'editor visivo. Salvandolo le sostituisci, e non si può annullare.", "validation_failed": "Richiesta di validazione non riuscita" }, "rule_summary": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 5d6d4771c..c8fdb02e0 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2516,12 +2516,12 @@ "script_content": "Sieveスクリプト", "valid": "スクリプトは有効です", "invalid": "スクリプトにエラーがあります", - "save_warning": "保存するとすべてのビジュアルルールが上書きされます。この操作は元に戻せません。もう一度「保存」をクリックして確認してください。", "validating": "検証中...", "validate": "検証", "cancel": "キャンセル", "save": "保存", - "confirm_save": "保存を確認", + "overwrite_title": "ビジュアルルールを置き換えますか?", + "overwrite_message": "このスクリプトには、ビジュアルエディターで作成したルールが含まれていません。保存するとそれらが置き換えられ、元に戻せません。", "validation_failed": "検証リクエストに失敗しました" }, "rule_summary": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 5f5e78516..1a79e3d3f 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2516,12 +2516,12 @@ "script_content": "Sieve 스크립트", "valid": "유효한 스크립트예요", "invalid": "스크립트에 오류가 있어요", - "save_warning": "저장하면 시각적 규칙이 덮어쓰기 되며, 이 작업은 되돌릴 수 없어요. 확인을 위해 한 번 더 저장을 눌러주세요.", "validating": "검증 중...", "validate": "검증", "cancel": "취소", "save": "저장", - "confirm_save": "저장 확인", + "overwrite_title": "시각적 규칙을 바꿀까요?", + "overwrite_message": "이 스크립트에는 시각적 편집기에서 만든 규칙이 더 이상 없어요. 저장하면 그 규칙이 바뀌며 되돌릴 수 없어요.", "validation_failed": "검증 요청에 실패했어요" }, "rule_summary": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 4abab7cdc..f8d63ea15 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2516,12 +2516,12 @@ "script_content": "Sieve skripts", "valid": "Skripts ir derīgs", "invalid": "Skriptā ir kļūdas", - "save_warning": "Saglabāšana pārrakstīs vizuālos noteikumus. To nevar atcelt. Nospiediet \"Saglabāt\" vēlreiz.", "validating": "Pārbauda...", "validate": "Pārbaudīt", "cancel": "Atcelt", "save": "Saglabāt", - "confirm_save": "Apstiprināt saglabāšanu", + "overwrite_title": "Aizstāt vizuālās kārtulas?", + "overwrite_message": "Šajā skriptā vairs nav vizuālajā redaktorā izveidoto kārtulu. Saglabājot tās tiks aizstātas, un to nevar atsaukt.", "validation_failed": "Pārbaudes pieprasījuma kļūda" }, "rule_summary": { diff --git a/locales/mn/common.json b/locales/mn/common.json index 6e29449b6..b77216919 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2523,12 +2523,12 @@ "script_content": "Шигшүүрийн скрипт", "valid": "Скрипт хүчинтэй", "invalid": "Script-д алдаа байна", - "save_warning": "Хадгалснаар ямар ч харааны дүрмийг дарж бичнэ. Үүнийг буцаах боломжгүй. Баталгаажуулахын тулд Save дахин дарна уу.", "validating": "Баталгаажуулж байна...", "validate": "Баталгаажуулах", "cancel": "Цуцлах", "save": "Хадгалах", - "confirm_save": "Хадгалахыг баталгаажуулах", + "overwrite_title": "Харааны дүрмүүдийг солих уу?", + "overwrite_message": "Энэ скриптэд харааны засварлагчид үүсгэсэн дүрмүүд байхгүй болсон. Хадгалбал тэдгээр солигдох бөгөөд буцаах боломжгүй.", "validation_failed": "Баталгаажуулах хүсэлт амжилтгүй боллоо" }, "rule_summary": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 316709528..dbad27796 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2523,12 +2523,12 @@ "script_content": "Sieve-skript", "valid": "Skriptet er gyldig", "invalid": "Skriptet har feil", - "save_warning": "Lagring overskriver eventuelle visuelle regler. Dette kan ikke angres. Klikk på «Lagre» igjen for å bekrefte.", "validating": "Validerer...", "validate": "Valider", "cancel": "Avbryt", "save": "Lagre", - "confirm_save": "Bekreft lagring", + "overwrite_title": "Erstatte de visuelle reglene?", + "overwrite_message": "Dette skriptet inneholder ikke lenger reglene du laget i den visuelle redigereren. Hvis du lagrer det, erstattes de, og det kan ikke angres.", "validation_failed": "Kunne ikke validere skriptet" }, "rule_summary": { diff --git a/locales/nl/common.json b/locales/nl/common.json index a9d8f202d..2e1b62a0e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2516,12 +2516,12 @@ "script_content": "Sieve-script", "valid": "Script is geldig", "invalid": "Script bevat fouten", - "save_warning": "Opslaan overschrijft alle visuele regels. Dit kan niet ongedaan worden gemaakt. Klik nogmaals op Opslaan om te bevestigen.", "validating": "Valideren...", "validate": "Valideren", "cancel": "Annuleren", "save": "Opslaan", - "confirm_save": "Opslaan bevestigen", + "overwrite_title": "Je visuele regels vervangen?", + "overwrite_message": "Dit script bevat de regels die je in de visuele editor hebt gemaakt niet meer. Opslaan vervangt ze, en dit kan niet ongedaan worden gemaakt.", "validation_failed": "Validatieverzoek mislukt" }, "rule_summary": { diff --git a/locales/pl/common.json b/locales/pl/common.json index 69c043ef9..fe8ec10fc 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2516,12 +2516,12 @@ "script_content": "Skrypt Sieve", "valid": "Skrypt jest poprawny", "invalid": "Skrypt zawiera błędy", - "save_warning": "Zapisanie nadpisze wszystkie reguły wizualne. Nie można tego cofnąć. Kliknij Zapisz ponownie, aby potwierdzić.", "validating": "Walidowanie...", "validate": "Waliduj", "cancel": "Anuluj", "save": "Zapisz", - "confirm_save": "Potwierdź zapis", + "overwrite_title": "Zastąpić reguły wizualne?", + "overwrite_message": "Ten skrypt nie zawiera już reguł utworzonych w edytorze wizualnym. Zapisanie go zastąpi je i nie można tego cofnąć.", "validation_failed": "Żądanie walidacji nie powiodło się" }, "rule_summary": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 34febc6cd..67af88f4d 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2516,12 +2516,12 @@ "script_content": "Script Sieve", "valid": "O script é válido", "invalid": "O script contém erros", - "save_warning": "Guardar irá substituir todas as regras visuais. Esta ação não pode ser desfeita. Clique em Guardar novamente para confirmar.", "validating": "A validar...", "validate": "Validar", "cancel": "Cancelar", "save": "Guardar", - "confirm_save": "Confirmar guardar", + "overwrite_title": "Substituir as regras visuais?", + "overwrite_message": "Este script não contém mais as regras criadas no editor visual. Ao salvá-lo, elas serão substituídas, e isso não pode ser desfeito.", "validation_failed": "Falha no pedido de validação" }, "rule_summary": { diff --git a/locales/ro/common.json b/locales/ro/common.json index c888e1c34..c32322aa7 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2523,12 +2523,12 @@ "script_content": "Sieve script", "valid": "Scriptul este valid", "invalid": "Scriptul conține erori", - "save_warning": "Salvarea va suprascrie orice reguli vizuale. Această acțiune nu poate fi anulată. Faceți clic din nou pe Salvare pentru a confirma.", "validating": "Se validează...", "validate": "Validați", "cancel": "Anulează", "save": "Salvare", - "confirm_save": "Confirmare Salvare", + "overwrite_title": "Înlocuiți regulile vizuale?", + "overwrite_message": "Acest script nu mai conține regulile create în editorul vizual. Salvarea lui le înlocuiește și nu poate fi anulată.", "validation_failed": "Solicitarea de validare a eșuat" }, "rule_summary": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 26248fc12..420a438b8 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2516,12 +2516,12 @@ "script_content": "Sieve-скрипт", "valid": "Скрипт корректен", "invalid": "Скрипт содержит ошибки", - "save_warning": "Сохранение перезапишет визуальные правила. Это нельзя отменить. Нажмите «Сохранить» ещё раз для подтверждения.", "validating": "Проверка...", "validate": "Проверить", "cancel": "Отмена", "save": "Сохранить", - "confirm_save": "Подтвердить сохранение", + "overwrite_title": "Заменить визуальные правила?", + "overwrite_message": "В этом скрипте больше нет правил, созданных в визуальном редакторе. При сохранении они будут заменены, и это нельзя отменить.", "validation_failed": "Ошибка запроса проверки" }, "rule_summary": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 5f78df7b6..21a9742ac 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2523,12 +2523,12 @@ "script_content": "Skript Sieve", "valid": "Skript je platný", "invalid": "Skript obsahuje chyby", - "save_warning": "Uloženie prepíše všetky vizuálne pravidlá. Túto akciu nie je možné vrátiť. Potvrďte opätovným kliknutím na Uložiť.", "validating": "Overovanie...", "validate": "Overiť", "cancel": "Zrušiť", "save": "Uložiť", - "confirm_save": "Potvrdiť uloženie", + "overwrite_title": "Nahradiť vizuálne pravidlá?", + "overwrite_message": "Tento skript už neobsahuje pravidlá, ktoré ste vytvorili vo vizuálnom editore. Uložením ich nahradíte a nedá sa to vrátiť späť.", "validation_failed": "Požiadavka na overenie zlyhala" }, "rule_summary": { diff --git a/locales/th/common.json b/locales/th/common.json index 191606baa..8b793cab4 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4702,12 +4702,12 @@ "script_content": "สคริปต์ Sieve", "valid": "สคริปต์ถูกต้อง", "invalid": "สคริปต์มีข้อผิดพลาด", - "save_warning": "การบันทึกจะเขียนทับกฎแบบภาพทั้งหมด การดำเนินการนี้ไม่สามารถยกเลิกได้ คลิกบันทึกอีกครั้งเพื่อยืนยัน", "validating": "กำลังตรวจสอบความถูกต้อง...", "validate": "ตรวจสอบความถูกต้อง", "cancel": "ยกเลิก", "save": "บันทึก", - "confirm_save": "ยืนยันการบันทึก", + "overwrite_title": "แทนที่กฎแบบภาพใช่ไหม", + "overwrite_message": "สคริปต์นี้ไม่มีกฎที่คุณสร้างไว้ในตัวแก้ไขแบบภาพแล้ว การบันทึกจะแทนที่กฎเหล่านั้น และเลิกทำไม่ได้", "validation_failed": "คำขอตรวจสอบความถูกต้องล้มเหลว" }, "rule_summary": { diff --git a/locales/tr/common.json b/locales/tr/common.json index ccb1b0544..1abc16551 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2516,12 +2516,12 @@ "script_content": "Sieve betiği", "valid": "Betik geçerli", "invalid": "Betikte hatalar var", - "save_warning": "Kaydetme işlemi tüm görsel kuralların üzerine yazacak. Bu geri alınamaz. Onaylamak için Kaydet'e tekrar tıklayın.", "validating": "Doğrulanıyor...", "validate": "Doğrula", "cancel": "İptal", "save": "Kaydet", - "confirm_save": "Kaydetmeyi Onayla", + "overwrite_title": "Görsel kurallar değiştirilsin mi?", + "overwrite_message": "Bu betik artık görsel düzenleyicide oluşturduğunuz kuralları içermiyor. Kaydederseniz bu kuralların yerini alır ve bu geri alınamaz.", "validation_failed": "Doğrulama isteği başarısız" }, "rule_summary": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 735d06df1..0e1c4272b 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2516,12 +2516,12 @@ "script_content": "Сито сценарій", "valid": "Сценарій дійсний", "invalid": "Скрипт містить помилки", - "save_warning": "Збереження перезапише будь-які візуальні правила. Це неможливо скасувати. Натисніть кнопку Зберегти ще раз для підтвердження.", "validating": "Перевірка...", "validate": "Перевірити", "cancel": "Скасувати", "save": "зберегти", - "confirm_save": "Підтвердити збереження", + "overwrite_title": "Замінити візуальні правила?", + "overwrite_message": "Цей скрипт більше не містить правил, створених у візуальному редакторі. Збереження замінить їх, і цю дію не можна скасувати.", "validation_failed": "Помилка запиту перевірки" }, "rule_summary": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 904bc88de..34c1c6fd9 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2523,12 +2523,12 @@ "script_content": "Sieve 指令碼", "valid": "指令碼有效", "invalid": "指令碼有錯誤", - "save_warning": "儲存會覆寫所有視覺化規則,且無法復原。請再次點擊「儲存」以確認。", "validating": "正在驗證…", "validate": "驗證", "cancel": "取消", "save": "儲存", - "confirm_save": "確認儲存", + "overwrite_title": "要取代視覺化規則嗎?", + "overwrite_message": "此指令碼已不包含您在視覺化編輯器中建立的規則。儲存後這些規則將被取代,且無法復原。", "validation_failed": "驗證請求失敗" }, "rule_summary": { diff --git a/locales/zh/common.json b/locales/zh/common.json index c0d17e304..c6571a778 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2516,12 +2516,12 @@ "script_content": "Sieve 脚本", "valid": "脚本有效", "invalid": "脚本有错误", - "save_warning": "保存将覆盖所有可视化规则,此操作无法撤销。再次点击\"保存\"以确认。", "validating": "正在验证...", "validate": "验证", "cancel": "取消", "save": "保存", - "confirm_save": "确认保存", + "overwrite_title": "替换可视化规则?", + "overwrite_message": "此脚本已不包含你在可视化编辑器中创建的规则。保存后它们将被替换,且无法撤销。", "validation_failed": "验证请求失败" }, "rule_summary": { 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 15/54] 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 cc46573524915caf4d748d81a0750ebe927a4a91 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:13:14 +0100 Subject: [PATCH 16/54] fix: say "filters saved", "identity created" and "templates exported" without "successfully" --- lib/__tests__/settings-wording.test.ts | 14 ++++++++++++++ locales/ar/common.json | 10 +++++----- locales/ca/common.json | 10 +++++----- locales/cs/common.json | 10 +++++----- locales/da/common.json | 10 +++++----- locales/de/common.json | 10 +++++----- locales/en/common.json | 8 ++++---- locales/es/common.json | 10 +++++----- locales/et/common.json | 10 +++++----- locales/fa/common.json | 10 +++++----- locales/fr/common.json | 10 +++++----- locales/he/common.json | 10 +++++----- locales/hu/common.json | 10 +++++----- locales/it/common.json | 10 +++++----- locales/ko/common.json | 4 ++-- locales/lv/common.json | 10 +++++----- locales/mn/common.json | 10 +++++----- locales/nl/common.json | 10 +++++----- locales/pl/common.json | 10 +++++----- locales/pt/common.json | 10 +++++----- locales/ro/common.json | 10 +++++----- locales/ru/common.json | 10 +++++----- locales/sk/common.json | 10 +++++----- locales/th/common.json | 10 +++++----- locales/tr/common.json | 10 +++++----- locales/uk/common.json | 10 +++++----- locales/zh-TW/common.json | 10 +++++----- locales/zh/common.json | 10 +++++----- 28 files changed, 145 insertions(+), 131 deletions(-) diff --git a/lib/__tests__/settings-wording.test.ts b/lib/__tests__/settings-wording.test.ts index 02a797347..25bddb1f9 100644 --- a/lib/__tests__/settings-wording.test.ts +++ b/lib/__tests__/settings-wording.test.ts @@ -45,6 +45,20 @@ describe('settings wording', () => { expect(offenders).toEqual([]); }); + // R9: a success toast states the result, without "successfully". + it('reports Settings saves without "successfully"', () => { + for (const key of [ + 'notifications.filters_saved', + 'settings.filters.saved', + 'notifications.identity_created', + 'notifications.identity_updated', + 'notifications.templates_exported', + ]) { + expect(text(key), key).not.toMatch(/successful|!/i); + } + expect(text('notifications.filters_saved')).toBe('Filters saved'); + }); + it('does not repeat the debug section title as its only row', () => { expect(text('settings.advanced.debug_mode.toggle')).not.toBe(text('settings.advanced.debug_mode.label')); }); diff --git a/locales/ar/common.json b/locales/ar/common.json index 2f558680b..83f3d75c0 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "تم وسم الرسالة", "emails_tagged": "تم وسم {count} رسالة", "tag_failed": "فشل الوسم", - "identity_created": "تم إنشاء الهوية بنجاح", - "identity_updated": "تم تحديث الهوية بنجاح", + "identity_created": "تم إنشاء الهوية", + "identity_updated": "تم تحديث الهوية", "identity_deleted": "تم حذف الهوية", "identity_set_primary": "تم تحديث الهوية الأساسية", "identity_create_failed": "فشل إنشاء الهوية: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "الهوية غير موجودة", "vacation_saved": "تم حفظ إعدادات الغياب", "vacation_save_failed": "فشل حفظ إعدادات الغياب", - "filters_saved": "تم حفظ المرشحات بنجاح", + "filters_saved": "تم حفظ المرشحات", "filters_save_failed": "فشل حفظ المرشحات", "filters_deleted": "تم حذف قاعدة المرشح", - "templates_exported": "تم تصدير القوالب بنجاح", + "templates_exported": "تم تصدير القوالب", "templates_imported": "{count, plural, one {تم استيراد قالب واحد} other {تم استيراد # قالب}}", "templates_import_errors": "تعذّر استيراد بعض القوالب", "templates_import_empty": "لم يتم العثور على قوالب في الملف", @@ -2416,7 +2416,7 @@ "validation_error": "النص يحتوي على أخطاء", "save": "حفظ", "saving": "جارٍ الحفظ...", - "saved": "تم حفظ المرشحات بنجاح", + "saved": "تم حفظ المرشحات", "save_failed": "فشل حفظ المرشحات", "loading": "جارٍ تحميل المرشحات...", "not_supported": "خادم بريدك لا يدعم مرشحات البريد.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 157df8e55..24d600073 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "Correu etiquetat", "emails_tagged": "{count} correus etiquetats", "tag_failed": "No s'ha pogut etiquetar", - "identity_created": "Identitat creada correctament", - "identity_updated": "Identitat actualitzada correctament", + "identity_created": "Identitat creada", + "identity_updated": "Identitat actualitzada", "identity_deleted": "Identitat suprimida", "identity_set_primary": "Identitat principal actualitzada", "identity_create_failed": "No s'ha pogut crear la identitat: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "No s'ha trobat la identitat", "vacation_saved": "S'ha desat la configuració d'absència", "vacation_save_failed": "No s'ha pogut desar la configuració d'absència", - "filters_saved": "Filtres desats correctament", + "filters_saved": "Filtres desats", "filters_save_failed": "No s'han pogut desar els filtres", "filters_deleted": "Regla de filtre suprimida", - "templates_exported": "Plantilles exportades correctament", + "templates_exported": "Plantilles exportades", "templates_imported": "{count, plural, one {# plantilla importada} other {# plantilles importades}}", "templates_import_errors": "Algunes plantilles no s'han pogut importar", "templates_import_empty": "No s'ha trobat cap plantilla al fitxer", @@ -2416,7 +2416,7 @@ "validation_error": "L'script té errors", "save": "Desa", "saving": "Desant...", - "saved": "Filtres desats correctament", + "saved": "Filtres desats", "save_failed": "No s'han pogut desar els filtres", "loading": "Carregant els filtres...", "not_supported": "El servidor de correu no admet filtres de correu.", diff --git a/locales/cs/common.json b/locales/cs/common.json index 7dd7ed2d0..5a47c65dd 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "Zpráva byla označena štítkem", "emails_tagged": "{count} zpráv bylo označeno štítkem", "tag_failed": "Označení štítkem selhalo", - "identity_created": "Identita úspěšně vytvořena", - "identity_updated": "Identita úspěšně aktualizována", + "identity_created": "Identita vytvořena", + "identity_updated": "Identita aktualizována", "identity_deleted": "Identita odstraněna", "identity_set_primary": "Hlavní identita byla aktualizována", "identity_create_failed": "Vytvoření identity selhalo: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identita nenalezena", "vacation_saved": "Nastavení nepřítomnosti bylo uloženo", "vacation_save_failed": "Uložení nastavení nepřítomnosti selhalo", - "filters_saved": "Filtry úspěšně uloženy", + "filters_saved": "Filtry uloženy", "filters_save_failed": "Uložení filtrů selhalo", "filters_deleted": "Pravidlo filtru bylo odstraněno", - "templates_exported": "Šablony úspěšně exportovány", + "templates_exported": "Šablony exportovány", "templates_imported": "{count, plural, one {Importována 1 šablona} few {Importovány # šablony} other {Importováno # šablon}}", "templates_import_errors": "Některé šablony se nepodařilo importovat", "templates_import_empty": "V souboru nebyly nalezeny žádné šablony", @@ -2409,7 +2409,7 @@ "validation_error": "Skript obsahuje chyby", "save": "Uložit", "saving": "Ukládání...", - "saved": "Filtry byly uloženy", + "saved": "Filtry uloženy", "save_failed": "Uložení filtrů selhalo", "loading": "Načítání filtrů...", "not_supported": "Váš poštovní server nepodporuje e-mailové filtry.", diff --git a/locales/da/common.json b/locales/da/common.json index 4ceb1001c..39b3577c3 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "E-mail tagget", "emails_tagged": "{count} e-mails tagget", "tag_failed": "Tagning mislykkedes", - "identity_created": "Identitet oprettet succesfuldt", - "identity_updated": "Identitet opdateret succesfuldt", + "identity_created": "Identitet oprettet", + "identity_updated": "Identitet opdateret", "identity_deleted": "Identitet slettet", "identity_set_primary": "Primær identitet opdateret", "identity_create_failed": "Kunne ikke oprette identitet: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identitet ikke fundet", "vacation_saved": "Fraværsindstillinger gemt", "vacation_save_failed": "Kunne ikke gemme fraværsindstillinger", - "filters_saved": "Filtre gemt succesfuldt", + "filters_saved": "Filtre gemt", "filters_save_failed": "Kunne ikke gemme filtre", "filters_deleted": "Filterregel slettet", - "templates_exported": "Skabeloner eksporteret succesfuldt", + "templates_exported": "Skabeloner eksporteret", "templates_imported": "{count, plural, one {# skabelon} other {# skabeloner}} importeret", "templates_import_errors": "Nogle skabeloner kunne ikke importeres", "templates_import_empty": "Ingen skabeloner fundet i filen", @@ -2412,7 +2412,7 @@ "validation_error": "Scriptet har fejl", "save": "Gem", "saving": "Gemmer...", - "saved": "Filtre gemt succesfuldt", + "saved": "Filtre gemt", "save_failed": "Kunne ikke gemme filtre", "loading": "Indlæser filtre...", "not_supported": "Din mailserver understøtter ikke e-mail-filtre.", diff --git a/locales/de/common.json b/locales/de/common.json index 0dad9198b..f674d50e4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "E-Mail markiert", "emails_tagged": "{count} E-Mails markiert", "tag_failed": "Markierung fehlgeschlagen", - "identity_created": "Identität erfolgreich erstellt", - "identity_updated": "Identität erfolgreich aktualisiert", + "identity_created": "Identität erstellt", + "identity_updated": "Identität aktualisiert", "identity_deleted": "Identität gelöscht", "identity_set_primary": "Primäre Identität aktualisiert", "identity_create_failed": "Identität erstellen fehlgeschlagen: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identität nicht gefunden", "vacation_saved": "Abwesenheits-Einstellungen gespeichert", "vacation_save_failed": "Fehler beim Speichern der Abwesenheits-Einstellungen", - "filters_saved": "Filter erfolgreich gespeichert", + "filters_saved": "Filter gespeichert", "filters_save_failed": "Fehler beim Speichern der Filter", "filters_deleted": "Filterregel gelöscht", - "templates_exported": "Vorlagen erfolgreich exportiert", + "templates_exported": "Vorlagen exportiert", "templates_imported": "{count, plural, one {# Vorlage importiert} other {# Vorlagen importiert}}", "templates_import_errors": "Einige Vorlagen konnten nicht importiert werden", "templates_import_empty": "Keine Vorlagen in der Datei gefunden", @@ -2409,7 +2409,7 @@ "validation_error": "Das Skript enthält Fehler", "save": "Speichern", "saving": "Wird gespeichert...", - "saved": "Filter erfolgreich gespeichert", + "saved": "Filter gespeichert", "save_failed": "Fehler beim Speichern der Filter", "loading": "Filter werden geladen...", "not_supported": "Ihr Mailserver unterstützt keine E-Mail-Filter.", diff --git a/locales/en/common.json b/locales/en/common.json index 3ac81fd9e..a52ffa489 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "Email tagged", "emails_tagged": "{count} emails tagged", "tag_failed": "Tagging failed", - "identity_created": "Identity created successfully", - "identity_updated": "Identity updated successfully", + "identity_created": "Identity created", + "identity_updated": "Identity updated", "identity_deleted": "Identity deleted", "identity_set_primary": "Primary identity updated", "identity_create_failed": "Failed to create identity: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identity not found", "vacation_saved": "Out of office settings saved", "vacation_save_failed": "Failed to save out of office settings", - "filters_saved": "Filters saved successfully", + "filters_saved": "Filters saved", "filters_save_failed": "Failed to save filters", "filters_deleted": "Filter rule deleted", - "templates_exported": "Templates exported successfully", + "templates_exported": "Templates exported", "templates_imported": "{count, plural, one {# template} other {# templates}} imported", "templates_import_errors": "Some templates could not be imported", "templates_import_empty": "No templates found in the file", diff --git a/locales/es/common.json b/locales/es/common.json index 85c16acfd..ea405fcf4 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "Correo etiquetado", "emails_tagged": "{count} correos etiquetados", "tag_failed": "Error al etiquetar", - "identity_created": "Identidad creada exitosamente", - "identity_updated": "Identidad actualizada exitosamente", + "identity_created": "Identidad creada", + "identity_updated": "Identidad actualizada", "identity_deleted": "Identidad eliminada", "identity_set_primary": "Identidad principal actualizada", "identity_create_failed": "Error al crear identidad: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identidad no encontrada", "vacation_saved": "Configuración de ausencia guardada", "vacation_save_failed": "Error al guardar la configuración de ausencia", - "filters_saved": "Filtros guardados correctamente", + "filters_saved": "Filtros guardados", "filters_save_failed": "Error al guardar los filtros", "filters_deleted": "Regla de filtrado eliminada", - "templates_exported": "Plantillas exportadas correctamente", + "templates_exported": "Plantillas exportadas", "templates_imported": "{count, plural, one {# plantilla importada} other {# plantillas importadas}}", "templates_import_errors": "Algunas plantillas no se pudieron importar", "templates_import_empty": "No se encontraron plantillas en el archivo", @@ -2409,7 +2409,7 @@ "validation_error": "El script tiene errores", "save": "Guardar", "saving": "Guardando...", - "saved": "Filtros guardados correctamente", + "saved": "Filtros guardados", "save_failed": "Error al guardar los filtros", "loading": "Cargando filtros...", "not_supported": "Su servidor de correo no admite filtros de correo electrónico.", diff --git a/locales/et/common.json b/locales/et/common.json index d192f473a..f10aef617 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "E-kiri märgistati", "emails_tagged": "{count} e-kirja märgistati", "tag_failed": "Märgistamine ebaõnnestus", - "identity_created": "Identiteet loodi edukalt", - "identity_updated": "Identiteet uuendati edukalt", + "identity_created": "Identiteet loodud", + "identity_updated": "Identiteet uuendatud", "identity_deleted": "Identiteet kustutati", "identity_set_primary": "Peamine identiteet uuendati", "identity_create_failed": "Identiteedi loomine ebaõnnestus: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identiteeti ei leitud", "vacation_saved": "Puhkusevastaja seaded salvestati", "vacation_save_failed": "Puhkusevastaja seadete salvestamine ebaõnnestus", - "filters_saved": "Filtrid salvestati edukalt", + "filters_saved": "Filtrid salvestatud", "filters_save_failed": "Filtrite salvestamine ebaõnnestus", "filters_deleted": "Filtrireegel kustutati", - "templates_exported": "Mallid eksporditi edukalt", + "templates_exported": "Mallid eksporditud", "templates_imported": "{count, plural, one {# mall} other {# malli}} imporditi", "templates_import_errors": "Mõnda malli ei õnnestunud importida", "templates_import_empty": "Failist ei leitud ühtegi malli", @@ -2416,7 +2416,7 @@ "validation_error": "Skriptis on vigu", "save": "Salvesta", "saving": "Salvestamine...", - "saved": "Filtrid salvestatud edukalt", + "saved": "Filtrid salvestatud", "save_failed": "Filtrite salvestamine ebaõnnestus", "loading": "Filtrite laadimine...", "not_supported": "Sinu meiliserver ei toeta e-posti filtreid.", diff --git a/locales/fa/common.json b/locales/fa/common.json index d4231bb19..cadc5cfc5 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "ایمیل برچسب‌گذاری شد", "emails_tagged": "{count} ایمیل برچسب‌گذاری شد", "tag_failed": "برچسب‌گذاری ناموفق بود", - "identity_created": "هویت با موفقیت ایجاد شد", - "identity_updated": "هویت با موفقیت به‌روزرسانی شد", + "identity_created": "هویت ایجاد شد", + "identity_updated": "هویت به‌روزرسانی شد", "identity_deleted": "هویت حذف شد", "identity_set_primary": "هویت اصلی به‌روزرسانی شد", "identity_create_failed": "ایجاد هویت ناموفق بود: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "هویت یافت نشد", "vacation_saved": "تنظیمات غیبت ذخیره شد", "vacation_save_failed": "ذخیره تنظیمات غیبت ناموفق بود", - "filters_saved": "فیلترها با موفقیت ذخیره شدند", + "filters_saved": "فیلترها ذخیره شدند", "filters_save_failed": "ذخیره فیلترها ناموفق بود", "filters_deleted": "قانون فیلتر حذف شد", - "templates_exported": "قالب‌ها با موفقیت خروجی گرفته شدند", + "templates_exported": "قالب‌ها خروجی گرفته شدند", "templates_imported": "{count, plural, one {# قالب} other {# قالب}} وارد شد", "templates_import_errors": "برخی قالب‌ها وارد نشدند", "templates_import_empty": "قالبی در فایل یافت نشد", @@ -2416,7 +2416,7 @@ "validation_error": "اسکریپت خطا دارد", "save": "ذخیره", "saving": "در حال ذخیره...", - "saved": "فیلترها با موفقیت ذخیره شدند", + "saved": "فیلترها ذخیره شدند", "save_failed": "ذخیره فیلترها ناموفق بود", "loading": "در حال بارگذاری فیلترها...", "not_supported": "سرور ایمیل شما از فیلترهای ایمیل پشتیبانی نمی‌کند.", diff --git a/locales/fr/common.json b/locales/fr/common.json index 6fe1b4638..ff049af3a 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "E-mail étiqueté", "emails_tagged": "{count} e-mails étiquetés", "tag_failed": "Échec de l'étiquetage", - "identity_created": "Identité créée avec succès", - "identity_updated": "Identité mise à jour avec succès", + "identity_created": "Identité créée", + "identity_updated": "Identité mise à jour", "identity_deleted": "Identité supprimée", "identity_set_primary": "Identité principale mise à jour", "identity_create_failed": "Échec de la création de l'identité: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identité introuvable", "vacation_saved": "Paramètres d'absence enregistrés", "vacation_save_failed": "Échec de l'enregistrement des paramètres d'absence", - "filters_saved": "Filtres enregistrés avec succès", + "filters_saved": "Filtres enregistrés", "filters_save_failed": "Échec de l'enregistrement des filtres", "filters_deleted": "Règle de filtrage supprimée", - "templates_exported": "Modèles exportés avec succès", + "templates_exported": "Modèles exportés", "templates_imported": "{count, plural, one {# modèle importé} other {# modèles importés}}", "templates_import_errors": "Certains modèles n'ont pas pu être importés", "templates_import_empty": "Aucun modèle trouvé dans le fichier", @@ -2409,7 +2409,7 @@ "validation_error": "Le script contient des erreurs", "save": "Enregistrer", "saving": "Enregistrement...", - "saved": "Filtres enregistrés avec succès", + "saved": "Filtres enregistrés", "save_failed": "Échec de l'enregistrement des filtres", "loading": "Chargement des filtres...", "not_supported": "Votre serveur de messagerie ne prend pas en charge les filtres de courrier.", diff --git a/locales/he/common.json b/locales/he/common.json index 13e86d7ab..7b6b96707 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -976,8 +976,8 @@ "email_tagged": "אימייל מתויג", "emails_tagged": "דוא\"ל{count}מתויג", "tag_failed": "התיוג נכשל", - "identity_created": "הזהות נוצרה בהצלחה", - "identity_updated": "הזהות עודכנה בהצלחה", + "identity_created": "הזהות נוצרה", + "identity_updated": "הזהות עודכנה", "identity_deleted": "הזהות נמחקה", "identity_set_primary": "הזהות הראשית עודכנה", "identity_create_failed": "יצירת זהות נכשלה:{error}", @@ -987,10 +987,10 @@ "identity_not_found": "זהות לא נמצאה", "vacation_saved": "הגדרות ההיעדרות נשמרו", "vacation_save_failed": "שמירת הגדרות ההיעדרות נכשלה", - "filters_saved": "המסננים נשמרו בהצלחה", + "filters_saved": "המסננים נשמרו", "filters_save_failed": "שמירת המסננים נכשלה", "filters_deleted": "כלל הסינון נמחק", - "templates_exported": "תבניות יוצאו בהצלחה", + "templates_exported": "התבניות יוצאו", "templates_imported": "יובאו {count, plural, one {תבנית אחת} other {# תבניות}}", "templates_import_errors": "לא ניתן לייבא חלק מהתבניות", "templates_import_empty": "לא נמצאו תבניות בקובץ", @@ -2331,7 +2331,7 @@ "validation_error": "בסקריפט יש שגיאות", "save": "שמירה", "saving": "שומר...", - "saved": "המסננים נשמרו בהצלחה", + "saved": "המסננים נשמרו", "save_failed": "שמירת המסננים נכשלה", "loading": "טוען מסננים...", "not_supported": "שרת הדואר שלך אינו תומך במסנני דואר אלקטרוני.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 8155f7b83..46ec89dd6 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "E-mail címkézve", "emails_tagged": "{count} e-mail címkézve", "tag_failed": "Címkézés sikertelen", - "identity_created": "Identitás sikeresen létrehozva", - "identity_updated": "Identitás sikeresen frissítve", + "identity_created": "Identitás létrehozva", + "identity_updated": "Identitás frissítve", "identity_deleted": "Identitás törölve", "identity_set_primary": "Elsődleges identitás frissítve", "identity_create_failed": "Nem sikerült létrehozni az identitást: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identitás nem található", "vacation_saved": "Távolléti beállítások elmentve", "vacation_save_failed": "Nem sikerült menteni a távolléti beállításokat", - "filters_saved": "Szűrők sikeresen elmentve", + "filters_saved": "Szűrők elmentve", "filters_save_failed": "Nem sikerült menteni a szűrőket", "filters_deleted": "Szűrő szabály törölve", - "templates_exported": "Sablonok sikeresen exportálva", + "templates_exported": "Sablonok exportálva", "templates_imported": "{count, plural, one {# sablon} other {# sablon}} importálva", "templates_import_errors": "Néhány sablon importálása sikertelen", "templates_import_empty": "Nem található sablon a fájlban", @@ -2412,7 +2412,7 @@ "validation_error": "A szkript hibákat tartalmaz", "save": "Mentés", "saving": "Mentés...", - "saved": "Szűrők sikeresen elmentve", + "saved": "Szűrők elmentve", "save_failed": "Nem sikerült menteni a szűrőket", "loading": "Szűrők betöltése...", "not_supported": "A levelezőszervered nem támogatja az e-mail szűrőket.", diff --git a/locales/it/common.json b/locales/it/common.json index 41c2f8e3a..1ae4ac6bd 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "Email etichettata", "emails_tagged": "{count} email etichettate", "tag_failed": "Etichettatura fallita", - "identity_created": "Identità creata con successo", - "identity_updated": "Identità aggiornata con successo", + "identity_created": "Identità creata", + "identity_updated": "Identità aggiornata", "identity_deleted": "Identità eliminata", "identity_set_primary": "Identità principale aggiornata", "identity_create_failed": "Impossibile creare l'identità: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identità non trovata", "vacation_saved": "Impostazioni di assenza salvate", "vacation_save_failed": "Impossibile salvare le impostazioni di assenza", - "filters_saved": "Filtri salvati con successo", + "filters_saved": "Filtri salvati", "filters_save_failed": "Impossibile salvare i filtri", "filters_deleted": "Regola di filtraggio eliminata", - "templates_exported": "Modelli esportati con successo", + "templates_exported": "Modelli esportati", "templates_imported": "{count, plural, one {# modello importato} other {# modelli importati}}", "templates_import_errors": "Alcuni modelli non sono stati importati", "templates_import_empty": "Nessun modello trovato nel file", @@ -2409,7 +2409,7 @@ "validation_error": "Lo script contiene errori", "save": "Salva", "saving": "Salvataggio...", - "saved": "Filtri salvati con successo", + "saved": "Filtri salvati", "save_failed": "Impossibile salvare i filtri", "loading": "Caricamento filtri...", "not_supported": "Il tuo server di posta non supporta i filtri email.", diff --git a/locales/ko/common.json b/locales/ko/common.json index 1a79e3d3f..0d47cdbdd 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1025,7 +1025,7 @@ "filters_saved": "필터가 저장되었어요", "filters_save_failed": "필터 저장에 실패했어요", "filters_deleted": "필터 규칙이 삭제되었어요", - "templates_exported": "템플릿을 성공적으로 내보냈어요", + "templates_exported": "템플릿을 내보냈어요", "templates_imported": "{count, plural, other {#개의 템플릿}}을 가져왔어요", "templates_import_errors": "일부 템플릿을 가져오지 못했어요", "templates_import_empty": "파일에서 템플릿을 찾을 수 없어요", @@ -2409,7 +2409,7 @@ "validation_error": "스크립트에 오류가 있어요", "save": "저장", "saving": "저장 중...", - "saved": "필터가 성공적으로 저장되었어요", + "saved": "필터가 저장되었어요", "save_failed": "필터를 저장하지 못했어요", "loading": "필터를 불러오는 중...", "not_supported": "사용 중인 메일 서버는 이메일 필터를 지원하지 않아요.", diff --git a/locales/lv/common.json b/locales/lv/common.json index f8d63ea15..576cf389f 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "Vēstulei pievienots tags", "emails_tagged": "{count} vēstulēm pievienots tags", "tag_failed": "Tagu pievienošanas kļūda", - "identity_created": "Identitāte veiksmīgi izveidota", - "identity_updated": "Identitāte veiksmīgi atjaunināta", + "identity_created": "Identitāte izveidota", + "identity_updated": "Identitāte atjaunināta", "identity_deleted": "Identitāte izdzēsta", "identity_set_primary": "Galvenā identitāte atjaunināta", "identity_create_failed": "Neizdevās izveidot identitāti: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identitāte netika atrasta", "vacation_saved": "Prombūtnes iestatījumi saglabāti", "vacation_save_failed": "Neizdevās saglabāt prombūtnes iestatījumus", - "filters_saved": "Filtri veiksmīgi saglabāti", + "filters_saved": "Filtri saglabāti", "filters_save_failed": "Neizdevās saglabāt filtrus", "filters_deleted": "Filtra nosacījums izdzēsts", - "templates_exported": "Veidnes veiksmīgi eksportētas", + "templates_exported": "Veidnes eksportētas", "templates_imported": "Importētas {count, plural, one {# veidne} other {# veidnes}}", "templates_import_errors": "Dažas veidnes neizdevās importēt", "templates_import_empty": "Failā netika atrastas veidnes", @@ -2409,7 +2409,7 @@ "validation_error": "Skriptā ir kļūdas", "save": "Saglabāt", "saving": "Saglabā...", - "saved": "Filtri veiksmīgi saglabāti", + "saved": "Filtri saglabāti", "save_failed": "Neizdevās saglabāt filtrus", "loading": "Ielādē filtrus...", "not_supported": "Jūsu pasta serveris neatbalsta filtrus.", diff --git a/locales/mn/common.json b/locales/mn/common.json index b77216919..fe6f07f32 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "И-мэйл хаяглагдсан", "emails_tagged": "{count} мэйл хаяглагдсан", "tag_failed": "Табга хийж чадсангүй", - "identity_created": "Таниулбарыг амжилттай үүсгэлээ", - "identity_updated": "Таниулбарыг амжилттай шинэчиллээ", + "identity_created": "Таниулбарыг үүсгэлээ", + "identity_updated": "Таниулбарыг шинэчиллээ", "identity_deleted": "Таниулбарыг устгалаа", "identity_set_primary": "Үндсэн таниулбар шинэчлэгдсэн", "identity_create_failed": "Таниулбар үүсгэж чадсангүй: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Таниулбар олдсонгүй", "vacation_saved": "Эзгүй байх тохиргоог хадгалсан", "vacation_save_failed": "Эзгүй байх тохиргоог хадгалж чадсангүй", - "filters_saved": "Шүүлтүүрийг амжилттай хадгаллаа", + "filters_saved": "Шүүлтүүрийг хадгаллаа", "filters_save_failed": "Шүүлтүүрийг хадгалж чадсангүй", "filters_deleted": "Шүүлтүүрийн дүрмийг устгасан", - "templates_exported": "Загваруудыг амжилттай экспортоллоо", + "templates_exported": "Загваруудыг экспортоллоо", "templates_imported": "{count, plural, one {# загвар} other {# загвар}}импортолсон", "templates_import_errors": "Зарим загварыг импортлох боломжгүй байна", "templates_import_empty": "Файлд ямар ч загвар олдсонгүй", @@ -2416,7 +2416,7 @@ "validation_error": "Script алдаатай байна", "save": "Хадгалах", "saving": "Хадгалж байна...", - "saved": "Шүүлтүүрийг амжилттай хадгаллаа", + "saved": "Шүүлтүүрийг хадгаллаа", "save_failed": "Шүүлтүүрийг хадгалж чадсангүй", "loading": "Шүүлтүүрийг ачаалж байна...", "not_supported": "Таны мэйл сервер имэйл шүүлтүүрийг дэмждэггүй.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 2e1b62a0e..666ab2471 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "E-mail gelabeld", "emails_tagged": "{count} e-mails gelabeld", "tag_failed": "Labelen mislukt", - "identity_created": "Identiteit succesvol aangemaakt", - "identity_updated": "Identiteit succesvol bijgewerkt", + "identity_created": "Identiteit aangemaakt", + "identity_updated": "Identiteit bijgewerkt", "identity_deleted": "Identiteit verwijderd", "identity_set_primary": "Primaire identiteit bijgewerkt", "identity_create_failed": "Kan identiteit niet aanmaken: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identiteit niet gevonden", "vacation_saved": "Afwezigheidsinstellingen opgeslagen", "vacation_save_failed": "Kan afwezigheidsinstellingen niet opslaan", - "filters_saved": "Filters succesvol opgeslagen", + "filters_saved": "Filters opgeslagen", "filters_save_failed": "Filters opslaan mislukt", "filters_deleted": "Filterregel verwijderd", - "templates_exported": "Sjablonen succesvol geëxporteerd", + "templates_exported": "Sjablonen geëxporteerd", "templates_imported": "{count, plural, one {# sjabloon geïmporteerd} other {# sjablonen geïmporteerd}}", "templates_import_errors": "Sommige sjablonen konden niet worden geïmporteerd", "templates_import_empty": "Geen sjablonen gevonden in het bestand", @@ -2409,7 +2409,7 @@ "validation_error": "Het script bevat fouten", "save": "Opslaan", "saving": "Opslaan...", - "saved": "Filters succesvol opgeslagen", + "saved": "Filters opgeslagen", "save_failed": "Filters opslaan mislukt", "loading": "Filters laden...", "not_supported": "Uw mailserver ondersteunt geen e-mailfilters.", diff --git a/locales/pl/common.json b/locales/pl/common.json index fe8ec10fc..cd950fcc8 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "Wiadomość oznaczona etykietą", "emails_tagged": "{count} wiadomości oznaczonych etykietą", "tag_failed": "Oznaczanie etykietą nie powiodło się", - "identity_created": "Tożsamość utworzona pomyślnie", - "identity_updated": "Tożsamość zaktualizowana pomyślnie", + "identity_created": "Tożsamość utworzona", + "identity_updated": "Tożsamość zaktualizowana", "identity_deleted": "Tożsamość usunięta", "identity_set_primary": "Główna tożsamość zaktualizowana", "identity_create_failed": "Nie udało się utworzyć tożsamości: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Nie znaleziono tożsamości", "vacation_saved": "Ustawienia nieobecności zapisane", "vacation_save_failed": "Nie udało się zapisać ustawień nieobecności", - "filters_saved": "Filtry zapisane pomyślnie", + "filters_saved": "Filtry zapisane", "filters_save_failed": "Nie udało się zapisać filtrów", "filters_deleted": "Reguła filtra usunięta", - "templates_exported": "Szablony wyeksportowane pomyślnie", + "templates_exported": "Szablony wyeksportowane", "templates_imported": "{count, plural, one {# szablon zaimportowany} other {# szablonów zaimportowanych}}", "templates_import_errors": "Niektóre szablony nie mogły zostać zaimportowane", "templates_import_empty": "Nie znaleziono szablonów w pliku", @@ -2409,7 +2409,7 @@ "validation_error": "Skrypt zawiera błędy", "save": "Zapisz", "saving": "Zapisywanie...", - "saved": "Filtry zostały zapisane", + "saved": "Filtry zapisane", "save_failed": "Nie udało się zapisać filtrów", "loading": "Ładowanie filtrów...", "not_supported": "Twój serwer pocztowy nie obsługuje filtrów wiadomości e-mail.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 67af88f4d..31b7da9b2 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "E-mail etiquetado", "emails_tagged": "{count} e-mails etiquetados", "tag_failed": "Falha ao etiquetar", - "identity_created": "Identidade criada com sucesso", - "identity_updated": "Identidade atualizada com sucesso", + "identity_created": "Identidade criada", + "identity_updated": "Identidade atualizada", "identity_deleted": "Identidade excluída", "identity_set_primary": "Identidade principal atualizada", "identity_create_failed": "Falha ao criar identidade: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identidade não encontrada", "vacation_saved": "Configurações de ausência salvas", "vacation_save_failed": "Falha ao salvar configurações de ausência", - "filters_saved": "Filtros salvos com sucesso", + "filters_saved": "Filtros salvos", "filters_save_failed": "Falha ao salvar os filtros", "filters_deleted": "Regra de filtragem eliminada", - "templates_exported": "Modelos exportados com sucesso", + "templates_exported": "Modelos exportados", "templates_imported": "{count, plural, one {# modelo importado} other {# modelos importados}}", "templates_import_errors": "Alguns modelos não puderam ser importados", "templates_import_empty": "Nenhum modelo encontrado no arquivo", @@ -2409,7 +2409,7 @@ "validation_error": "O script contém erros", "save": "Salvar", "saving": "Salvando...", - "saved": "Filtros salvos com sucesso", + "saved": "Filtros salvos", "save_failed": "Falha ao salvar os filtros", "loading": "Carregando filtros...", "not_supported": "Seu servidor de e-mail não suporta filtros de e-mail.", diff --git a/locales/ro/common.json b/locales/ro/common.json index c32322aa7..2ff1964e4 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "E-mail etichetat", "emails_tagged": "{count} e-mailuri etichetate", "tag_failed": "Etichetarea a eșuat", - "identity_created": "Identitatea a fost creată cu succes", - "identity_updated": "Identitatea a fost actualizată cu succes", + "identity_created": "Identitatea a fost creată", + "identity_updated": "Identitatea a fost actualizată", "identity_deleted": "Identitate ștearsă", "identity_set_primary": "Identitate principală actualizată", "identity_create_failed": "Nu s-a putut crea identitatea: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identitate neidentificată", "vacation_saved": "Setările de absență au fost salvate", "vacation_save_failed": "Nu s-au putut salva setările de absență", - "filters_saved": "Filtrele au fost salvate cu succes", + "filters_saved": "Filtrele au fost salvate", "filters_save_failed": "Nu s-au putut salva filtrele", "filters_deleted": "Regula de filtrare a fost ștersă", - "templates_exported": "Șabloanele au fost exportate cu succes", + "templates_exported": "Șabloanele au fost exportate", "templates_imported": "{count, plural, one {# șablon} few {# șabloane} other {# de șabloane}} importat", "templates_import_errors": "Unele șabloane nu au putut fi importate", "templates_import_empty": "Nu s-au găsit șabloane în fișier", @@ -2416,7 +2416,7 @@ "validation_error": "Scriptul conține erori", "save": "Salvare", "saving": "Se salvează...", - "saved": "Filtrele au fost salvate cu succes", + "saved": "Filtrele au fost salvate", "save_failed": "Nu s-au putut salva filtrele", "loading": "Se încarcă filtrele...", "not_supported": "Serverul dvs. de e-mail nu acceptă filtre de e-mail.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 420a438b8..42dd04851 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "Письмо помечено тегом", "emails_tagged": "{count} писем помечено тегом", "tag_failed": "Ошибка тегирования", - "identity_created": "Удостоверение успешно создано", - "identity_updated": "Удостоверение успешно обновлено", + "identity_created": "Удостоверение создано", + "identity_updated": "Удостоверение обновлено", "identity_deleted": "Удостоверение удалено", "identity_set_primary": "Основное удостоверение обновлено", "identity_create_failed": "Не удалось создать удостоверение: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Удостоверение не найдено", "vacation_saved": "Настройки отсутствия сохранены", "vacation_save_failed": "Не удалось сохранить настройки отсутствия", - "filters_saved": "Фильтры успешно сохранены", + "filters_saved": "Фильтры сохранены", "filters_save_failed": "Не удалось сохранить фильтры", "filters_deleted": "Правило фильтра удалено", - "templates_exported": "Шаблоны успешно экспортированы", + "templates_exported": "Шаблоны экспортированы", "templates_imported": "{count, plural, one {# шаблон} other {# шаблонов}} импортировано", "templates_import_errors": "Некоторые шаблоны не удалось импортировать", "templates_import_empty": "В файле не найдено шаблонов", @@ -2409,7 +2409,7 @@ "validation_error": "Скрипт содержит ошибки", "save": "Сохранить", "saving": "Сохранение...", - "saved": "Фильтры успешно сохранены", + "saved": "Фильтры сохранены", "save_failed": "Не удалось сохранить фильтры", "loading": "Загрузка фильтров...", "not_supported": "Ваш почтовый сервер не поддерживает фильтры.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 21a9742ac..f58a0f303 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "E-mail bol označený štítkom", "emails_tagged": "{count} e-mailov bolo označených štítkom", "tag_failed": "Označovanie štítkom zlyhalo", - "identity_created": "Identita bola úspešne vytvorená", - "identity_updated": "Identita bola úspešne aktualizovaná", + "identity_created": "Identita bola vytvorená", + "identity_updated": "Identita bola aktualizovaná", "identity_deleted": "Identita bola odstránená", "identity_set_primary": "Hlavná identita bola aktualizovaná", "identity_create_failed": "Vytvorenie identity zlyhalo: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Identita sa nenašla", "vacation_saved": "Nastavenia neprítomnosti boli uložené", "vacation_save_failed": "Uloženie nastavení neprítomnosti zlyhalo", - "filters_saved": "Filtre boli úspešne uložené", + "filters_saved": "Filtre uložené", "filters_save_failed": "Uloženie filtrov zlyhalo", "filters_deleted": "Pravidlo filtra bolo odstránené", - "templates_exported": "Šablóny boli úspešne exportované", + "templates_exported": "Šablóny boli exportované", "templates_imported": "{count, plural, one {# šablóna} other {# šablón}} importovaných", "templates_import_errors": "Niektoré šablóny sa nepodarilo importovať", "templates_import_empty": "V súbore neboli nájdené žiadne šablóny", @@ -2416,7 +2416,7 @@ "validation_error": "Skript obsahuje chyby", "save": "Uložiť", "saving": "Ukladanie...", - "saved": "Filtre boli uložené", + "saved": "Filtre uložené", "save_failed": "Uloženie filtrov zlyhalo", "loading": "Načítavanie filtrov...", "not_supported": "Váš poštový server nepodporuje e-mailové filtre.", diff --git a/locales/th/common.json b/locales/th/common.json index 8b793cab4..181a7ec8e 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -434,8 +434,8 @@ "email_tagged": "เพิ่มป้ายกำกับแล้ว", "emails_tagged": "{count} อีเมลถูกเพิ่มป้ายกำกับแล้ว", "tag_failed": "เพิ่มป้ายกำกับไม่สำเร็จ", - "identity_created": "สร้างข้อมูลประจำตัวเรียบร้อยแล้ว", - "identity_updated": "อัปเดตข้อมูลประจำตัวเรียบร้อยแล้ว", + "identity_created": "สร้างข้อมูลประจำตัวแล้ว", + "identity_updated": "อัปเดตข้อมูลประจำตัวแล้ว", "identity_deleted": "ลบข้อมูลประจำตัวแล้ว", "identity_set_primary": "ตั้งเป็นข้อมูลประจำตัวหลักแล้ว", "identity_create_failed": "สร้างข้อมูลประจำตัวไม่สำเร็จ: {error}", @@ -445,10 +445,10 @@ "identity_not_found": "ไม่พบข้อมูลประจำตัว", "vacation_saved": "บันทึกการตั้งค่าตอบกลับอัตโนมัติแล้ว", "vacation_save_failed": "บันทึกการตั้งค่าตอบกลับอัตโนมัติไม่สำเร็จ", - "filters_saved": "บันทึกตัวกรองเรียบร้อยแล้ว", + "filters_saved": "บันทึกตัวกรองแล้ว", "filters_save_failed": "บันทึกตัวกรองไม่สำเร็จ", "filters_deleted": "ลบกฎตัวกรองแล้ว", - "templates_exported": "ส่งออกแม่แบบข้อความเรียบร้อยแล้ว", + "templates_exported": "ส่งออกแม่แบบข้อความแล้ว", "templates_imported": "{count, plural, other {นำเข้าแม่แบบข้อความแล้ว # รายการ}}", "templates_import_errors": "ไม่สามารถนำเข้าแม่แบบบางรายการได้", "templates_import_empty": "ไม่พบแม่แบบข้อความในไฟล์", @@ -4595,7 +4595,7 @@ "validation_error": "สคริปต์มีข้อผิดพลาด", "save": "บันทึก", "saving": "กำลังบันทึก...", - "saved": "บันทึกตัวกรองเรียบร้อยแล้ว", + "saved": "บันทึกตัวกรองแล้ว", "save_failed": "บันทึกตัวกรองไม่สำเร็จ", "loading": "กำลังโหลดตัวกรอง...", "not_supported": "เมลเซิร์ฟเวอร์ของคุณไม่รองรับตัวกรองอีเมล", diff --git a/locales/tr/common.json b/locales/tr/common.json index 1abc16551..eb80b9fc9 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "E-posta etiketlendi", "emails_tagged": "{count} e-posta etiketlendi", "tag_failed": "Etiketleme başarısız", - "identity_created": "Kimlik başarıyla oluşturuldu", - "identity_updated": "Kimlik başarıyla güncellendi", + "identity_created": "Kimlik oluşturuldu", + "identity_updated": "Kimlik güncellendi", "identity_deleted": "Kimlik silindi", "identity_set_primary": "Birincil kimlik güncellendi", "identity_create_failed": "Kimlik oluşturulamadı: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Kimlik bulunamadı", "vacation_saved": "Ofis dışında ayarları kaydedildi", "vacation_save_failed": "Ofis dışında ayarları kaydedilemedi", - "filters_saved": "Filtreler başarıyla kaydedildi", + "filters_saved": "Filtreler kaydedildi", "filters_save_failed": "Filtreler kaydedilemedi", "filters_deleted": "Filtre kuralı silindi", - "templates_exported": "Şablonlar başarıyla dışa aktarıldı", + "templates_exported": "Şablonlar dışa aktarıldı", "templates_imported": "{count, plural, one {# şablon} other {# şablon}} içe aktarıldı", "templates_import_errors": "Bazı şablonlar içe aktarılamadı", "templates_import_empty": "Dosyada şablon bulunamadı", @@ -2409,7 +2409,7 @@ "validation_error": "Betikte hatalar var", "save": "Kaydet", "saving": "Kaydediliyor...", - "saved": "Filtreler başarıyla kaydedildi", + "saved": "Filtreler kaydedildi", "save_failed": "Filtreler kaydedilemedi", "loading": "Filtreler yükleniyor...", "not_supported": "Posta sunucunuz e-posta filtrelerini desteklemiyor.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 0e1c4272b..d1162c2ea 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "Електронна пошта позначена тегами", "emails_tagged": "{count} електронних листів позначено тегами", "tag_failed": "Помилка тегування", - "identity_created": "Ідентичність створено успішно", - "identity_updated": "Ідентичність успішно оновлено", + "identity_created": "Ідентичність створено", + "identity_updated": "Ідентичність оновлено", "identity_deleted": "Ідентичність видалено", "identity_set_primary": "Основну особу оновлено", "identity_create_failed": "Не вдалося створити ідентифікатор: {error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "Особа не знайдена", "vacation_saved": "Налаштування відсутності збережено", "vacation_save_failed": "Не вдалося зберегти налаштування відсутності", - "filters_saved": "Фільтри успішно збережено", + "filters_saved": "Фільтри збережено", "filters_save_failed": "Не вдалося зберегти фільтри", "filters_deleted": "Правило фільтра видалено", - "templates_exported": "Шаблони успішно експортовано", + "templates_exported": "Шаблони експортовано", "templates_imported": "{count, plural, one {# template} other {# templates}} імпортовано", "templates_import_errors": "Деякі шаблони не вдалося імпортувати", "templates_import_empty": "У файлі не знайдено шаблонів", @@ -2409,7 +2409,7 @@ "validation_error": "Скрипт містить помилки", "save": "зберегти", "saving": "Збереження...", - "saved": "Фільтри успішно збережено", + "saved": "Фільтри збережено", "save_failed": "Не вдалося зберегти фільтри", "loading": "Завантаження фільтрів...", "not_supported": "Ваш поштовий сервер не підтримує фільтри електронної пошти.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 34c1c6fd9..16779b94c 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "郵件已加標籤", "emails_tagged": "{count} 封郵件已加標籤", "tag_failed": "加上標籤失敗", - "identity_created": "已成功建立寄件身分", - "identity_updated": "已成功更新寄件身分", + "identity_created": "已建立寄件身分", + "identity_updated": "已更新寄件身分", "identity_deleted": "已刪除寄件身分", "identity_set_primary": "已更新主要寄件身分", "identity_create_failed": "無法建立寄件身分:{error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "找不到寄件身分", "vacation_saved": "外出設定已儲存", "vacation_save_failed": "無法儲存外出設定", - "filters_saved": "篩選規則已成功儲存", + "filters_saved": "篩選規則已儲存", "filters_save_failed": "無法儲存篩選規則", "filters_deleted": "篩選規則已刪除", - "templates_exported": "範本匯出成功", + "templates_exported": "範本已匯出", "templates_imported": "已匯入{count, plural, one {# 個範本} other {# 個範本}}", "templates_import_errors": "部分範本無法匯入", "templates_import_empty": "檔案中找不到範本", @@ -2416,7 +2416,7 @@ "validation_error": "指令碼有錯誤", "save": "儲存", "saving": "正在儲存…", - "saved": "篩選規則已成功儲存", + "saved": "篩選規則已儲存", "save_failed": "無法儲存篩選規則", "loading": "正在載入篩選規則…", "not_supported": "您的郵件伺服器不支援郵件篩選規則。", diff --git a/locales/zh/common.json b/locales/zh/common.json index c6571a778..b1626b7df 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1011,8 +1011,8 @@ "email_tagged": "邮件已加标签", "emails_tagged": "{count} 封邮件已加标签", "tag_failed": "标记失败", - "identity_created": "身份创建成功", - "identity_updated": "身份更新成功", + "identity_created": "身份已创建", + "identity_updated": "身份已更新", "identity_deleted": "身份已删除", "identity_set_primary": "主要身份已更新", "identity_create_failed": "无法创建身份:{error}", @@ -1022,10 +1022,10 @@ "identity_not_found": "未找到身份", "vacation_saved": "外出设置已保存", "vacation_save_failed": "无法保存外出设置", - "filters_saved": "过滤规则保存成功", + "filters_saved": "过滤规则已保存", "filters_save_failed": "无法保存过滤器", "filters_deleted": "过滤规则已删除", - "templates_exported": "模板导出成功", + "templates_exported": "模板已导出", "templates_imported": "已导入{count, plural, one {# 个模板} other {# 个模板}}", "templates_import_errors": "部分模板无法导入", "templates_import_empty": "文件中未找到模板", @@ -2409,7 +2409,7 @@ "validation_error": "脚本有错误", "save": "保存", "saving": "保存中...", - "saved": "过滤器保存成功", + "saved": "过滤规则已保存", "save_failed": "无法保存过滤器", "loading": "正在加载过滤器...", "not_supported": "您的邮件服务器不支持邮件过滤器。", From e48a0ed8f6ad08683abdb5a5e1d406e9cb03d4d5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:13:53 +0100 Subject: [PATCH 17/54] fix: write event dates in each language's own order on the event card, the event view and the import preview --- components/calendar/ical-import-modal.tsx | 14 +++++--- hooks/__tests__/use-format-event-date.test.ts | 35 +++++++++++++++++++ hooks/use-format-event-date.ts | 25 +++++++------ 3 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 hooks/__tests__/use-format-event-date.test.ts diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 7d27dc3ae..645848086 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button"; import { Upload, Check, RefreshCw, Globe } from "@/components/icons"; import { Dialog } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; -import { format } from "date-fns"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { getEventStartDate } from "@/lib/calendar-utils"; @@ -212,10 +211,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal if (!event.start) return ""; try { const date = getEventStartDate(event as CalendarEvent); - const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; - return event.showWithoutTime - ? format(date, "MMM d, yyyy") - : format(date, `MMM d, yyyy ${timeFmt}`); + // The locale's own order and month names, not an English pattern. + return date.toLocaleString(locale === "en" ? "en-US" : locale, { + month: "short", + day: "numeric", + year: "numeric", + ...(event.showWithoutTime ? {} : timeFormat === "12h" + ? { hour: "numeric", minute: "2-digit", hour12: true } + : { hour: "2-digit", minute: "2-digit", hour12: false }), + }); } catch { return event.start; } diff --git a/hooks/__tests__/use-format-event-date.test.ts b/hooks/__tests__/use-format-event-date.test.ts new file mode 100644 index 000000000..1a683de28 --- /dev/null +++ b/hooks/__tests__/use-format-event-date.test.ts @@ -0,0 +1,35 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; + +// German dates on the event card read "Sa, Okt 31, 2026": English order +// with translated names. Each locale now gets its own order. + +let locale = 'en'; +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => locale, +})); + +import { useFormatEventDate } from '../use-format-event-date'; + +function formatIn(code: string, date: Date) { + locale = code; + const { result } = renderHook(() => useFormatEventDate()); + return result.current(date); +} + +describe('useFormatEventDate', () => { + const date = new Date(2026, 9, 31); + + it('keeps the English order in English', () => { + expect(formatIn('en', date)).toBe('Sat, Oct 31, 2026'); + }); + + it('puts the day before the month in German', () => { + expect(formatIn('de', date)).toBe('Sa., 31. Okt. 2026'); + }); + + it('follows French too', () => { + expect(formatIn('fr', date)).toBe('sam. 31 oct. 2026'); + }); +}); diff --git a/hooks/use-format-event-date.ts b/hooks/use-format-event-date.ts index 9e09b3ee7..e2600995f 100644 --- a/hooks/use-format-event-date.ts +++ b/hooks/use-format-event-date.ts @@ -4,13 +4,10 @@ import { format } from "date-fns"; import { toJalali, shouldUseJalaliCalendar, JALALI_MONTHS } from "@/lib/jalali-utils"; /** - * Returns a memoized function that formats a calendar event date - * using the current locale for day and month names. - * - * The string will be in the format: "EEE, MMM d, yyyy" - * - * For example: "Wed, Apr 29, 2026" (en) - * "Qua, Abr 29, 2026" (pt) + * Returns a memoized function that formats a calendar event date in the + * current locale's own order and names (Intl), e.g. + * "Wed, Apr 29, 2026" (en) + * "Mi., 29. Apr. 2026" (de) * * When the Jalali calendar is active (fa locale), the format uses * Persian day/month names with the Jalali year, e.g.: @@ -31,12 +28,14 @@ export function useFormatEventDate(): (date: Date) => string { return `${t(`days.${dayOfWeek}`)}, ${jd} ${monthName} ${jy}`; } - const dayOfWeek = format(date, "EEE").toLowerCase(); - const month = format(date, "MMM").toLowerCase(); - const day = format(date, "d"); - const year = format(date, "yyyy"); - return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`; + // A display date: its local fields are the wall-clock to show. + return date.toLocaleDateString(locale === "en" ? "en-US" : locale, { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }); }, - [t, isJalali] + [t, isJalali, locale] ); } 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 18/54] 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 19/54] 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 82aeddfdaadd437ec04561018f1a64668a75a8ea Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:19:02 +0100 Subject: [PATCH 20/54] fix: keep hover previews closed while an editor, card or dialog is open and after one closes until the pointer moves --- .../calendar/__tests__/calendar-app.test.tsx | 12 ++++ components/calendar/calendar-app.tsx | 11 ++- .../__tests__/use-hover-preview-gate.test.ts | 43 +++++++++++ hooks/use-hover-preview-gate.ts | 72 +++++++++++++++++++ 4 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 hooks/__tests__/use-hover-preview-gate.test.ts create mode 100644 hooks/use-hover-preview-gate.ts diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index 843e003d9..323b411a3 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -126,6 +126,18 @@ describe('CalendarApp keyboard shortcuts', () => { fireEvent.keyDown(document.body, { key: 'm', code: 'KeyM' }); expect(useCalendarStore.getState().viewMode).toBe('month'); }); + + // New 6: the preview opened on top of the open editor. + it('opens no hover preview while an editor is open', async () => { + setUp([makeEvent(), makeEvent({ id: 'e2', uid: 'u2', title: 'Retro', start: '2026-10-13T14:00:00' })]); + renderApp(); + fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]); + fireEvent.click(await screen.findByRole('button', { name: 'events.edit' })); + await screen.findByRole('dialog', { name: 'events.edit' }); + fireEvent.mouseEnter(screen.getAllByRole('button', { name: /Retro/ })[0]); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByRole('dialog', { name: 'Retro' })).toBeNull(); + }); }); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index db516779c..7eca930b6 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -91,6 +91,7 @@ import { useCalendarEventSearch } from "@/hooks/use-calendar-event-search"; import { CalendarSearchField, CalendarSearchResults } from "@/components/calendar/calendar-search"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; +import { useHoverPreviewGate } from "@/hooks/use-hover-preview-gate"; import { computeScrollWindow, fixedScrollWindowState, freshScrollWindowState, growScrollWindow, normalizeScrollWindowState, scrollWindowContains, type CalendarFocus, type ScrollViewMode, type ScrollWindowOptions, @@ -842,16 +843,20 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = [closeDetail, openEmptyContextMenu], ); + // No preview over an open editor, card or dialog, nor after one closed + // until the pointer moves. + const canHoverPreview = useHoverPreviewGate(showEventModal || showTaskModal || detailPinned || quickDraft !== null); + const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { if (isMobile) return; if (detailPinnedRef.current) return; if (calendarHoverPreview === 'off') return; if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } - // Don't show hover popover if the sidebar is already open for this event - if (showEventModal && editEvent?.id === event.id) return; + if (!canHoverPreview()) return; if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') { const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000; hoverTimerRef.current = setTimeout(() => { + if (!canHoverPreview()) return; setDetailEvent(event); setDetailAnchorRect(anchorRect); }, ms); @@ -859,7 +864,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setDetailEvent(event); setDetailAnchorRect(anchorRect); } - }, [isMobile, calendarHoverPreview, showEventModal, editEvent]); + }, [isMobile, calendarHoverPreview, canHoverPreview]); const handleHoverLeave = useCallback(() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } diff --git a/hooks/__tests__/use-hover-preview-gate.test.ts b/hooks/__tests__/use-hover-preview-gate.test.ts new file mode 100644 index 000000000..6de1c03c8 --- /dev/null +++ b/hooks/__tests__/use-hover-preview-gate.test.ts @@ -0,0 +1,43 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { useHoverPreviewGate } from '../use-hover-preview-gate'; + +// The hover preview opened on top of the open editor, and by itself after +// a dialog closed with the pointer resting on an event. + +function move(x: number, y: number) { + window.dispatchEvent(new MouseEvent('pointermove', { clientX: x, clientY: y })); +} + +describe('useHoverPreviewGate', () => { + it('lets a preview open when nothing is open', () => { + const { result } = renderHook(() => useHoverPreviewGate(false)); + expect(result.current()).toBe(true); + }); + + it('holds previews back while an editor is open, and after it closed until the pointer moves', () => { + const { result, rerender } = renderHook(({ open }) => useHoverPreviewGate(open), { initialProps: { open: true } }); + move(100, 100); + expect(result.current()).toBe(false); + rerender({ open: false }); + expect(result.current()).toBe(false); + move(101, 101); + expect(result.current()).toBe(false); + move(140, 120); + expect(result.current()).toBe(true); + }); + + it('holds previews back while a modal dialog is on the page, and after it closed', async () => { + const { result } = renderHook(() => useHoverPreviewGate(false)); + move(10, 10); + const dialog = document.createElement('div'); + dialog.setAttribute('aria-modal', 'true'); + document.body.appendChild(dialog); + expect(result.current()).toBe(false); + dialog.remove(); + await new Promise((r) => setTimeout(r, 0)); + expect(result.current()).toBe(false); + move(60, 60); + expect(result.current()).toBe(true); + }); +}); diff --git a/hooks/use-hover-preview-gate.ts b/hooks/use-hover-preview-gate.ts new file mode 100644 index 000000000..430a2a873 --- /dev/null +++ b/hooks/use-hover-preview-gate.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useRef } from "react"; +import { isContextMenuOpen } from "@/components/ui/context-menu"; + +/** How far the pointer has to move before a preview may open again. */ +const MOVE_THRESHOLD = 4; + +const MODAL_SELECTOR = '[aria-modal="true"]'; + +/** + * Decides whether a hover preview may open. Not while an editor, a card, + * a menu or a dialog is open (`overlayOpen` plus any modal on the page), + * and not after one closed until the pointer really moves: the pointer + * resting where the dialog was otherwise "hovers" the event under it and + * the preview opens by itself. + */ +export function useHoverPreviewGate(overlayOpen: boolean): () => boolean { + const overlayOpenRef = useRef(overlayOpen); + const pointer = useRef<{ x: number; y: number } | null>(null); + // Where the pointer was when previews were held back; null when free. + const heldAt = useRef<{ x: number; y: number } | null>(null); + + const hold = useCallback(() => { + heldAt.current = pointer.current ?? { x: Number.NaN, y: Number.NaN }; + }, []); + + useEffect(() => { + const onMove = (e: PointerEvent | MouseEvent) => { + const at = { x: e.clientX, y: e.clientY }; + pointer.current = at; + const held = heldAt.current; + if (!held) return; + // Position unknown when it was held: the first move frees it. + if (Number.isNaN(held.x) || Math.hypot(at.x - held.x, at.y - held.y) > MOVE_THRESHOLD) { + heldAt.current = null; + } + }; + window.addEventListener("pointermove", onMove, { passive: true }); + return () => window.removeEventListener("pointermove", onMove); + }, []); + + // An editor, card or quick editor of the calendar closing. + useEffect(() => { + const was = overlayOpenRef.current; + overlayOpenRef.current = overlayOpen; + if (was && !overlayOpen) hold(); + }, [overlayOpen, hold]); + + // Any other dialog (a confirm, import, shortcuts) portalled onto . + useEffect(() => { + if (typeof MutationObserver === "undefined") return; + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.removedNodes) { + if (node instanceof Element && (node.matches(MODAL_SELECTOR) || node.querySelector(MODAL_SELECTOR))) { + hold(); + return; + } + } + } + }); + observer.observe(document.body, { childList: true }); + return () => observer.disconnect(); + }, [hold]); + + return useCallback(() => { + if (overlayOpenRef.current || isContextMenuOpen() || document.querySelector(MODAL_SELECTOR)) { + hold(); + return false; + } + return heldAt.current === null; + }, [hold]); +} From 62ab48aee4e512557e252fd348a373e533dee358 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:19:10 +0100 Subject: [PATCH 21/54] fix: ask "discard changes?" with keep editing and discard in settings editors and the admin pages, as in calendar and contacts --- .../__tests__/unsaved-changes-guard.test.tsx | 26 +++++++++--------- app/(main)/admin/_unsaved-changes-guard.tsx | 12 ++++----- lib/__tests__/settings-wording.test.ts | 27 +++++++++++++++++++ locales/ar/common.json | 4 +-- locales/ca/common.json | 4 +-- locales/cs/common.json | 4 +-- locales/da/common.json | 4 +-- locales/de/common.json | 4 +-- locales/en/common.json | 4 +-- locales/es/common.json | 2 +- locales/et/common.json | 4 +-- locales/fa/common.json | 6 ++--- locales/fr/common.json | 2 +- locales/he/common.json | 12 ++++----- locales/hu/common.json | 4 +-- locales/it/common.json | 8 +++--- locales/ja/common.json | 4 +-- locales/ko/common.json | 14 +++++----- locales/lv/common.json | 4 +-- locales/mn/common.json | 12 ++++----- locales/nb/common.json | 4 +-- locales/nl/common.json | 2 +- locales/pl/common.json | 4 +-- locales/pt/common.json | 2 +- locales/ro/common.json | 6 ++--- locales/ru/common.json | 8 +++--- locales/sk/common.json | 8 +++--- locales/th/common.json | 4 +-- locales/tr/common.json | 8 +++--- locales/uk/common.json | 6 ++--- locales/zh-TW/common.json | 6 ++--- locales/zh/common.json | 6 ++--- 32 files changed, 126 insertions(+), 99 deletions(-) diff --git a/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx index 7e5a92e95..160bd435a 100644 --- a/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx +++ b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx @@ -21,27 +21,27 @@ describe('admin unsaved changes guard (#1255)', () => { expect(screen.queryByRole('alertdialog')).toBeNull(); }); - it('asks before leaving with unsaved edits; Stay keeps the admin on the page', () => { + it('asks before leaving with unsaved edits; Keep editing keeps the admin on the page', () => { render(<>); const action = vi.fn(); act(() => useAdminUnsavedStore.getState().requestLeave(action)); - expect(screen.getByRole('alertdialog')).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'Stay' })); + expect(screen.getByRole('alertdialog', { name: 'Discard changes?' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Keep editing' })); expect(action).not.toHaveBeenCalled(); expect(screen.queryByRole('alertdialog')).toBeNull(); expect(useAdminUnsavedStore.getState().dirty).toEqual({ settings: true }); }); - it('Leave runs the action and drops the unsaved flag', () => { + it('Discard runs the action and drops the unsaved flag', () => { render(<>); const action = vi.fn(); act(() => useAdminUnsavedStore.getState().requestLeave(action)); - fireEvent.click(screen.getByRole('button', { name: 'Leave' })); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); expect(action).toHaveBeenCalledTimes(1); expect(useAdminUnsavedStore.getState().dirty).toEqual({}); }); - it('Escape closes the prompt like Stay', () => { + it('Escape closes the prompt like Keep editing', () => { render(<>); const action = vi.fn(); act(() => useAdminUnsavedStore.getState().requestLeave(action)); @@ -58,12 +58,12 @@ describe('admin unsaved changes guard (#1255)', () => { act(() => useAdminUnsavedStore.getState().setDirty('plugin-config', true)); act(() => useAdminUnsavedStore.getState().requestLeave(back, 'plugin-config')); - fireEvent.click(screen.getByRole('button', { name: 'Leave' })); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); expect(back).toHaveBeenCalledTimes(2); expect(useAdminUnsavedStore.getState().dirty).toEqual({ plugins: true }); }); - it('holds a link click until the admin picks Leave', () => { + it('holds a link click until the admin picks Discard', () => { const onLinkClick = vi.fn((e: React.MouseEvent) => e.preventDefault()); render( <> @@ -76,7 +76,7 @@ describe('admin unsaved changes guard (#1255)', () => { expect(onLinkClick).not.toHaveBeenCalled(); expect(screen.getByRole('alertdialog')).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'Leave' })); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); expect(onLinkClick).toHaveBeenCalledTimes(1); }); @@ -103,7 +103,7 @@ describe('admin unsaved changes guard on Back', () => { await act(async () => { await done; }); }; - it('keeps the page and asks when Back is pressed with unsaved edits; Stay keeps guarding', async () => { + it('keeps the page and asks when Back is pressed with unsaved edits; Keep editing keeps guarding', async () => { window.history.pushState(null, '', '/admin/policy'); window.history.pushState(null, '', '/admin/telemetry'); render(<>); @@ -112,7 +112,7 @@ describe('admin unsaved changes guard on Back', () => { expect(window.location.pathname).toBe('/admin/telemetry'); expect(screen.getByRole('alertdialog')).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'Stay' })); + fireEvent.click(screen.getByRole('button', { name: 'Keep editing' })); expect(useAdminUnsavedStore.getState().dirty).toEqual({ settings: true }); // Back asks again: the page is still guarded. @@ -121,14 +121,14 @@ describe('admin unsaved changes guard on Back', () => { expect(screen.getByRole('alertdialog')).toBeTruthy(); }); - it('Leave goes back to the previous page', async () => { + it('Discard goes back to the previous page', async () => { window.history.pushState(null, '', '/admin/policy'); window.history.pushState(null, '', '/admin/telemetry'); render(<>); await back(); const done = popped(); - fireEvent.click(screen.getByRole('button', { name: 'Leave' })); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); await act(async () => { await done; }); expect(window.location.pathname).toBe('/admin/policy'); expect(useAdminUnsavedStore.getState().dirty).toEqual({}); diff --git a/app/(main)/admin/_unsaved-changes-guard.tsx b/app/(main)/admin/_unsaved-changes-guard.tsx index bb1a63124..4f3d452b8 100644 --- a/app/(main)/admin/_unsaved-changes-guard.tsx +++ b/app/(main)/admin/_unsaved-changes-guard.tsx @@ -49,8 +49,8 @@ export function UnsavedChangesGuard() { // Back and the back gesture are a same-document popstate (the pages switch // through pushState), which can't be cancelled. So while something is // unsaved a copy of the page's entry sits on top: Back only takes that - // one off, the page stays, and the prompt asks. Stay puts the copy back; - // Leave goes back past the page for real. + // one off, the page stays, and the prompt asks. Keep editing puts the + // copy back; Discard goes back past the page for real. // The mark on the page entry the copy was put on top of. const guard = useRef(null); useEffect(() => { @@ -124,10 +124,10 @@ export function UnsavedChangesGuard() { isOpen={isOpen} onClose={cancelLeave} onConfirm={confirmLeave} - title="Unsaved changes" - message="You have changes that haven't been saved. If you leave now, they will be lost." - cancelText="Stay" - confirmText="Leave" + title="Discard changes?" + message="Your changes haven't been saved." + cancelText="Keep editing" + confirmText="Discard" variant="destructive" /> ); diff --git a/lib/__tests__/settings-wording.test.ts b/lib/__tests__/settings-wording.test.ts index 25bddb1f9..c9245cf69 100644 --- a/lib/__tests__/settings-wording.test.ts +++ b/lib/__tests__/settings-wording.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import en from '@/locales/en/common.json'; import { getByPath } from '../settings-search'; @@ -59,6 +61,31 @@ describe('settings wording', () => { expect(text('notifications.filters_saved')).toBe('Filters saved'); }); + // R5: every editor asks the same question with the same buttons, in every + // language: Settings and Contacts say what Calendar says. + it('asks "Discard changes?" with Keep editing / Discard everywhere', () => { + expect(text('calendar.discard.title')).toBe('Discard changes?'); + const mismatches: string[] = []; + for (const locale of readdirSync(join(process.cwd(), 'locales'))) { + const messages = JSON.parse(readFileSync(join(process.cwd(), 'locales', locale, 'common.json'), 'utf8')); + const ref = getByPath(messages, 'calendar.discard') as Record; + const expected: Record = { + 'settings.discard_changes': ref.title, + 'settings.unsaved_changes': ref.message, + 'settings.keep_editing': ref.keep, + 'settings.discard': ref.confirm, + 'contacts.form.discard_title': ref.title, + 'contacts.form.discard_message': ref.message, + 'contacts.form.keep_editing': ref.keep, + 'contacts.form.discard': ref.confirm, + }; + for (const [key, value] of Object.entries(expected)) { + if (getByPath(messages, key) !== value) mismatches.push(`${locale} ${key}`); + } + } + expect(mismatches).toEqual([]); + }); + it('does not repeat the debug section title as its only row', () => { expect(text('settings.advanced.debug_mode.toggle')).not.toBe(text('settings.advanced.debug_mode.label')); }); diff --git a/locales/ar/common.json b/locales/ar/common.json index 83f3d75c0..031813254 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1118,8 +1118,8 @@ "import_success": "تم استيراد الإعدادات بنجاح", "import_error": "فشل استيراد الإعدادات", "reset_confirm": "هل أنت متأكد أنك تريد إعادة تعيين جميع الإعدادات إلى الافتراضية؟", - "unsaved_changes": "لديك تغييرات غير محفوظة", - "discard_changes": "تجاهل التغييرات غير المحفوظة؟", + "unsaved_changes": "لم يتم حفظ تغييراتك.", + "discard_changes": "تجاهل التغييرات؟", "discard": "تجاهل", "keep_editing": "متابعة التعديل", "search_placeholder": "بحث في الإعدادات", diff --git a/locales/ca/common.json b/locales/ca/common.json index 24d600073..b0624d8a4 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1118,8 +1118,8 @@ "import_success": "Configuració importada correctament", "import_error": "No s'ha pogut importar la configuració", "reset_confirm": "Segur que voleu restablir tota la configuració als valors per defecte?", - "unsaved_changes": "Teniu canvis sense desar", - "discard_changes": "Voleu descartar els canvis sense desar?", + "unsaved_changes": "Els canvis no s'han desat.", + "discard_changes": "Voleu descartar els canvis?", "discard": "Descarta", "keep_editing": "Continua editant", "search_placeholder": "Cerca a la configuració", diff --git a/locales/cs/common.json b/locales/cs/common.json index 5a47c65dd..76fc49c44 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1118,8 +1118,8 @@ "import_success": "Nastavení bylo importováno", "import_error": "Import nastavení selhal", "reset_confirm": "Opravdu chcete obnovit výchozí nastavení?", - "unsaved_changes": "Máte neuložené změny", - "discard_changes": "Zahodit neuložené změny?", + "unsaved_changes": "Vaše změny nebyly uloženy.", + "discard_changes": "Zahodit změny?", "discard": "Zahodit", "keep_editing": "Pokračovat v úpravách", "tabs": { diff --git a/locales/da/common.json b/locales/da/common.json index 39b3577c3..f062a2935 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1118,8 +1118,8 @@ "import_success": "Indstillinger importeret succesfuldt", "import_error": "Kunne ikke importere indstillinger", "reset_confirm": "Er du sikker på, at du vil nulstille alle indstillinger til standard?", - "unsaved_changes": "Du har ugemte ændringer", - "discard_changes": "Kassér ugemte ændringer?", + "unsaved_changes": "Dine ændringer er ikke gemt.", + "discard_changes": "Kassér ændringer?", "discard": "Kassér", "keep_editing": "Fortsæt redigering", "search_placeholder": "Søg i indstillinger", diff --git a/locales/de/common.json b/locales/de/common.json index f674d50e4..3bfcd34b0 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1118,7 +1118,7 @@ "import_success": "Einstellungen erfolgreich importiert", "import_error": "Import der Einstellungen fehlgeschlagen", "reset_confirm": "Sind Sie sicher, dass Sie alle Einstellungen auf die Standardwerte zurücksetzen möchten?", - "unsaved_changes": "Sie haben ungespeicherte Änderungen", + "unsaved_changes": "Ihre Änderungen wurden noch nicht gespeichert.", "discard_changes": "Änderungen verwerfen?", "discard": "Verwerfen", "keep_editing": "Weiter bearbeiten", @@ -3468,7 +3468,7 @@ "save": "Speichern", "cancel": "Abbrechen", "discard_title": "Änderungen verwerfen?", - "discard_message": "Ihre Änderungen wurden nicht gespeichert.", + "discard_message": "Ihre Änderungen wurden noch nicht gespeichert.", "discard": "Verwerfen", "keep_editing": "Weiter bearbeiten", "remove_entry": "Entfernen", diff --git a/locales/en/common.json b/locales/en/common.json index a52ffa489..4ffa2b423 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1118,8 +1118,8 @@ "import_success": "Settings imported", "import_error": "Couldn't import settings", "reset_confirm": "Are you sure you want to reset all settings to defaults?", - "unsaved_changes": "You have unsaved changes", - "discard_changes": "Discard unsaved changes?", + "unsaved_changes": "Your changes haven't been saved.", + "discard_changes": "Discard changes?", "discard": "Discard", "keep_editing": "Keep editing", "search_placeholder": "Search settings", diff --git a/locales/es/common.json b/locales/es/common.json index ea405fcf4..86ad684d7 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1118,7 +1118,7 @@ "import_success": "Configuración importada exitosamente", "import_error": "Error al importar la configuración", "reset_confirm": "¿Está seguro de que desea restablecer toda la configuración a los valores predeterminados?", - "unsaved_changes": "Tienes cambios sin guardar", + "unsaved_changes": "Tus cambios no se han guardado.", "discard_changes": "¿Descartar los cambios?", "discard": "Descartar", "keep_editing": "Seguir editando", diff --git a/locales/et/common.json b/locales/et/common.json index f10aef617..4e366c410 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -1118,8 +1118,8 @@ "import_success": "Seaded imporditud", "import_error": "Seadete importimine ebaõnnestus", "reset_confirm": "Kas soovid kindlasti lähtestada kõik seaded vaikeväärtustele?", - "unsaved_changes": "Sul on salvestamata muudatusi", - "discard_changes": "Kas loobuda salvestamata muudatustest?", + "unsaved_changes": "Teie muudatusi pole salvestatud.", + "discard_changes": "Kas loobuda muudatustest?", "discard": "Loobu", "keep_editing": "Jätka muutmist", "search_placeholder": "Otsi seadetest", diff --git a/locales/fa/common.json b/locales/fa/common.json index cadc5cfc5..cb36a4136 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1118,8 +1118,8 @@ "import_success": "تنظیمات با موفقیت وارد شد", "import_error": "وارد کردن تنظیمات ناموفق بود", "reset_confirm": "آیا از بازنشانی همه تنظیمات اطمینان دارید؟", - "unsaved_changes": "تغییرات ذخیره نشده دارید", - "discard_changes": "دور انداختن تغییرات ذخیره نشده؟", + "unsaved_changes": "تغییرات شما ذخیره نشده است.", + "discard_changes": "تغییرات دور انداخته شود؟", "discard": "دور انداختن", "keep_editing": "ادامه ویرایش", "search_placeholder": "جستجوی تنظیمات", @@ -3469,7 +3469,7 @@ "add_phone": "افزودن تلفن", "save": "ذخیره", "cancel": "انصراف", - "discard_title": "تغییرات دور انداخته شوند؟", + "discard_title": "تغییرات دور انداخته شود؟", "discard_message": "تغییرات شما ذخیره نشده است.", "discard": "دور انداختن", "keep_editing": "ادامه ویرایش", diff --git a/locales/fr/common.json b/locales/fr/common.json index ff049af3a..efb7c7e71 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1118,7 +1118,7 @@ "import_success": "Paramètres importés avec succès", "import_error": "Échec de l'importation des paramètres", "reset_confirm": "Êtes-vous sûr de vouloir réinitialiser tous les paramètres ?", - "unsaved_changes": "Vous avez des modifications non enregistrées", + "unsaved_changes": "Vos modifications n'ont pas été enregistrées.", "discard_changes": "Abandonner les modifications ?", "discard": "Abandonner", "keep_editing": "Continuer l'édition", diff --git a/locales/he/common.json b/locales/he/common.json index 7b6b96707..107d33578 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1083,10 +1083,10 @@ "import_success": "ההגדרות יובאו בהצלחה", "import_error": "ייבוא ההגדרות נכשל", "reset_confirm": "האם אתה בטוח שברצונך לאפס את כל ההגדרות לברירות מחדל?", - "unsaved_changes": "יש לך שינויים שלא נשמרו", - "discard_changes": "למחוק שינויים שלא נשמרו?", - "discard": "לזרוק", - "keep_editing": "תמשיך לערוך", + "unsaved_changes": "השינויים שלך לא נשמרו.", + "discard_changes": "לבטל את השינויים?", + "discard": "לבטל שינויים", + "keep_editing": "להמשיך לערוך", "tabs": { "appearance": "מראה", "account": "חשבון", @@ -3373,8 +3373,8 @@ "cancel": "לְבַטֵל", "discard_title": "לבטל את השינויים?", "discard_message": "השינויים שלך לא נשמרו.", - "discard": "בטל", - "keep_editing": "המשך לערוך", + "discard": "לבטל שינויים", + "keep_editing": "להמשיך לערוך", "remove_entry": "הסר", "close": "סגור", "remove_category": "הסר את {name}", diff --git a/locales/hu/common.json b/locales/hu/common.json index 46ec89dd6..bd808f20c 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1118,8 +1118,8 @@ "import_success": "Beállítások sikeresen importálva", "import_error": "Nem sikerült importálni a beállításokat", "reset_confirm": "Biztosan vissza akarod állítani az összes beállítást az alapértelmezettre?", - "unsaved_changes": "Nem mentett változtatások vannak", - "discard_changes": "Nem mentett változtatások elvetése?", + "unsaved_changes": "A változtatásai nincsenek mentve.", + "discard_changes": "Elveti a változtatásokat?", "discard": "Elvetés", "keep_editing": "Szerkesztés folytatása", "search_placeholder": "Beállítások keresése", diff --git a/locales/it/common.json b/locales/it/common.json index 1ae4ac6bd..23c75e930 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1118,9 +1118,9 @@ "import_success": "Impostazioni importate con successo", "import_error": "Impossibile importare le impostazioni", "reset_confirm": "Sei sicuro di voler ripristinare tutte le impostazioni ai valori predefiniti?", - "unsaved_changes": "Hai modifiche non salvate", + "unsaved_changes": "Le modifiche non sono state salvate.", "discard_changes": "Annullare le modifiche?", - "discard": "Annulla", + "discard": "Annulla modifiche", "keep_editing": "Continua a modificare", "tabs": { "appearance": "Aspetto", @@ -3467,9 +3467,9 @@ "add_phone": "Aggiungi telefono", "save": "Salva", "cancel": "Annulla", - "discard_title": "Eliminare le modifiche?", + "discard_title": "Annullare le modifiche?", "discard_message": "Le modifiche non sono state salvate.", - "discard": "Elimina", + "discard": "Annulla modifiche", "keep_editing": "Continua a modificare", "remove_entry": "Rimuovi", "close": "Chiudi", diff --git a/locales/ja/common.json b/locales/ja/common.json index c8fdb02e0..88ca69f23 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1118,7 +1118,7 @@ "import_success": "設定をインポートしました", "import_error": "設定のインポートに失敗しました", "reset_confirm": "すべての設定をデフォルトにリセットしてもよろしいですか?", - "unsaved_changes": "保存されていない変更があります", + "unsaved_changes": "変更はまだ保存されていません。", "discard_changes": "変更を破棄しますか?", "discard": "破棄", "keep_editing": "編集を続ける", @@ -3468,7 +3468,7 @@ "save": "保存", "cancel": "キャンセル", "discard_title": "変更を破棄しますか?", - "discard_message": "変更は保存されていません。", + "discard_message": "変更はまだ保存されていません。", "discard": "破棄", "keep_editing": "編集を続ける", "remove_entry": "削除", diff --git a/locales/ko/common.json b/locales/ko/common.json index 0d47cdbdd..d60cb13aa 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1118,9 +1118,9 @@ "import_success": "설정을 성공적으로 가져왔어요", "import_error": "설정 가져오기에 실패했어요", "reset_confirm": "모든 설정을 기본값으로 되돌릴까요?", - "unsaved_changes": "저장되지 않은 변경 사항이 있어요", - "discard_changes": "변경 사항을 취소할까요?", - "discard": "취소하기", + "unsaved_changes": "변경 사항이 아직 저장되지 않았습니다.", + "discard_changes": "변경 사항을 버릴까요?", + "discard": "버리기", "keep_editing": "계속 수정하기", "tabs": { "appearance": "화면 설정", @@ -3467,10 +3467,10 @@ "add_phone": "전화번호 추가", "save": "저장", "cancel": "취소", - "discard_title": "변경 사항을 삭제할까요?", - "discard_message": "변경 사항이 저장되지 않았습니다.", - "discard": "삭제", - "keep_editing": "계속 편집", + "discard_title": "변경 사항을 버릴까요?", + "discard_message": "변경 사항이 아직 저장되지 않았습니다.", + "discard": "버리기", + "keep_editing": "계속 수정하기", "remove_entry": "제거", "close": "닫기", "remove_category": "{name} 제거", diff --git a/locales/lv/common.json b/locales/lv/common.json index 576cf389f..b043b1a6c 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1118,8 +1118,8 @@ "import_success": "Iestatījumi veiksmīgi importēti", "import_error": "Neizdevās importēt iestatījumus", "reset_confirm": "Vai tiešām vēlaties atiestatīt visus iestatījumus uz noklusējuma vērtībām?", - "unsaved_changes": "Jums ir nesaglabātas izmaiņas", - "discard_changes": "Atmest nesaglabātās izmaiņas?", + "unsaved_changes": "Jūsu izmaiņas nav saglabātas.", + "discard_changes": "Atmest izmaiņas?", "discard": "Atmest", "keep_editing": "Turpināt rediģēšanu", "tabs": { diff --git a/locales/mn/common.json b/locales/mn/common.json index fe6f07f32..103d1523a 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1118,10 +1118,10 @@ "import_success": "Тохиргоог амжилттай импортоллоо", "import_error": "Тохиргоог оруулж чадсангүй", "reset_confirm": "Та бүх тохиргоог өгөгдмөл рүү буцаахдаа итгэлтэй байна уу?", - "unsaved_changes": "Танд хадгалагдаагүй өөрчлөлтүүд байна", - "discard_changes": "Хадгалагдаагүй өөрчлөлтүүдийг устгах уу?", - "discard": "Хасах", - "keep_editing": "Засварлаж байгаарай", + "unsaved_changes": "Таны өөрчлөлтүүд хадгалагдаагүй байна.", + "discard_changes": "Өөрчлөлтүүдийг хаях уу?", + "discard": "Хаях", + "keep_editing": "Үргэлжлүүлэн засах", "search_placeholder": "Хайлтын тохиргоо", "search_clear": "Хайлтыг арилгах", "search_no_results": "Тохирох тохиргоо байхгүй", @@ -3469,8 +3469,8 @@ "add_phone": "Утас нэмэх", "save": "Хадгалах", "cancel": "Цуцлах", - "discard_title": "Өөрчлөлтийг хаях уу?", - "discard_message": "Таны өөрчлөлт хадгалагдаагүй байна.", + "discard_title": "Өөрчлөлтүүдийг хаях уу?", + "discard_message": "Таны өөрчлөлтүүд хадгалагдаагүй байна.", "discard": "Хаях", "keep_editing": "Үргэлжлүүлэн засах", "remove_entry": "Хасах", diff --git a/locales/nb/common.json b/locales/nb/common.json index dbad27796..35692689d 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1118,8 +1118,8 @@ "import_success": "Innstillingene er importert", "import_error": "Kunne ikke importere innstillinger", "reset_confirm": "Vil du tilbakestille alle innstillingene?", - "unsaved_changes": "Du har ulagrede endringer", - "discard_changes": "Vil du forkaste ulagrede endringer?", + "unsaved_changes": "Endringene dine er ikke lagret.", + "discard_changes": "Vil du forkaste endringene?", "discard": "Forkast", "keep_editing": "Fortsett å redigere", "search_placeholder": "Søk i innstillinger", diff --git a/locales/nl/common.json b/locales/nl/common.json index 666ab2471..3d0a27361 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1118,7 +1118,7 @@ "import_success": "Instellingen succesvol geïmporteerd", "import_error": "Kan instellingen niet importeren", "reset_confirm": "Weet je zeker dat je alle instellingen wilt resetten naar de standaardwaarden?", - "unsaved_changes": "U heeft niet-opgeslagen wijzigingen", + "unsaved_changes": "Je wijzigingen zijn niet opgeslagen.", "discard_changes": "Wijzigingen verwerpen?", "discard": "Verwerpen", "keep_editing": "Doorgaan met bewerken", diff --git a/locales/pl/common.json b/locales/pl/common.json index cd950fcc8..4ba98decb 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1118,8 +1118,8 @@ "import_success": "Ustawienia zostały zaimportowane", "import_error": "Nie udało się zaimportować ustawień", "reset_confirm": "Czy na pewno chcesz przywrócić domyślne ustawienia?", - "unsaved_changes": "Masz niezapisane zmiany", - "discard_changes": "Odrzucić niezapisane zmiany?", + "unsaved_changes": "Twoje zmiany nie zostały zapisane.", + "discard_changes": "Odrzucić zmiany?", "discard": "Odrzuć", "keep_editing": "Kontynuuj edycję", "tabs": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 31b7da9b2..ad28659db 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1118,7 +1118,7 @@ "import_success": "Configurações importadas com sucesso", "import_error": "Falha ao importar configurações", "reset_confirm": "Tem certeza de que deseja redefinir todas as configurações para os padrões?", - "unsaved_changes": "Você tem alterações não salvas", + "unsaved_changes": "Suas alterações não foram salvas.", "discard_changes": "Descartar alterações?", "discard": "Descartar", "keep_editing": "Continuar editando", diff --git a/locales/ro/common.json b/locales/ro/common.json index 2ff1964e4..f5034810f 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1118,9 +1118,9 @@ "import_success": "Setările au fost importate cu succes", "import_error": "Nu s-au putut importa setările", "reset_confirm": "Sunteți sigur că doriți să resetați toate setările la valorile implicite?", - "unsaved_changes": "Aveți modificări nesalvate", - "discard_changes": "Doriți să renunțați la modificările nesalvate?", - "discard": "Ignorați", + "unsaved_changes": "Modificările dvs. nu au fost salvate.", + "discard_changes": "Renunțați la modificări?", + "discard": "Renunțați", "keep_editing": "Continuați editarea", "search_placeholder": "Setări de căutare", "search_clear": "Șterge căutarea", diff --git a/locales/ru/common.json b/locales/ru/common.json index 42dd04851..41f180584 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1118,9 +1118,9 @@ "import_success": "Настройки успешно импортированы", "import_error": "Не удалось импортировать настройки", "reset_confirm": "Вы уверены, что хотите сбросить все настройки до значений по умолчанию?", - "unsaved_changes": "У вас есть несохранённые изменения", - "discard_changes": "Отменить несохранённые изменения?", - "discard": "Отменить", + "unsaved_changes": "Ваши изменения не сохранены.", + "discard_changes": "Отменить изменения?", + "discard": "Отменить изменения", "keep_editing": "Продолжить редактирование", "tabs": { "appearance": "Внешний вид", @@ -3469,7 +3469,7 @@ "cancel": "Отмена", "discard_title": "Отменить изменения?", "discard_message": "Ваши изменения не сохранены.", - "discard": "Отменить", + "discard": "Отменить изменения", "keep_editing": "Продолжить редактирование", "remove_entry": "Убрать", "close": "Закрыть", diff --git a/locales/sk/common.json b/locales/sk/common.json index f58a0f303..9bcf26ed2 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1118,9 +1118,9 @@ "import_success": "Nastavenia boli úspešne importované", "import_error": "Import nastavení zlyhal", "reset_confirm": "Naozaj chcete obnoviť všetky nastavenia na predvolené?", - "unsaved_changes": "Máte neuložené zmeny", - "discard_changes": "Zahoditť neuložené zmeny?", - "discard": "Zahoditť", + "unsaved_changes": "Vaše zmeny neboli uložené.", + "discard_changes": "Zahodiť zmeny?", + "discard": "Zahodiť", "keep_editing": "Pokračovať v úprave", "search_placeholder": "Hľadať nastavenia", "search_clear": "Vymazať hľadanie", @@ -3472,7 +3472,7 @@ "discard_title": "Zahodiť zmeny?", "discard_message": "Vaše zmeny neboli uložené.", "discard": "Zahodiť", - "keep_editing": "Pokračovať v úpravách", + "keep_editing": "Pokračovať v úprave", "remove_entry": "Odstrániť", "close": "Zavrieť", "remove_category": "Odstrániť {name}", diff --git a/locales/th/common.json b/locales/th/common.json index 181a7ec8e..44a9fe238 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -3297,8 +3297,8 @@ "import_success": "นำเข้าการตั้งค่าเรียบร้อยแล้ว", "import_error": "นำเข้าการตั้งค่าไม่สำเร็จ", "reset_confirm": "คุณแน่ใจหรือไม่ว่าต้องการรีเซ็ตการตั้งค่าทั้งหมดกลับเป็นค่าเริ่มต้น?", - "unsaved_changes": "คุณมีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก", - "discard_changes": "ละทิ้งการเปลี่ยนแปลงที่ยังไม่ได้บันทึกหรือไม่?", + "unsaved_changes": "การเปลี่ยนแปลงของคุณยังไม่ได้บันทึก", + "discard_changes": "ละทิ้งการเปลี่ยนแปลงหรือไม่?", "discard": "ละทิ้ง", "keep_editing": "แก้ไขต่อ", "search_placeholder": "ค้นหาการตั้งค่า", diff --git a/locales/tr/common.json b/locales/tr/common.json index eb80b9fc9..85a78614e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1118,9 +1118,9 @@ "import_success": "Ayarlar başarıyla içe aktarıldı", "import_error": "Ayarlar içe aktarılamadı", "reset_confirm": "Tüm ayarları varsayılanlara sıfırlamak istediğinizden emin misiniz?", - "unsaved_changes": "Kaydedilmemiş değişiklikleriniz var", - "discard_changes": "Kaydedilmemiş değişiklikler atılsın mı?", - "discard": "Atla", + "unsaved_changes": "Değişiklikleriniz kaydedilmedi.", + "discard_changes": "Değişiklikler atılsın mı?", + "discard": "Değişiklikleri at", "keep_editing": "Düzenlemeye devam et", "tabs": { "appearance": "Görünüm", @@ -3469,7 +3469,7 @@ "cancel": "İptal", "discard_title": "Değişiklikler atılsın mı?", "discard_message": "Değişiklikleriniz kaydedilmedi.", - "discard": "At", + "discard": "Değişiklikleri at", "keep_editing": "Düzenlemeye devam et", "remove_entry": "Kaldır", "close": "Kapat", diff --git a/locales/uk/common.json b/locales/uk/common.json index d1162c2ea..7fbdeaae5 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1118,10 +1118,10 @@ "import_success": "Налаштування успішно імпортовано", "import_error": "Не вдалося імпортувати налаштування", "reset_confirm": "Ви впевнені, що бажаєте скинути всі налаштування до стандартних?", - "unsaved_changes": "У вас є незбережені зміни", - "discard_changes": "Скасувати незбережені зміни?", + "unsaved_changes": "Ваші зміни не збережено.", + "discard_changes": "Відкинути зміни?", "discard": "Відкинути", - "keep_editing": "Продовжуйте редагувати", + "keep_editing": "Продовжити редагування", "tabs": { "appearance": "Зовнішній вигляд", "account": "Обліковий запис", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 16779b94c..828e6255e 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1118,8 +1118,8 @@ "import_success": "已成功匯入設定", "import_error": "無法匯入設定", "reset_confirm": "您確定要將所有設定重設為預設值嗎?", - "unsaved_changes": "您有尚未儲存的變更", - "discard_changes": "要捨棄尚未儲存的變更嗎?", + "unsaved_changes": "你的變更尚未儲存。", + "discard_changes": "要捨棄變更嗎?", "discard": "捨棄", "keep_editing": "繼續編輯", "search_placeholder": "搜尋設定", @@ -3470,7 +3470,7 @@ "save": "儲存", "cancel": "取消", "discard_title": "要捨棄變更嗎?", - "discard_message": "您的變更尚未儲存。", + "discard_message": "你的變更尚未儲存。", "discard": "捨棄", "keep_editing": "繼續編輯", "remove_entry": "移除", diff --git a/locales/zh/common.json b/locales/zh/common.json index b1626b7df..6a0577260 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1118,8 +1118,8 @@ "import_success": "设置导入成功", "import_error": "导入设置失败", "reset_confirm": "您确定要将所有设置重置为默认值吗?", - "unsaved_changes": "您有未保存的更改", - "discard_changes": "放弃未保存的更改?", + "unsaved_changes": "你的更改尚未保存。", + "discard_changes": "放弃更改?", "discard": "放弃", "keep_editing": "继续编辑", "tabs": { @@ -3468,7 +3468,7 @@ "save": "保存", "cancel": "取消", "discard_title": "放弃更改?", - "discard_message": "您的更改尚未保存。", + "discard_message": "你的更改尚未保存。", "discard": "放弃", "keep_editing": "继续编辑", "remove_entry": "移除", From 9c2952c43758a28a5e9cbb999f351334d5a0c6d9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:19:13 +0100 Subject: [PATCH 22/54] fix: take the tour to the screen of each step on a phone and count only the steps it shows --- components/calendar/calendar-app.tsx | 1 + components/layout/mobile-header.tsx | 1 + components/layout/navigation-rail.tsx | 1 + components/mail/mail-app.tsx | 9 +- components/settings/settings-app.tsx | 2 +- .../tour/__tests__/tour-provider.test.tsx | 111 ++++++++++++++++++ components/tour/__tests__/tour.test.tsx | 91 ++++++++++++-- components/tour/tour-overlay.tsx | 68 ++++++----- components/tour/tour-provider.tsx | 106 ++++++++++------- components/tour/tour-steps.ts | 76 ++++++++++-- lib/app-commands.ts | 2 +- 11 files changed, 370 insertions(+), 98 deletions(-) create mode 100644 components/tour/__tests__/tour-provider.test.tsx diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index db516779c..ce2b3e79e 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -2228,6 +2228,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onClick={openCreateForView} className="absolute bottom-4 end-4 z-40 h-14 w-14 rounded-full shadow-lg" aria-label={createLabel} + data-tour="create-event-button" > diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx index 85267493e..c04c36b67 100644 --- a/components/layout/mobile-header.tsx +++ b/components/layout/mobile-header.tsx @@ -121,6 +121,7 @@ export function MobileHeader({ nesting one inside another is invalid markup. */
{ @@ -1143,6 +1145,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { else keyboardHandlers.onFocusSearch?.(); }, 'all-inboxes': () => handleMailboxSelect(UNIFIED_MAILBOX_IDS.inbox), + 'show-list': () => { + if (!isMobile) return; + if (sidebarOpen) setSidebarOpen(false); + if (!showComposer && (activeView !== "list" || selectedEmail)) handleMobileBack(); + }, }, isAuthenticated && !!client); // Shared mail refresh: re-fetch mailboxes (unread counts) + the active list diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index e5e24d08b..58f1c11b6 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -909,7 +909,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
-
+
diff --git a/components/tour/__tests__/tour-provider.test.tsx b/components/tour/__tests__/tour-provider.test.tsx new file mode 100644 index 000000000..15f00d319 --- /dev/null +++ b/components/tour/__tests__/tour-provider.test.tsx @@ -0,0 +1,111 @@ +import { act, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Which steps a run of the tour counts, and where it takes the user. + +const env = vi.hoisted(() => ({ + phone: true, + pathname: '/mail/folder/inbox', + push: vi.fn(), + command: vi.fn(), +})); + +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: env.push }), + usePathname: () => env.pathname, +})); +vi.mock('@/hooks/use-media-query', () => ({ + useMediaQuery: () => env.phone, + useIsMobile: () => env.phone, +})); +vi.mock('@/lib/app-commands', () => ({ requestAppCommand: env.command })); +vi.mock('../tour-overlay', () => ({ TourOverlay: () => null })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: (sel: (s: unknown) => unknown) => sel({ isDemoMode: true, client: { supportsContacts: () => true } }), +})); +vi.mock('@/stores/calendar-store', () => ({ + useCalendarStore: (sel: (s: unknown) => unknown) => sel({ supportsCalendar: true }), +})); +vi.mock('@/stores/webdav-store', () => ({ + useWebDAVStore: (sel: (s: unknown) => unknown) => sel({ supportsWebDAV: true }), +})); +vi.mock('@/stores/policy-store', () => ({ + usePolicyStore: (sel: (s: unknown) => unknown) => sel({ isFeatureEnabled: () => true }), +})); +const updateSetting = vi.fn(); +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (sel: (s: unknown) => unknown) => sel({ tourCompleted: false, showOnboardingOnNewDevices: false, updateSetting }), +})); + +const { TourProvider, useTour } = await import('../tour-provider'); +const { getTourSteps } = await import('../tour-steps'); + +let api: ReturnType; +function Probe() { + api = useTour(); + const step = api.steps[api.currentStep]; + return

{api.isActive ? `${api.currentStep + 1}/${api.totalSteps}/${step?.id}` : 'off'}

; +} +const probe = () => screen.getByTestId('probe').textContent; + +beforeEach(() => { + env.phone = true; + env.pathname = '/mail/folder/inbox'; + env.push.mockClear(); + env.command.mockClear(); + localStorage.clear(); +}); + +function start() { + render(); + act(() => api.startTour()); +} + +describe('tour run', () => { + it('counts only the steps a phone shows, starting at step 1', () => { + start(); + const shown = getTourSteps({ isDemoMode: true, supportsCalendar: true, supportsWebDAV: true, supportsContacts: true, touch: true, phone: true }); + expect(probe()).toBe(`1/${shown.length}/compose`); + }); + + it('drops a step that has nothing to point at, so the count stays whole', () => { + start(); + const total = api.totalSteps; + act(() => api.nextStep()); + expect(probe()).toBe(`2/${total}/search`); + act(() => api.skipStep(1)); + expect(probe()).toBe(`2/${total - 1}/email-list`); + act(() => api.skipStep(-1)); + expect(probe()).toBe(`1/${total - 2}/compose`); + }); + + it('closes the phone reader for Mail steps but not for the reader step', () => { + start(); + expect(env.command).toHaveBeenLastCalledWith('show-list'); + while (api.steps[api.currentStep].id !== 'email-viewer') act(() => api.nextStep()); + env.command.mockClear(); + env.pathname = '/mail/message/m1'; + act(() => api.nextStep()); + // The next Mail step (Calendar in the tab bar) needs the list back. + expect(api.steps[api.currentStep].id).toBe('nav-calendar'); + expect(env.command).toHaveBeenCalledWith('show-list'); + env.command.mockClear(); + act(() => api.prevStep()); + expect(env.command).not.toHaveBeenCalled(); + }); + + it('takes the user to the app of the step', () => { + start(); + while (api.steps[api.currentStep].id !== 'calendar-view') act(() => api.nextStep()); + expect(env.push).toHaveBeenLastCalledWith('/calendar'); + // File storage is shown over Files. + while (api.steps[api.currentStep].id !== 'nav-files') act(() => api.nextStep()); + expect(env.push).toHaveBeenLastCalledWith('/files'); + }); + + it('leaves a desktop reader open', () => { + env.phone = false; + start(); + expect(env.command).not.toHaveBeenCalled(); + }); +}); diff --git a/components/tour/__tests__/tour.test.tsx b/components/tour/__tests__/tour.test.tsx index 6c0b1de38..ac5d0e33b 100644 --- a/components/tour/__tests__/tour.test.tsx +++ b/components/tour/__tests__/tour.test.tsx @@ -4,29 +4,37 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // The tour on a phone, where the rail's anchors are not rendered, and its // keyboard handling. +const nav = vi.hoisted(() => ({ pathname: '/' })); +vi.mock('@/i18n/navigation', () => ({ usePathname: () => nav.pathname })); + const tour = vi.hoisted(() => ({ currentStep: 0, nextStep: vi.fn(), prevStep: vi.fn(), + skipStep: vi.fn(), stopTour: vi.fn(), + // The same objects on every render, as the provider's steps are. + steps: [ + { id: 'one', target: '[data-tour="one"]', titleKey: 'one', descriptionKey: 'one_desc', placement: 'right' }, + { id: 'two', target: '[data-tour="two"]', titleKey: 'two', descriptionKey: 'two_desc', placement: 'right' }, + { id: 'three', target: '[data-tour="three"]', titleKey: 'three', descriptionKey: 'three_desc', placement: 'right' }, + { id: 'files', target: '[data-tour="four"]', titleKey: 'four', descriptionKey: 'four_desc', placement: 'right', page: '/files' }, + ], })); vi.mock('../tour-provider', () => ({ useTour: () => ({ currentStep: tour.currentStep, totalSteps: 3, - steps: [ - { id: 'one', target: '[data-tour="one"]', titleKey: 'one', descriptionKey: 'one_desc', placement: 'right' }, - { id: 'two', target: '[data-tour="two"]', titleKey: 'two', descriptionKey: 'two_desc', placement: 'right' }, - { id: 'three', target: '[data-tour="three"]', titleKey: 'three', descriptionKey: 'three_desc', placement: 'right' }, - ], + steps: tour.steps, nextStep: tour.nextStep, prevStep: tour.prevStep, + skipStep: tour.skipStep, stopTour: tour.stopTour, }), })); -const { TourOverlay, findTourTarget } = await import('../tour-overlay'); +const { TourOverlay, findTourTarget, computeTooltipPosition } = await import('../tour-overlay'); const { getTourSteps } = await import('../tour-steps'); /** jsdom lays nothing out: give the elements marked `data-size` a box. */ @@ -34,8 +42,10 @@ const realRect = HTMLElement.prototype.getBoundingClientRect; beforeEach(() => { vi.useFakeTimers(); tour.currentStep = 0; + nav.pathname = '/'; tour.nextStep.mockClear(); tour.prevStep.mockClear(); + tour.skipStep.mockClear(); tour.stopTour.mockClear(); HTMLElement.prototype.getBoundingClientRect = function () { const sized = this.hasAttribute('data-size'); @@ -48,6 +58,8 @@ afterEach(() => { HTMLElement.prototype.getBoundingClientRect = realRect; hosts.splice(0).forEach((h) => h.remove()); document.documentElement.removeAttribute('dir'); + // jsdom has no elementsFromPoint; tests that need it add one. + delete (document as { elementsFromPoint?: unknown }).elementsFromPoint; }); const hosts: HTMLElement[] = []; @@ -69,11 +81,47 @@ describe('tour targets', () => { expect(findTourTarget('[data-tour="one"]')).toBeNull(); }); - it('skips a step whose target is in the page but not shown, instead of leaving the user behind the overlay', () => { + it('ignores a copy covered by something else, like the tab bar under an open message', () => { + anchors('
'); + const reader = document.getElementById('reader-bar')!; + (document as { elementsFromPoint?: unknown }).elementsFromPoint = () => [reader, document.body]; + expect(findTourTarget('[data-tour="one"]')).toBeNull(); + // The tour's own layers on top don't count. + const layer = document.createElement('div'); + layer.setAttribute('data-tour-layer', ''); + const tabbar = document.getElementById('tabbar')!; + (document as { elementsFromPoint?: unknown }).elementsFromPoint = () => [layer, tabbar, document.body]; + expect(findTourTarget('[data-tour="one"]')?.id).toBe('tabbar'); + }); + + it('drops a step whose target is in the page but not shown, instead of leaving the user behind the overlay', () => { anchors(''); render(); act(() => { vi.advanceTimersByTime(1100); }); - expect(tour.nextStep).toHaveBeenCalledTimes(1); + expect(tour.skipStep).toHaveBeenCalledWith(1); + expect(tour.nextStep).not.toHaveBeenCalled(); + }); + + it("waits for the step's app before pointing at an anchor the app being left also has", () => { + anchors(''); + tour.currentStep = 3; + nav.pathname = '/settings'; + const { rerender } = render(); + act(() => { vi.advanceTimersByTime(6000); }); + expect(tour.skipStep).not.toHaveBeenCalled(); + expect(document.querySelector('[role="dialog"]')).toHaveClass('invisible'); + nav.pathname = '/files'; + rerender(); + act(() => { vi.advanceTimersByTime(400); }); + expect(screen.getByRole('dialog', { name: 'four' })).not.toHaveClass('invisible'); + }); + + it('has no card that catches taps while it looks for the target', () => { + render(); + act(() => { vi.advanceTimersByTime(300); }); + const card = document.querySelector('[role="dialog"]') as HTMLElement; + expect(card).toHaveClass('invisible'); + expect(card.style.pointerEvents).toBe('none'); }); it('shows a step whose target is on screen', () => { @@ -92,6 +140,33 @@ describe('tour targets', () => { expect(ids({})).toContain('nav-contacts'); expect(ids({ supportsContacts: false })).not.toContain('nav-contacts'); }); + + it('puts the card above a tab at the end of the phone tab bar instead of over it', () => { + const w = window.innerWidth; + const hgt = window.innerHeight; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 }); + try { + const tab = { left: 234, top: 792, width: 78, height: 52 }; + const pos = computeTooltipPosition(tab, 'right', { width: 360, height: 196 }); + expect(pos.top + 196).toBeLessThanOrEqual(tab.top); + expect(pos.left + 360).toBeLessThanOrEqual(390 - 8); + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: w }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: hgt }); + } + }); + + it('leaves out on a phone the steps that point into the rail or the folder drawer', () => { + const ids = (phone: boolean) => + getTourSteps({ isDemoMode: true, supportsCalendar: true, supportsWebDAV: true, touch: phone, phone }).map((s) => s.id); + for (const id of ['sidebar', 'keywords', 'demo-banner', 'quota']) { + expect(ids(false), id).toContain(id); + expect(ids(true), id).not.toContain(id); + } + expect(ids(true)[0]).toBe('compose'); + expect(ids(true)).toContain('nav-files'); + }); }); describe('tour keyboard', () => { diff --git a/components/tour/tour-overlay.tsx b/components/tour/tour-overlay.tsx index 8751bbc03..02b9e265d 100644 --- a/components/tour/tour-overlay.tsx +++ b/components/tour/tour-overlay.tsx @@ -9,6 +9,10 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { Button } from "@/components/ui/button"; import { isEditableEventTarget } from "@/lib/keyboard"; import { isDocumentRTL } from "@/i18n/direction"; +import { findTourTarget, isTourStepOnPage } from "./tour-steps"; +import { usePathname } from "@/i18n/navigation"; + +export { findTourTarget }; interface Rect { top: number; @@ -21,23 +25,6 @@ const PADDING = 8; const TOOLTIP_GAP = 12; const TOOLTIP_MAX_W = 360; -/** - * The first element for the selector that is on screen. The same anchor can - * exist twice (the rail and the phone's tab bar); one with no size is not - * rendered in this layout, and one in a closed drawer (inert, or pushed off - * the side of the screen) can't be seen: neither has anything to point at. - */ -export function findTourTarget(selector: string): Element | null { - for (const el of document.querySelectorAll(selector)) { - if (el.closest("[inert]")) continue; - const r = el.getBoundingClientRect(); - if (r.width === 0 && r.height === 0) continue; - if (r.right <= 0 || r.left >= window.innerWidth) continue; - return el; - } - return null; -} - function getTargetRect(selector: string): Rect | null { const el = findTourTarget(selector); if (!el) return null; @@ -49,8 +36,10 @@ function getTargetRect(selector: string): Rect | null { const HIDDEN_TARGET_ATTEMPTS = 10; /** Polls before a step whose target never appeared (another page loading) is skipped. */ const MISSING_TARGET_ATTEMPTS = 50; +/** Polls while the step's app is still loading (a first visit compiles it in development). */ +const PAGE_LOAD_ATTEMPTS = 300; -function computeTooltipPosition( +export function computeTooltipPosition( target: Rect, placement: "top" | "bottom" | "left" | "right", tooltipSize: { width: number; height: number } @@ -60,21 +49,25 @@ function computeTooltipPosition( const tw = Math.max(tooltipSize.width, 200); // minimum fallback width const th = Math.max(tooltipSize.height, 100); // minimum fallback height + // Along the side it sits on, the card slides to stay on screen: above a + // tab at the end of the phone's tab bar it can't be centred on the tab. + const alongX = (left: number) => Math.max(8, Math.min(left, vw - tw - 8)); + const alongY = (top: number) => Math.max(8, Math.min(top, vh - th - 8)); const positions = { bottom: { top: target.top + target.height + PADDING + TOOLTIP_GAP, - left: target.left + target.width / 2 - tw / 2, + left: alongX(target.left + target.width / 2 - tw / 2), }, top: { top: target.top - PADDING - TOOLTIP_GAP - th, - left: target.left + target.width / 2 - tw / 2, + left: alongX(target.left + target.width / 2 - tw / 2), }, right: { - top: target.top + target.height / 2 - th / 2, + top: alongY(target.top + target.height / 2 - th / 2), left: target.left + target.width + PADDING + TOOLTIP_GAP, }, left: { - top: target.top + target.height / 2 - th / 2, + top: alongY(target.top + target.height / 2 - th / 2), left: target.left - PADDING - TOOLTIP_GAP - tw, }, }; @@ -100,8 +93,13 @@ function computeTooltipPosition( export function TourOverlay() { const t = useTranslations(); - const { currentStep, totalSteps, steps, nextStep, prevStep, stopTour } = useTour(); + const { currentStep, totalSteps, steps, nextStep, prevStep, skipStep, stopTour } = useTour(); const step = steps[currentStep]; + // The same anchor can be in the app being left (the tab bar in Settings + // while Files loads): look for the target only once the step's app is up. + const pathname = usePathname(); + const onPageRef = useRef(true); + onPageRef.current = !step || isTourStepOnPage(step, pathname); const [targetRect, setTargetRect] = useState(null); const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null); @@ -112,14 +110,12 @@ export function TourOverlay() { // Use refs for callbacks to avoid stale closures in timers/intervals const updatePositionRef = useRef<() => void>(() => {}); - const nextStepRef = useRef<() => void>(() => {}); - const prevStepRef = useRef<() => void>(() => {}); + const skipStepRef = useRef<(direction: 1 | -1) => void>(() => {}); // A step with nothing to point at is skipped in the direction the user went. const directionRef = useRef<1 | -1>(1); const goNext = useCallback(() => { directionRef.current = 1; nextStep(); }, [nextStep]); const goPrev = useCallback(() => { directionRef.current = -1; prevStep(); }, [prevStep]); - nextStepRef.current = nextStep; - prevStepRef.current = prevStep; + skipStepRef.current = skipStep; const focusTrapRef = useFocusTrap({ isActive: visible, @@ -172,6 +168,7 @@ export function TourOverlay() { const tryFind = () => { if (cancelled) return true; + if (!onPageRef.current) return false; const el = findTourTarget(step.target); if (el) { el.scrollIntoView({ behavior: "smooth", block: "nearest" }); @@ -195,7 +192,9 @@ export function TourOverlay() { // Poll for the element to appear (another page loading). One that is in // the DOM but not rendered in this layout (the rail on a phone) is given // up on sooner. + let loading = 0; const interval = setInterval(() => { + if (!onPageRef.current && ++loading < PAGE_LOAD_ATTEMPTS) return; attempts++; if (tryFind()) { clearInterval(interval); @@ -205,8 +204,8 @@ export function TourOverlay() { if (attempts >= limit) { clearInterval(interval); if (cancelled) return; - if (directionRef.current === -1 && currentStep > 0) prevStepRef.current(); - else nextStepRef.current(); + // Dropped from this run, so the counter never counts it. + skipStepRef.current(directionRef.current); } }, 100); @@ -301,6 +300,7 @@ export function TourOverlay() { <> {/* SVG overlay with cutout */} ).current = node; (focusTrapRef as React.MutableRefObject).current = node; }} + data-tour-layer="" role="dialog" aria-modal="true" aria-label={t(step.titleKey)} + aria-hidden={!visible || undefined} className={cn( "fixed z-[9999] transition-all", - visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2" + // While the next target is looked for, the card is not there at + // all: an invisible card would still catch taps. + visible ? "opacity-100 translate-y-0" : "invisible opacity-0 translate-y-2" )} style={{ top: tooltipPos?.top ?? -9999, left: tooltipPos?.left ?? -9999, maxWidth: TOOLTIP_MAX_W, transition: reducedMotion ? "none" : "opacity 200ms ease, transform 200ms ease, top 300ms ease, left 300ms ease", - pointerEvents: "auto", + pointerEvents: visible ? "auto" : "none", }} onClick={(e) => e.stopPropagation()} > diff --git a/components/tour/tour-provider.tsx b/components/tour/tour-provider.tsx index 9d241e1b5..79bf37b1a 100644 --- a/components/tour/tour-provider.tsx +++ b/components/tour/tour-provider.tsx @@ -8,8 +8,9 @@ import { useCalendarStore } from "@/stores/calendar-store"; import { useWebDAVStore } from "@/stores/webdav-store"; import { useSettingsStore } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; -import { getTourSteps, type TourStep } from "./tour-steps"; -import { useMediaQuery } from "@/hooks/use-media-query"; +import { getTourSteps, isTourStepOnPage, type TourStep } from "./tour-steps"; +import { useIsMobile, useMediaQuery } from "@/hooks/use-media-query"; +import { requestAppCommand } from "@/lib/app-commands"; import { TourOverlay } from "./tour-overlay"; const TOUR_COMPLETED_KEY = "tour_completed"; @@ -24,6 +25,12 @@ interface TourContextValue { stopTour: () => void; nextStep: () => void; prevStep: () => void; + /** + * Drops the current step, which has nothing to point at, from this run of + * the tour, so the counter only ever counts steps that show. `direction` + * is the way the user was going. + */ + skipStep: (direction: 1 | -1) => void; hasCompletedTour: boolean; resetTourCompletion: () => void; } @@ -36,6 +43,12 @@ export function useTour() { return ctx; } +function rememberStep(step: TourStep | undefined) { + try { + if (step) localStorage.setItem(TOUR_CURRENT_STEP_KEY, step.id); + } catch { /* */ } +} + export function TourProvider({ children }: { children: ReactNode }) { const router = useRouter(); const pathname = usePathname(); @@ -54,14 +67,32 @@ export function TourProvider({ children }: { children: ReactNode }) { const client = useAuthStore((s) => s.client); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); const touch = useMediaQuery("(pointer: coarse)"); + const phone = useIsMobile(); + // Steps found to have nothing to point at during this run of the tour. + const [skipped, setSkipped] = useState([]); - const steps = getTourSteps({ + const allSteps = getTourSteps({ isDemoMode, supportsCalendar: supportsCalendar && calendarEnabled, supportsWebDAV: supportsWebDAV !== false, supportsContacts: (client?.supportsContacts?.() ?? false) && contactsEnabled, touch, + phone, }); + const steps = skipped.length ? allSteps.filter((s) => !skipped.includes(s.id)) : allSteps; + const step = isActive ? steps[currentStep] : undefined; + + // Take the user to the app the step is about. On a phone the open message + // covers the list and the tab bar, so every Mail step but the reader's own + // closes it first. + useEffect(() => { + if (!step) return; + if (!isTourStepOnPage(step, pathname)) { + router.push(step.page ?? "/"); + } else if (phone && !step.page && step.view !== "reader") { + requestAppCommand("show-list"); + } + }, [step, pathname, phone, router]); useEffect(() => { // One-time migration: if the legacy per-device flag is set but the synced @@ -88,29 +119,20 @@ export function TourProvider({ children }: { children: ReactNode }) { setHasCompletedTour(true); }, [tourCompleted, showOnboardingOnNewDevices]); + // A fresh run gives every step its chance again; the effect above takes the + // user to the first step's app. const startTour = useCallback(() => { let resumeStep = 0; try { const stored = localStorage.getItem(TOUR_CURRENT_STEP_KEY); - if (stored) { - const parsed = parseInt(stored, 10); - if (!isNaN(parsed) && parsed >= 0) resumeStep = parsed; - } + const index = stored ? allSteps.findIndex((s) => s.id === stored) : -1; + if (index >= 0) resumeStep = index; } catch { /* */ } - // If the resume step is beyond the current steps, start from 0 - if (resumeStep >= steps.length) resumeStep = 0; - - // Most steps live on the mailbox - navigate there (or to the step's specific page) - // so we don't start the tour on a page where the targets don't exist. - const targetPage = steps[resumeStep]?.page ?? "/"; - if (pathname !== targetPage) { - router.push(targetPage); - } - + setSkipped([]); setCurrentStep(resumeStep); setIsActive(true); - }, [steps, pathname, router]); + }, [allSteps]); const stopTour = useCallback(() => { setIsActive(false); @@ -134,38 +156,31 @@ export function TourProvider({ children }: { children: ReactNode }) { completeTour(); return; } - const next = currentStep + 1; - const nextStepDef = steps[next]; - const currentStepDef = steps[currentStep]; - setCurrentStep(next); - try { - localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next)); - } catch { /* */ } - - // Navigate when the next step is on a different page (treat "no page" as the mailbox) - const nextPage = nextStepDef?.page ?? "/"; - const currentPage = currentStepDef?.page ?? "/"; - if (nextPage !== currentPage) { - router.push(nextPage); - } - }, [currentStep, steps, completeTour, router]); + setCurrentStep(currentStep + 1); + rememberStep(steps[currentStep + 1]); + }, [currentStep, steps, completeTour]); const prevStep = useCallback(() => { if (currentStep <= 0) return; - const prev = currentStep - 1; - const prevStepDef = steps[prev]; - setCurrentStep(prev); - try { - localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(prev)); - } catch { /* */ } + setCurrentStep(currentStep - 1); + rememberStep(steps[currentStep - 1]); + }, [currentStep, steps]); - if (prevStepDef?.page) { - router.push(prevStepDef.page); - } else if (steps[currentStep]?.page) { - // Going back from a page-specific step to a non-page step => go to mail - router.push("/"); + const skipStep = useCallback((direction: 1 | -1) => { + const current = steps[currentStep]; + if (!current) return; + const rest = steps.filter((s) => s !== current); + // Going on, the next step moves up into this index; going back, the + // previous one is a place lower (at the start, the next one shows). + const index = direction === -1 && currentStep > 0 ? currentStep - 1 : currentStep; + if (index >= rest.length) { + completeTour(); + return; } - }, [currentStep, steps, router]); + setSkipped((ids) => [...ids, current.id]); + setCurrentStep(index); + rememberStep(rest[index]); + }, [currentStep, steps, completeTour]); const resetTourCompletion = useCallback(() => { setHasCompletedTour(false); @@ -185,6 +200,7 @@ export function TourProvider({ children }: { children: ReactNode }) { stopTour, nextStep, prevStep, + skipStep, hasCompletedTour, resetTourCompletion, }; diff --git a/components/tour/tour-steps.ts b/components/tour/tour-steps.ts index 4074e32a1..0f9639713 100644 --- a/components/tour/tour-steps.ts +++ b/components/tour/tour-steps.ts @@ -6,11 +6,60 @@ export interface TourStep { placement: "top" | "bottom" | "left" | "right"; interactive?: boolean; spotlight?: "rect" | "circle"; + /** The app the step is in; none means Mail. */ page?: string; + /** + * "reader": the step is in Mail's reading pane, which on a phone covers the + * list and the tab bar. Every other Mail step closes the reader there. + */ + view?: "reader"; + /** false: the target only exists in the rail or the folder drawer, so a phone never shows it. */ + phone?: false; demoOnly?: boolean; beforeAction?: () => void; } +/** + * The first element for the selector that the user can see: not in a closed + * drawer (inert), with a size, on screen sideways and not covered by + * something else (the open message over the phone's tab bar). The same anchor + * can exist twice (the rail and the phone's tab bar). + */ +export function findTourTarget(selector: string): Element | null { + for (const el of document.querySelectorAll(selector)) { + if (el.closest("[inert]")) continue; + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) continue; + if (r.right <= 0 || r.left >= window.innerWidth) continue; + if (isCovered(el, r)) continue; + return el; + } + return null; +} + +/** Whether the middle of an on-screen element is under another element (the tour's own layers aside). */ +function isCovered(el: Element, r: DOMRect): boolean { + if (typeof document.elementsFromPoint !== "function") return false; + const x = r.left + r.width / 2; + const y = r.top + r.height / 2; + // Below or above the fold: scrolled into view before it is shown. + if (y < 0 || y > window.innerHeight || x < 0 || x > window.innerWidth) return false; + const top = document.elementsFromPoint(x, y).find((hit) => !hit.closest("[data-tour-layer]")); + return !!top && top !== el && !el.contains(top) && !top.contains(el); +} + +/** Whether the user is in the app the step is about. */ +export function isTourStepOnPage(step: Pick, pathname: string): boolean { + const page = step.page && step.page !== "/" ? step.page : null; + if (!page) return pathname === "/" || pathname === "" || pathname === "/mail" || pathname.startsWith("/mail/"); + return pathname === page || pathname.startsWith(page + "/"); +} + +/** Clicks the visible copy of a tour anchor (the phone's compose button, not the closed drawer's). */ +function clickTourTarget(selector: string): void { + (findTourTarget(selector) as HTMLElement | null)?.click(); +} + export const BASE_TOUR_STEPS: TourStep[] = [ { id: "sidebar", @@ -18,6 +67,7 @@ export const BASE_TOUR_STEPS: TourStep[] = [ titleKey: "tour.sidebar_title", descriptionKey: "tour.sidebar_desc", placement: "right", + phone: false, }, { id: "compose", @@ -48,6 +98,7 @@ export const BASE_TOUR_STEPS: TourStep[] = [ titleKey: "tour.email_viewer_title", descriptionKey: "tour.email_viewer_desc", placement: "left", + view: "reader", beforeAction: () => { // Click the "Welcome to Bulwark Mail!" email (or the first email) to open the viewer const emailList = document.querySelector('[data-tour="email-list"]'); @@ -72,6 +123,7 @@ export const BASE_TOUR_STEPS: TourStep[] = [ titleKey: "tour.keywords_title", descriptionKey: "tour.keywords_desc", placement: "right", + phone: false, }, { id: "nav-calendar", @@ -111,12 +163,9 @@ export const DEMO_TOUR_STEPS: TourStep[] = [ titleKey: "tour.compose_open_title", descriptionKey: "tour.compose_open_desc", placement: "left", + view: "reader", demoOnly: true, - beforeAction: () => { - // Click the compose button to open the composer - const btn = document.querySelector('[data-tour="compose-button"]') as HTMLElement | null; - if (btn) btn.click(); - }, + beforeAction: () => clickTourTarget('[data-tour="compose-button"]'), }, { id: "calendar-view", @@ -146,11 +195,7 @@ export const DEMO_TOUR_STEPS: TourStep[] = [ page: "/calendar", interactive: true, demoOnly: true, - beforeAction: () => { - // Click the create event button to open the modal - const btn = document.querySelector('[data-tour="create-event-button"]') as HTMLElement | null; - if (btn) btn.click(); - }, + beforeAction: () => clickTourTarget('[data-tour="create-event-button"]'), }, { id: "contacts-list", @@ -176,6 +221,8 @@ export const DEMO_TOUR_STEPS: TourStep[] = [ titleKey: "tour.files_title", descriptionKey: "tour.files_desc", placement: "right", + // Shown over the Files app, pointing at its place in the rail or tab bar. + page: "/files", demoOnly: true, }, { @@ -184,7 +231,7 @@ export const DEMO_TOUR_STEPS: TourStep[] = [ titleKey: "tour.demo_banner_title", descriptionKey: "tour.demo_banner_desc", placement: "bottom", - page: "/", + phone: false, demoOnly: true, }, { @@ -193,6 +240,7 @@ export const DEMO_TOUR_STEPS: TourStep[] = [ titleKey: "tour.quota_title", descriptionKey: "tour.quota_desc", placement: "right", + phone: false, demoOnly: true, }, ]; @@ -205,6 +253,8 @@ export function getTourSteps(options: { supportsContacts?: boolean; /** A touch screen: no keyboard to use the shortcuts with. */ touch?: boolean; + /** The phone layout: no rail, the folders in a drawer. */ + phone?: boolean; }): TourStep[] { let steps = [...BASE_TOUR_STEPS]; @@ -227,5 +277,9 @@ export function getTourSteps(options: { steps = [...steps, ...demoSteps]; } + if (options.phone) { + steps = steps.filter((s) => s.phone !== false); + } + return steps; } diff --git a/lib/app-commands.ts b/lib/app-commands.ts index b5298775d..d3bbf1c47 100644 --- a/lib/app-commands.ts +++ b/lib/app-commands.ts @@ -8,7 +8,7 @@ import { useEffect, useRef } from 'react'; -export type AppCommand = 'compose' | 'search' | 'all-inboxes'; +export type AppCommand = 'compose' | 'search' | 'all-inboxes' | 'show-list'; export const COMMAND_PALETTE_OPEN_EVENT = 'bulwark:command-palette'; 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 23/54] 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 945fd219a7ca5a8cb4baa3970fe408232c963da3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:20:41 +0100 Subject: [PATCH 24/54] fix: point the mail client hint to the app passwords section below it --- lib/__tests__/settings-wording.test.ts | 5 +++++ locales/ar/common.json | 2 +- locales/ca/common.json | 2 +- locales/cs/common.json | 2 +- locales/da/common.json | 2 +- locales/de/common.json | 2 +- locales/en/common.json | 2 +- locales/es/common.json | 2 +- locales/et/common.json | 2 +- locales/fa/common.json | 2 +- locales/fr/common.json | 2 +- locales/he/common.json | 2 +- locales/hu/common.json | 2 +- locales/it/common.json | 2 +- locales/ja/common.json | 2 +- locales/ko/common.json | 2 +- locales/lv/common.json | 2 +- locales/mn/common.json | 2 +- locales/nb/common.json | 2 +- locales/nl/common.json | 2 +- locales/pl/common.json | 2 +- locales/pt/common.json | 2 +- locales/ro/common.json | 2 +- locales/ru/common.json | 2 +- locales/sk/common.json | 2 +- locales/th/common.json | 2 +- locales/tr/common.json | 2 +- locales/uk/common.json | 2 +- locales/zh-TW/common.json | 2 +- locales/zh/common.json | 2 +- 30 files changed, 34 insertions(+), 29 deletions(-) diff --git a/lib/__tests__/settings-wording.test.ts b/lib/__tests__/settings-wording.test.ts index c9245cf69..2672267eb 100644 --- a/lib/__tests__/settings-wording.test.ts +++ b/lib/__tests__/settings-wording.test.ts @@ -61,6 +61,11 @@ describe('settings wording', () => { expect(text('notifications.filters_saved')).toBe('Filters saved'); }); + // Security: App passwords come after the mail client section. + it('sends the mail client hint to the app passwords section below it', () => { + expect(text('settings.security.email_client.password_instructions')).toContain('in the section below'); + }); + // R5: every editor asks the same question with the same buttons, in every // language: Settings and Contacts say what Calendar says. it('asks "Discard changes?" with Keep editing / Discard everywhere', () => { diff --git a/locales/ar/common.json b/locales/ar/common.json index 031813254..fc87c4acd 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2010,7 +2010,7 @@ "jmap_username_label": "اسم مستخدم JMAP", "copy": "نسخ", "copied": "تم النسخ", - "password_instructions": "استخدم اسم مستخدم JMAP أعلاه مع كلمة مرور تطبيق لتسجيل الدخول إلى عميل البريد الخاص بك. أنشئ كلمة مرور تطبيق في القسم أعلاه إذا لم تفعل بعد." + "password_instructions": "استخدم اسم مستخدم JMAP أعلاه مع كلمة مرور تطبيق لتسجيل الدخول إلى عميل البريد الخاص بك. أنشئ كلمة مرور تطبيق في القسم أدناه إذا لم تفعل بعد." }, "link_device": { "title": "ربط تطبيق الجوال", diff --git a/locales/ca/common.json b/locales/ca/common.json index b0624d8a4..2d3ddb386 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2010,7 +2010,7 @@ "jmap_username_label": "Nom d'usuari JMAP", "copy": "Copia", "copied": "Copiat", - "password_instructions": "Utilitzeu el nom d'usuari JMAP anterior juntament amb una contrasenya d'aplicació per iniciar la sessió al client de correu. Creeu una contrasenya d'aplicació a la secció anterior si encara no ho heu fet." + "password_instructions": "Utilitzeu el nom d'usuari JMAP anterior juntament amb una contrasenya d'aplicació per iniciar la sessió al client de correu. Creeu una contrasenya d'aplicació a la secció següent si encara no ho heu fet." }, "link_device": { "title": "Enllaça l'aplicació mòbil", diff --git a/locales/cs/common.json b/locales/cs/common.json index 76fc49c44..f05a13556 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "Uživatelské jméno JMAP", "copy": "Kopírovat", "copied": "Zkopírováno", - "password_instructions": "Použijte výše uvedené uživatelské jméno JMAP společně s heslem aplikace pro přihlášení v poštovním klientovi. Pokud jste tak ještě neučinili, vytvořte si heslo aplikace v sekci výše." + "password_instructions": "Použijte výše uvedené uživatelské jméno JMAP společně s heslem aplikace pro přihlášení v poštovním klientovi. Pokud jste tak ještě neučinili, vytvořte si heslo aplikace v sekci níže." }, "link_device": { "title": "Propojit mobilní aplikaci", diff --git a/locales/da/common.json b/locales/da/common.json index f062a2935..c74a9d2d3 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2006,7 +2006,7 @@ "jmap_username_label": "JMAP-brugernavn", "copy": "Kopier", "copied": "Kopieret", - "password_instructions": "Brug dit JMAP-brugernavn ovenfor sammen med en app-adgangskode for at logge ind i din e-mailklient. Opret en app-adgangskode i sektionen ovenfor, hvis du ikke allerede har gjort det." + "password_instructions": "Brug dit JMAP-brugernavn ovenfor sammen med en app-adgangskode for at logge ind i din e-mailklient. Opret en app-adgangskode i sektionen nedenfor, hvis du ikke allerede har gjort det." }, "link_device": { "title": "Tilknyt mobilapp", diff --git a/locales/de/common.json b/locales/de/common.json index 3bfcd34b0..e2295b354 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "JMAP-Benutzername", "copy": "Kopieren", "copied": "Kopiert", - "password_instructions": "Verwenden Sie Ihren oben angezeigten JMAP-Benutzernamen zusammen mit einem App-Passwort, um sich bei Ihrem E-Mail-Client anzumelden. Erstellen Sie im obigen Abschnitt ein App-Passwort, falls Sie noch keines haben." + "password_instructions": "Verwenden Sie Ihren oben angezeigten JMAP-Benutzernamen zusammen mit einem App-Passwort, um sich bei Ihrem E-Mail-Client anzumelden. Erstellen Sie im Abschnitt unten ein App-Passwort, falls Sie noch keines haben." }, "link_device": { "title": "Mobile App verknüpfen", diff --git a/locales/en/common.json b/locales/en/common.json index 4ffa2b423..78b7a21e5 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2024,7 +2024,7 @@ "jmap_username_label": "JMAP username", "copy": "Copy", "copied": "Copied", - "password_instructions": "Use your JMAP username above along with an app password to sign in to your email client. Create an app password in the section above if you haven't already." + "password_instructions": "Use your JMAP username above along with an app password to sign in to your email client. Create an app password in the section below if you haven't already." }, "link_device": { "title": "Link the mobile app", diff --git a/locales/es/common.json b/locales/es/common.json index 86ad684d7..6a8d152e5 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "Nombre de usuario JMAP", "copy": "Copiar", "copied": "Copiado", - "password_instructions": "Use su nombre de usuario JMAP indicado arriba junto con una contraseña de aplicación para iniciar sesión en su cliente de correo. Cree una contraseña de aplicación en la sección anterior si aún no lo ha hecho." + "password_instructions": "Use su nombre de usuario JMAP indicado arriba junto con una contraseña de aplicación para iniciar sesión en su cliente de correo. Cree una contraseña de aplicación en la sección de abajo si aún no lo ha hecho." }, "link_device": { "title": "Vincular aplicación móvil", diff --git a/locales/et/common.json b/locales/et/common.json index 4e366c410..564baaadd 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -2024,7 +2024,7 @@ "jmap_username_label": "JMAP kasutajanimi", "copy": "Kopeeri", "copied": "Kopeeritud", - "password_instructions": "Kasuta oma e-posti kliendisse sisselogimiseks ülaltoodud JMAP kasutajanime koos rakenduse parooliga. Kui sul veel pole rakenduse parooli, loo see ülaltoodud jaotises." + "password_instructions": "Kasuta oma e-posti kliendisse sisselogimiseks ülaltoodud JMAP kasutajanime koos rakenduse parooliga. Kui sul veel pole rakenduse parooli, loo see allolevas jaotises." }, "link_device": { "title": "Ühenda mobiilirakendus", diff --git a/locales/fa/common.json b/locales/fa/common.json index cb36a4136..18bf26732 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2010,7 +2010,7 @@ "jmap_username_label": "نام کاربری JMAP", "copy": "کپی", "copied": "کپی شد", - "password_instructions": "با رمز برنامه استفاده کنید" + "password_instructions": "از نام کاربری JMAP بالا همراه با یک رمز برنامه برای ورود به برنامهٔ ایمیل خود استفاده کنید. اگر هنوز رمز برنامه نساخته‌اید، آن را در بخش زیر بسازید." }, "link_device": { "title": "اتصال موبایل", diff --git a/locales/fr/common.json b/locales/fr/common.json index efb7c7e71..44d05bf46 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "Nom d'utilisateur JMAP", "copy": "Copier", "copied": "Copié", - "password_instructions": "Utilisez votre nom d'utilisateur JMAP ci-dessus avec un mot de passe d'application pour vous connecter à votre client de messagerie. Créez un mot de passe d'application dans la section ci-dessus si vous ne l'avez pas encore fait." + "password_instructions": "Utilisez votre nom d'utilisateur JMAP ci-dessus avec un mot de passe d'application pour vous connecter à votre client de messagerie. Créez un mot de passe d'application dans la section ci-dessous si vous ne l'avez pas encore fait." }, "link_device": { "title": "Associer l'application mobile", diff --git a/locales/he/common.json b/locales/he/common.json index 107d33578..d5c565350 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1954,7 +1954,7 @@ "jmap_username_label": "שם משתמש JMAP", "copy": "העתק", "copied": "הועתק", - "password_instructions": "השתמש בשם המשתמש שלך ב-JMAP למעלה יחד עם סיסמת אפליקציה כדי להיכנס ללקוח הדוא\"ל שלך. צור סיסמת אפליקציה בסעיף למעלה אם עדיין לא עשית זאת." + "password_instructions": "השתמש בשם המשתמש שלך ב-JMAP למעלה יחד עם סיסמת אפליקציה כדי להיכנס ללקוח הדוא\"ל שלך. צור סיסמת אפליקציה בסעיף למטה אם עדיין לא עשית זאת." }, "api_keys": { "title": "מפתחות API", diff --git a/locales/hu/common.json b/locales/hu/common.json index bd808f20c..cc04a4d52 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2006,7 +2006,7 @@ "jmap_username_label": "JMAP felhasználónév", "copy": "Másolás", "copied": "Másolva", - "password_instructions": "Használd a fenti JMAP felhasználóneved egy alkalmazás jelszóval együtt az e-mail kliensbe való bejelentkezéshez. Hozz létre egy alkalmazás jelszót a fenti részben, ha még nem tetted." + "password_instructions": "Használd a fenti JMAP felhasználóneved egy alkalmazás jelszóval együtt az e-mail kliensbe való bejelentkezéshez. Hozz létre egy alkalmazás jelszót a lenti részben, ha még nem tetted." }, "link_device": { "title": "Mobilalkalmazás összekapcsolása", diff --git a/locales/it/common.json b/locales/it/common.json index 23c75e930..9e6e69f5f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "Nome utente JMAP", "copy": "Copia", "copied": "Copiato", - "password_instructions": "Usa il tuo nome utente JMAP sopra indicato insieme a una password dell'app per accedere al tuo client e-mail. Crea una password dell'app nella sezione sopra se non l'hai ancora fatto." + "password_instructions": "Usa il tuo nome utente JMAP sopra indicato insieme a una password dell'app per accedere al tuo client e-mail. Crea una password dell'app nella sezione sotto se non l'hai ancora fatto." }, "link_device": { "title": "Collega l'app mobile", diff --git a/locales/ja/common.json b/locales/ja/common.json index 88ca69f23..ecd81e198 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "JMAPユーザー名", "copy": "コピー", "copied": "コピー済み", - "password_instructions": "上記のJMAPユーザー名とアプリパスワードを使用してメールクライアントにサインインします。まだ作成していない場合は、上のセクションでアプリパスワードを作成してください。" + "password_instructions": "上記のJMAPユーザー名とアプリパスワードを使用してメールクライアントにサインインします。まだ作成していない場合は、下のセクションでアプリパスワードを作成してください。" }, "link_device": { "title": "モバイルアプリを連携", diff --git a/locales/ko/common.json b/locales/ko/common.json index d60cb13aa..8f7b9ac91 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "JMAP 사용자 이름", "copy": "복사", "copied": "복사되었어요", - "password_instructions": "위의 JMAP 사용자 이름과 앱 비밀번호를 사용해서 이메일 클라이언트에 로그인하세요. 아직 만들지 않았다면 위에서 앱 비밀번호를 먼저 만들어 주세요." + "password_instructions": "위의 JMAP 사용자 이름과 앱 비밀번호를 사용해서 이메일 클라이언트에 로그인하세요. 아직 만들지 않았다면 아래에서 앱 비밀번호를 먼저 만들어 주세요." }, "link_device": { "title": "모바일 앱 연결", diff --git a/locales/lv/common.json b/locales/lv/common.json index b043b1a6c..46f6ae102 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "JMAP lietotājvārds", "copy": "Kopēt", "copied": "Nokopēts", - "password_instructions": "Izmantojiet norādīto JMAP lietotājvārdu kopā ar lietotnes paroli. Izveidojiet lietotnes paroli sadaļā augstāk." + "password_instructions": "Izmantojiet augstāk norādīto JMAP lietotājvārdu kopā ar lietotnes paroli, lai pierakstītos e-pasta klientā. Ja vēl neesat to izdarījis, izveidojiet lietotnes paroli sadaļā zemāk." }, "link_device": { "title": "Saistīt mobilo lietotni", diff --git a/locales/mn/common.json b/locales/mn/common.json index 103d1523a..f871c0a08 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2024,7 +2024,7 @@ "jmap_username_label": "JMAP хэрэглэгчийн нэр", "copy": "Хуулбар", "copied": "Хуулбарласан", - "password_instructions": "Имэйл клиентдээ нэвтрэхийн тулд дээрх JMAP хэрэглэгчийн нэрээ програмын нууц үгийн хамт ашиглана уу. Хэрэв та хийгээгүй бол дээрх хэсэгт програмын нууц үг үүсгэнэ үү." + "password_instructions": "Имэйл клиентдээ нэвтрэхийн тулд дээрх JMAP хэрэглэгчийн нэрээ програмын нууц үгийн хамт ашиглана уу. Хэрэв та хийгээгүй бол доорх хэсэгт програмын нууц үг үүсгэнэ үү." }, "link_device": { "title": "Мобайл програмыг холбоно уу", diff --git a/locales/nb/common.json b/locales/nb/common.json index 35692689d..6c5beb2f6 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2024,7 +2024,7 @@ "jmap_username_label": "JMAP-brukernavn", "copy": "Kopier", "copied": "Kopiert", - "password_instructions": "Bruk JMAP-brukernavnet ovenfor sammen med et app-passord for å logge på e-postklienten. Opprett et app-passord i delen ovenfor hvis du ikke allerede har gjort det." + "password_instructions": "Bruk JMAP-brukernavnet ovenfor sammen med et app-passord for å logge på e-postklienten. Opprett et app-passord i delen nedenfor hvis du ikke allerede har gjort det." }, "link_device": { "title": "Koble til mobilappen", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3d0a27361..6602dcc9d 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "JMAP-gebruikersnaam", "copy": "Kopiëren", "copied": "Gekopieerd", - "password_instructions": "Gebruik uw hierboven vermelde JMAP-gebruikersnaam samen met een app-wachtwoord om in te loggen bij uw e-mailclient. Maak een app-wachtwoord aan in het bovenstaande gedeelte als u dat nog niet heeft gedaan." + "password_instructions": "Gebruik uw hierboven vermelde JMAP-gebruikersnaam samen met een app-wachtwoord om in te loggen bij uw e-mailclient. Maak een app-wachtwoord aan in het gedeelte hieronder als u dat nog niet heeft gedaan." }, "link_device": { "title": "Mobiele app koppelen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 4ba98decb..59f412ab0 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "Nazwa użytkownika JMAP", "copy": "Kopiuj", "copied": "Skopiowano", - "password_instructions": "Użyj powyższej nazwy użytkownika JMAP wraz z hasłem aplikacji, aby zalogować się w kliencie poczty. Utwórz hasło aplikacji w sekcji powyżej, jeśli jeszcze tego nie zrobiłeś." + "password_instructions": "Użyj powyższej nazwy użytkownika JMAP wraz z hasłem aplikacji, aby zalogować się w kliencie poczty. Utwórz hasło aplikacji w sekcji poniżej, jeśli jeszcze tego nie zrobiłeś." }, "link_device": { "title": "Połącz aplikację mobilną", diff --git a/locales/pt/common.json b/locales/pt/common.json index ad28659db..5c487790c 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "Nome de Usuário JMAP", "copy": "Copiar", "copied": "Copiado", - "password_instructions": "Use seu nome de usuário JMAP acima junto com uma senha de aplicativo para entrar no seu cliente de e-mail. Crie uma senha de aplicativo na seção acima se ainda não tiver uma." + "password_instructions": "Use seu nome de usuário JMAP acima junto com uma senha de aplicativo para entrar no seu cliente de e-mail. Crie uma senha de aplicativo na seção abaixo se ainda não tiver uma." }, "link_device": { "title": "Vincular aplicativo móvel", diff --git a/locales/ro/common.json b/locales/ro/common.json index f5034810f..54d55b9c6 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2010,7 +2010,7 @@ "jmap_username_label": "JMAP Nume de utilizator", "copy": "Copiere", "copied": "Copiat", - "password_instructions": "Folosiți numele de utilizator de la JMAP menționat mai sus, împreună cu o parolă de aplicație, pentru a vă autentifica în clientul de e-mail. Creați o parolă de aplicație în secțiunea de mai sus, dacă nu ați făcut-o deja." + "password_instructions": "Folosiți numele de utilizator de la JMAP menționat mai sus, împreună cu o parolă de aplicație, pentru a vă autentifica în clientul de e-mail. Creați o parolă de aplicație în secțiunea de mai jos, dacă nu ați făcut-o deja." }, "link_device": { "title": "Aplicație mobilă", diff --git a/locales/ru/common.json b/locales/ru/common.json index 41f180584..28b11a12a 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "Имя пользователя JMAP", "copy": "Копировать", "copied": "Скопировано", - "password_instructions": "Используйте указанное выше имя пользователя JMAP вместе с паролем приложения для входа в почтовый клиент. Создайте пароль приложения в разделе выше, если вы ещё этого не сделали." + "password_instructions": "Используйте указанное выше имя пользователя JMAP вместе с паролем приложения для входа в почтовый клиент. Создайте пароль приложения в разделе ниже, если вы ещё этого не сделали." }, "link_device": { "title": "Привязать мобильное приложение", diff --git a/locales/sk/common.json b/locales/sk/common.json index 9bcf26ed2..4a6b34d53 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2010,7 +2010,7 @@ "jmap_username_label": "Používateľské meno JMAP", "copy": "Kopírovať", "copied": "Skopírované", - "password_instructions": "Použite vyššie uvedené používateľské meno JMAP spolu s heslom aplikácie na prihlásenie v poštovom klientovi." + "password_instructions": "Použite vyššie uvedené používateľské meno JMAP spolu s heslom aplikácie na prihlásenie v poštovom klientovi. Ak ste tak ešte neurobili, vytvorte si heslo aplikácie v sekcii nižšie." }, "link_device": { "title": "Prepojiť mobilnú aplikáciu", diff --git a/locales/th/common.json b/locales/th/common.json index 44a9fe238..463a126c6 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4349,7 +4349,7 @@ "jmap_username_label": "ชื่อผู้ใช้ JMAP", "copy": "คัดลอก", "copied": "คัดลอกแล้ว", - "password_instructions": "ใช้ชื่อผู้ใช้ JMAP ด้านบนควบคู่ไปกับรหัสผ่านแอปเพื่อลงชื่อเข้าใช้โปรแกรมอีเมลของคุณ สร้างรหัสผ่านแอปในส่วนด้านบนหากคุณยังไม่ได้สร้าง" + "password_instructions": "ใช้ชื่อผู้ใช้ JMAP ด้านบนควบคู่ไปกับรหัสผ่านแอปเพื่อลงชื่อเข้าใช้โปรแกรมอีเมลของคุณ สร้างรหัสผ่านแอปในส่วนด้านล่างหากคุณยังไม่ได้สร้าง" }, "link_device": { "title": "เชื่อมโยงแอปบนมือถือ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 85a78614e..053f4fb3d 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "JMAP Kullanıcı Adı", "copy": "Kopyala", "copied": "Kopyalandı", - "password_instructions": "E-posta istemcinizde oturum açmak için yukarıdaki JMAP kullanıcı adı ile bir uygulama parolasını birlikte kullanın. Henüz oluşturmadıysanız yukarıdaki bölümden bir uygulama parolası oluşturun." + "password_instructions": "E-posta istemcinizde oturum açmak için yukarıdaki JMAP kullanıcı adı ile bir uygulama parolasını birlikte kullanın. Henüz oluşturmadıysanız aşağıdaki bölümden bir uygulama parolası oluşturun." }, "link_device": { "title": "Mobil uygulamayı bağla", diff --git a/locales/uk/common.json b/locales/uk/common.json index 7fbdeaae5..4c9a961b0 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "Ім'я користувача JMAP", "copy": "Копія", "copied": "Скопійовано", - "password_instructions": "Використовуйте своє ім’я користувача JMAP вище разом із паролем програми, щоб увійти у свій поштовий клієнт. Створіть пароль програми в розділі вище, якщо ви ще цього не зробили." + "password_instructions": "Використовуйте своє ім’я користувача JMAP вище разом із паролем програми, щоб увійти у свій поштовий клієнт. Створіть пароль програми в розділі нижче, якщо ви ще цього не зробили." }, "link_device": { "title": "Прив'язати мобільний застосунок", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 828e6255e..d4c0791d6 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2024,7 +2024,7 @@ "jmap_username_label": "JMAP 使用者名稱", "copy": "複製", "copied": "已複製", - "password_instructions": "使用上方的 JMAP 使用者名稱與應用程式密碼登入電子郵件用戶端。如果尚未建立應用程式密碼,請在上方區段建立一組。" + "password_instructions": "使用上方的 JMAP 使用者名稱與應用程式密碼登入電子郵件用戶端。如果尚未建立應用程式密碼,請在下方區段建立一組。" }, "link_device": { "title": "連結行動應用程式", diff --git a/locales/zh/common.json b/locales/zh/common.json index 6a0577260..4fdfb43c5 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2003,7 +2003,7 @@ "jmap_username_label": "JMAP 用户名", "copy": "复制", "copied": "已复制", - "password_instructions": "使用上面的 JMAP 用户名和应用密码登录您的邮件客户端。如果您还没有创建应用密码,请在上面的部分中创建。" + "password_instructions": "使用上面的 JMAP 用户名和应用密码登录您的邮件客户端。如果您还没有创建应用密码,请在下面的部分中创建。" }, "link_device": { "title": "关联移动应用", From 4d736740a5ae8f90111ffd5a9702e09f33150aaa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:21:14 +0100 Subject: [PATCH 25/54] fix: offer clear search when a calendar search finds nothing --- components/calendar/__tests__/calendar-app.test.tsx | 12 ++++++++++++ components/calendar/calendar-app.tsx | 1 + components/calendar/calendar-search.tsx | 10 +++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index 323b411a3..24ce2f577 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -352,6 +352,18 @@ describe('CalendarApp event search (CAL-16)', () => { expect(await screen.findByText('search.no_results', {}, { timeout: 3000 })).toBeInTheDocument(); }); + it('offers Clear search when nothing matches, back to the calendar with the cursor in the box', async () => { + renderApp(); + const box = screen.getByRole('searchbox', { name: 'search.title' }); + fireEvent.change(box, { target: { value: 'zzz' } }); + await screen.findByText('search.no_results', {}, { timeout: 3000 }); + const results = screen.getByTestId('calendar-search-results'); + fireEvent.click(within(results).getByRole('button', { name: 'search.clear' })); + expect(box).toHaveValue(''); + expect(document.activeElement).toBe(box); + await waitFor(() => expect(screen.queryByTestId('calendar-search-results')).toBeNull()); + }); + it('is in the command palette', () => { render(<>); const items = screen.getByTestId('palette').textContent ?? ''; diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 7eca930b6..b9d63985b 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -1795,6 +1795,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = currentUserEmails={currentUserEmails} onSelectEvent={handleSelectEvent} onContextMenuEvent={handleContextMenuEvent} + onClearSearch={() => { setSearchQuery(""); searchInputRef.current?.focus(); }} /> ); } diff --git a/components/calendar/calendar-search.tsx b/components/calendar/calendar-search.tsx index 283cb8551..a46b9d5e6 100644 --- a/components/calendar/calendar-search.tsx +++ b/components/calendar/calendar-search.tsx @@ -6,6 +6,7 @@ import { format } from "date-fns"; import { Search, X, MapPin, Repeat } from "@/components/icons"; import { Input } from "@/components/ui/input"; import { IconButton } from "@/components/ui/icon-button"; +import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; @@ -88,11 +89,13 @@ interface CalendarSearchResultsProps { currentUserEmails?: string[]; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; + /** Empties the search box, back to the calendar (R9). */ + onClearSearch?: () => void; } /** The events a calendar search found, upcoming first, then past ones. */ export function CalendarSearchResults({ - query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent, + query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent, onClearSearch, }: CalendarSearchResultsProps) { const t = useTranslations("calendar"); const intlFormatter = useDisplayDateFormatter(); @@ -199,6 +202,11 @@ export function CalendarSearchResults({ icon={Search} title={t("search.no_results", { query: query.trim() })} description={t("search.no_results_hint")} + actions={onClearSearch && ( + + )} /> )} {section("calendar-search-upcoming", t("search.upcoming"), upcoming)} From a9468929fdc9d1f5ebf106e22b8d68b710edaa66 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:21:35 +0100 Subject: [PATCH 26/54] fix: say that address books and contact categories can be deleted in settings, not only renamed --- lib/__tests__/settings-wording.test.ts | 6 ++++++ locales/ar/common.json | 4 ++-- locales/ca/common.json | 4 ++-- locales/cs/common.json | 4 ++-- locales/da/common.json | 4 ++-- locales/de/common.json | 4 ++-- locales/en/common.json | 4 ++-- locales/es/common.json | 4 ++-- locales/et/common.json | 4 ++-- locales/fa/common.json | 4 ++-- locales/fr/common.json | 4 ++-- locales/he/common.json | 4 ++-- locales/hu/common.json | 4 ++-- locales/it/common.json | 4 ++-- locales/ja/common.json | 4 ++-- locales/ko/common.json | 4 ++-- locales/lv/common.json | 4 ++-- locales/mn/common.json | 4 ++-- locales/nb/common.json | 4 ++-- locales/nl/common.json | 4 ++-- locales/pl/common.json | 4 ++-- locales/pt/common.json | 4 ++-- locales/ro/common.json | 4 ++-- locales/ru/common.json | 4 ++-- locales/sk/common.json | 4 ++-- locales/th/common.json | 4 ++-- locales/tr/common.json | 4 ++-- locales/uk/common.json | 4 ++-- locales/zh-TW/common.json | 4 ++-- locales/zh/common.json | 4 ++-- 30 files changed, 64 insertions(+), 58 deletions(-) diff --git a/lib/__tests__/settings-wording.test.ts b/lib/__tests__/settings-wording.test.ts index 2672267eb..c98fbf14d 100644 --- a/lib/__tests__/settings-wording.test.ts +++ b/lib/__tests__/settings-wording.test.ts @@ -66,6 +66,12 @@ describe('settings wording', () => { expect(text('settings.security.email_client.password_instructions')).toContain('in the section below'); }); + // Settings > Contacts deletes address books and categories as well. + it('says the contact lists can be deleted, not only renamed', () => { + expect(text('settings.contacts.manage_description')).toBe('Rename or delete your address books'); + expect(text('settings.contacts.categories_description')).toBe('Rename or delete contact categories'); + }); + // R5: every editor asks the same question with the same buttons, in every // language: Settings and Contacts say what Calendar says. it('asks "Discard changes?" with Keep editing / Discard everywhere', () => { diff --git a/locales/ar/common.json b/locales/ar/common.json index fc87c4acd..f858d5cae 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2339,10 +2339,10 @@ "export_label": "تصدير جهات الاتصال", "export_description": "تصدير جميع جهات الاتصال كملف vCard (‎.vcf)", "manage_title": "دفاتر العناوين", - "manage_description": "إعادة تسمية دفاتر عناوينك", + "manage_description": "إعادة تسمية دفاتر عناوينك أو حذفها", "no_address_books": "لم يتم العثور على دفاتر عناوين", "categories_title": "الفئات", - "categories_description": "إعادة تسمية فئات جهات الاتصال", + "categories_description": "إعادة تسمية فئات جهات الاتصال أو حذفها", "no_categories": "لم يتم العثور على فئات" }, "calendar_management": { diff --git a/locales/ca/common.json b/locales/ca/common.json index 2d3ddb386..9d19ad99a 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2339,10 +2339,10 @@ "export_label": "Exporta contactes", "export_description": "Exporteu tots els contactes com a fitxer vCard (.vcf)", "manage_title": "Llibretes d'adreces", - "manage_description": "Canvieu el nom de les llibretes d'adreces", + "manage_description": "Canvieu el nom de les llibretes d'adreces o suprimiu-les", "no_address_books": "No s'ha trobat cap llibreta d'adreces", "categories_title": "Categories", - "categories_description": "Canvieu el nom de les categories de contactes", + "categories_description": "Canvieu el nom de les categories de contactes o suprimiu-les", "no_categories": "No s'ha trobat cap categoria" }, "calendar_management": { diff --git a/locales/cs/common.json b/locales/cs/common.json index f05a13556..ab2f21138 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2332,10 +2332,10 @@ "export_label": "Exportovat kontakty", "export_description": "Exportovat všechny kontakty jako soubor vCard (.vcf)", "manage_title": "Adresáře", - "manage_description": "Přejmenovat své adresáře", + "manage_description": "Přejmenovat nebo smazat své adresáře", "no_address_books": "Nenalezeny žádné adresáře", "categories_title": "Kategorie", - "categories_description": "Přejmenovat kategorie kontaktů", + "categories_description": "Přejmenovat nebo smazat kategorie kontaktů", "no_categories": "Nenalezeny žádné kategorie" }, "calendar_management": { diff --git a/locales/da/common.json b/locales/da/common.json index c74a9d2d3..5a30d3d01 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2335,10 +2335,10 @@ "export_label": "Eksportér kontakter", "export_description": "Eksportér alle kontakter som en vCard-fil (.vcf)", "manage_title": "Adressebøger", - "manage_description": "Omdøb dine adressebøger", + "manage_description": "Omdøb eller slet dine adressebøger", "no_address_books": "Ingen adressebøger fundet", "categories_title": "Kategorier", - "categories_description": "Omdøb kontaktkategorier", + "categories_description": "Omdøb eller slet kontaktkategorier", "no_categories": "Ingen kategorier fundet" }, "calendar_management": { diff --git a/locales/de/common.json b/locales/de/common.json index e2295b354..e18487406 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2328,10 +2328,10 @@ "export_label": "Kontakte exportieren", "export_description": "Alle Kontakte als vCard-Datei (.vcf) exportieren", "manage_title": "Adressbücher", - "manage_description": "Adressbücher umbenennen", + "manage_description": "Adressbücher umbenennen oder löschen", "no_address_books": "Keine Adressbücher gefunden", "categories_title": "Kategorien", - "categories_description": "Kontaktkategorien umbenennen", + "categories_description": "Kontaktkategorien umbenennen oder löschen", "no_categories": "Keine Kategorien gefunden", "group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen", "group_by_letter_label": "Nach Anfangsbuchstaben gruppieren", diff --git a/locales/en/common.json b/locales/en/common.json index 78b7a21e5..a7d3b738d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2339,10 +2339,10 @@ "export_label": "Export contacts", "export_description": "Export all contacts as a vCard (.vcf) file", "manage_title": "Address books", - "manage_description": "Rename your address books", + "manage_description": "Rename or delete your address books", "no_address_books": "No address books found", "categories_title": "Categories", - "categories_description": "Rename contact categories", + "categories_description": "Rename or delete contact categories", "no_categories": "No categories found" }, "calendar_management": { diff --git a/locales/es/common.json b/locales/es/common.json index 6a8d152e5..597896dc2 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2328,10 +2328,10 @@ "export_label": "Exportar contactos", "export_description": "Exportar todos los contactos como archivo vCard (.vcf)", "manage_title": "Libretas de direcciones", - "manage_description": "Renombrar tus libretas de direcciones", + "manage_description": "Renombrar o eliminar tus libretas de direcciones", "no_address_books": "No se encontraron libretas de direcciones", "categories_title": "Categorías", - "categories_description": "Renombrar categorías de contactos", + "categories_description": "Renombrar o eliminar categorías de contactos", "no_categories": "No se encontraron categorías", "group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos", "group_by_letter_label": "Agrupar por primera letra", diff --git a/locales/et/common.json b/locales/et/common.json index 564baaadd..75abcf6cd 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -2339,10 +2339,10 @@ "export_label": "Ekspordi kontaktid", "export_description": "Ekspordi kõik kontaktid vCard (.vcf) failina", "manage_title": "Aadressiraamatud", - "manage_description": "Nimeta oma aadressiraamatud ümber", + "manage_description": "Nimeta oma aadressiraamatud ümber või kustuta need", "no_address_books": "Aadressiraamatuid ei leitud", "categories_title": "Kategooriad", - "categories_description": "Nimeta kontaktikategooriad ümber", + "categories_description": "Nimeta kontaktikategooriad ümber või kustuta need", "no_categories": "Kategooriaid ei leitud" }, "calendar_management": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 18bf26732..09a2ab17c 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2339,10 +2339,10 @@ "export_label": "خروجی مخاطبین", "export_description": "خروجی همه مخاطبین به صورت فایل vCard (.vcf)", "manage_title": "دفترچه‌های آدرس", - "manage_description": "تغییر نام دفترچه‌های آدرس", + "manage_description": "تغییر نام یا حذف دفترچه‌های آدرس", "no_address_books": "دفترچه آدرسی یافت نشد", "categories_title": "دسته‌بندی‌ها", - "categories_description": "تغییر نام دسته‌بندی‌های مخاطبین", + "categories_description": "تغییر نام یا حذف دسته‌بندی‌های مخاطبین", "no_categories": "دسته‌بندی یافت نشد" }, "calendar_management": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 44d05bf46..53fee441e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2328,10 +2328,10 @@ "export_label": "Exporter des contacts", "export_description": "Exporter tous les contacts au format vCard (.vcf)", "manage_title": "Carnets d'adresses", - "manage_description": "Renommer vos carnets d'adresses", + "manage_description": "Renommer ou supprimer vos carnets d'adresses", "no_address_books": "Aucun carnet d'adresses trouvé", "categories_title": "Catégories", - "categories_description": "Renommer les catégories de contacts", + "categories_description": "Renommer ou supprimer les catégories de contacts", "no_categories": "Aucune catégorie trouvée", "group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts", "group_by_letter_label": "Grouper par première lettre", diff --git a/locales/he/common.json b/locales/he/common.json index d5c565350..849dcbb97 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2294,10 +2294,10 @@ "export_label": "ייצוא אנשי קשר", "export_description": "ייצא את כל אנשי הקשר כקובץ vCard (.vcf).", "manage_title": "ספרי כתובות", - "manage_description": "שנה את שם פנקסי הכתובות שלך", + "manage_description": "שנה את השם של פנקסי הכתובות שלך או מחק אותם", "no_address_books": "לא נמצאו פנקסי כתובות", "categories_title": "קטגוריות", - "categories_description": "שנה שם של קטגוריות אנשי קשר", + "categories_description": "שנה שם של קטגוריות אנשי קשר או מחק אותן", "no_categories": "לא נמצאו קטגוריות", "group_by_letter_label": "קבוצה לפי אות ראשונה", "sort_by_last_name_label": "מיון לפי שם משפחה", diff --git a/locales/hu/common.json b/locales/hu/common.json index cc04a4d52..eba5e9290 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2335,10 +2335,10 @@ "export_label": "Névjegyek exportálása", "export_description": "Összes névjegy exportálása vCard (.vcf) fájlként", "manage_title": "Címjegyzékek", - "manage_description": "Címjegyzékeid átnevezése", + "manage_description": "Címjegyzékeid átnevezése vagy törlése", "no_address_books": "Nincs címjegyzék", "categories_title": "Kategóriák", - "categories_description": "Névjegy kategóriák átnevezése", + "categories_description": "Névjegy kategóriák átnevezése vagy törlése", "no_categories": "Nincs kategória" }, "calendar_management": { diff --git a/locales/it/common.json b/locales/it/common.json index 9e6e69f5f..22df3912d 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2328,10 +2328,10 @@ "export_label": "Esporta contatti", "export_description": "Esporta tutti i contatti come file vCard (.vcf)", "manage_title": "Rubriche", - "manage_description": "Rinomina le tue rubriche", + "manage_description": "Rinomina o elimina le tue rubriche", "no_address_books": "Nessuna rubrica trovata", "categories_title": "Categorie", - "categories_description": "Rinomina le categorie dei contatti", + "categories_description": "Rinomina o elimina le categorie dei contatti", "no_categories": "Nessuna categoria trovata", "group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti", "group_by_letter_label": "Raggruppa per prima lettera", diff --git a/locales/ja/common.json b/locales/ja/common.json index ecd81e198..42f6db245 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2328,10 +2328,10 @@ "export_label": "連絡先をエクスポート", "export_description": "すべての連絡先をvCardファイル(.vcf)としてエクスポート", "manage_title": "アドレス帳", - "manage_description": "アドレス帳の名前を変更", + "manage_description": "アドレス帳の名前を変更または削除", "no_address_books": "アドレス帳が見つかりません", "categories_title": "カテゴリ", - "categories_description": "連絡先カテゴリの名前を変更", + "categories_description": "連絡先カテゴリの名前を変更または削除", "no_categories": "カテゴリが見つかりません", "group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示", "group_by_letter_label": "頭文字でグループ化", diff --git a/locales/ko/common.json b/locales/ko/common.json index 8f7b9ac91..fd46ecd60 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2328,10 +2328,10 @@ "export_label": "연락처 내보내기", "export_description": "모든 연락처를 vCard(.vcf) 파일로 저장해요", "manage_title": "주소록", - "manage_description": "주소록 이름 변경", + "manage_description": "주소록 이름 변경 또는 삭제", "no_address_books": "주소록을 찾을 수 없음", "categories_title": "카테고리", - "categories_description": "연락처 카테고리 이름 변경", + "categories_description": "연락처 카테고리 이름 변경 또는 삭제", "no_categories": "카테고리를 찾을 수 없음", "group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시", "group_by_letter_label": "첫 글자로 그룹화", diff --git a/locales/lv/common.json b/locales/lv/common.json index 46f6ae102..198e85afb 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2328,10 +2328,10 @@ "export_label": "Kontaktu eksports", "export_description": "Eksportēt visus kontaktus vCard failā (.vcf)", "manage_title": "Adrešu grāmatas", - "manage_description": "Pārdēvēt savas adrešu grāmatas", + "manage_description": "Pārdēvēt vai dzēst savas adrešu grāmatas", "no_address_books": "Adrešu grāmatas nav atrastas", "categories_title": "Kategorijas", - "categories_description": "Pārdēvēt kontaktu kategorijas", + "categories_description": "Pārdēvēt vai dzēst kontaktu kategorijas", "no_categories": "Kategorijas nav atrastas", "group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā", "group_by_letter_label": "Grupēt pēc pirmā burta", diff --git a/locales/mn/common.json b/locales/mn/common.json index f871c0a08..769d669ec 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2339,10 +2339,10 @@ "export_label": "Харилцагчдыг экспортлох", "export_description": "Бүх харилцагчдыг vCard (.vcf) файл болгон экспортлох", "manage_title": "Хаягийн дэвтэр", - "manage_description": "Хаягийн дэвтэрийнхээ нэрийг өөрчлөх", + "manage_description": "Хаягийн дэвтрийнхээ нэрийг өөрчлөх эсвэл устгах", "no_address_books": "Хаягийн дэвтэр олдсонгүй", "categories_title": "Ангилал", - "categories_description": "Харилцах ангиллын нэрийг өөрчлөх", + "categories_description": "Харилцагчийн ангиллын нэрийг өөрчлөх эсвэл устгах", "no_categories": "Ангилал олдсонгүй" }, "calendar_management": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 6c5beb2f6..53aa22a8b 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2339,10 +2339,10 @@ "export_label": "Eksporter kontakter", "export_description": "Eksporter alle kontakter som en vCard-fil (.vcf).", "manage_title": "Adressebøker", - "manage_description": "Gi nytt navn til adressebøkene dine", + "manage_description": "Gi nytt navn til eller slett adressebøkene dine", "no_address_books": "Ingen adressebøker funnet", "categories_title": "Kategorier", - "categories_description": "Gi nytt navn til kontaktkategorier", + "categories_description": "Gi nytt navn til eller slett kontaktkategorier", "no_categories": "Ingen kategorier funnet" }, "calendar_management": { diff --git a/locales/nl/common.json b/locales/nl/common.json index 6602dcc9d..4fff2a167 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2328,10 +2328,10 @@ "export_label": "Contacten exporteren", "export_description": "Alle contacten exporteren als vCard-bestand (.vcf)", "manage_title": "Adresboeken", - "manage_description": "Hernoem uw adresboeken", + "manage_description": "Hernoem of verwijder uw adresboeken", "no_address_books": "Geen adresboeken gevonden", "categories_title": "Categorieën", - "categories_description": "Contactcategorieën hernoemen", + "categories_description": "Contactcategorieën hernoemen of verwijderen", "no_categories": "Geen categorieën gevonden", "group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst", "group_by_letter_label": "Groeperen op eerste letter", diff --git a/locales/pl/common.json b/locales/pl/common.json index 59f412ab0..f7140d7fe 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2328,10 +2328,10 @@ "export_label": "Eksportuj kontakty", "export_description": "Eksportuj wszystkie kontakty jako plik vCard (.vcf)", "manage_title": "Książki adresowe", - "manage_description": "Zmień nazwy swoich książek adresowych", + "manage_description": "Zmień nazwy swoich książek adresowych lub je usuń", "no_address_books": "Nie znaleziono książek adresowych", "categories_title": "Kategorie", - "categories_description": "Zmień nazwy kategorii kontaktów", + "categories_description": "Zmień nazwy kategorii kontaktów lub je usuń", "no_categories": "Nie znaleziono kategorii", "group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów", "group_by_letter_label": "Grupuj według pierwszej litery", diff --git a/locales/pt/common.json b/locales/pt/common.json index 5c487790c..47832a11a 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2328,10 +2328,10 @@ "export_label": "Exportar contatos", "export_description": "Exportar todos os contatos como arquivo vCard (.vcf)", "manage_title": "Catálogos de endereços", - "manage_description": "Renomeie seus catálogos de endereços", + "manage_description": "Renomeie ou exclua seus catálogos de endereços", "no_address_books": "Nenhum catálogo de endereços encontrado", "categories_title": "Categorias", - "categories_description": "Renomear categorias de contatos", + "categories_description": "Renomear ou excluir categorias de contatos", "no_categories": "Nenhuma categoria encontrada", "group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos", "group_by_letter_label": "Agrupar pela primeira letra", diff --git a/locales/ro/common.json b/locales/ro/common.json index 54d55b9c6..8009c0e00 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2339,10 +2339,10 @@ "export_label": "Exportați contactele", "export_description": "Exportați toate contactele ca fișier VCF (vCard).", "manage_title": "Agende de adrese", - "manage_description": "Redenumiți agendele de adrese", + "manage_description": "Redenumiți sau ștergeți agendele de adrese", "no_address_books": "Nu s-au găsit agende de adrese", "categories_title": "Categorii", - "categories_description": "Redenumiți categoriile de contacte", + "categories_description": "Redenumiți sau ștergeți categoriile de contacte", "no_categories": "Nu s-au găsit categorii" }, "calendar_management": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 28b11a12a..2435d6433 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2328,10 +2328,10 @@ "export_label": "Экспорт контактов", "export_description": "Экспортировать все контакты в виде файла vCard (.vcf)", "manage_title": "Адресные книги", - "manage_description": "Переименуйте ваши адресные книги", + "manage_description": "Переименуйте или удалите ваши адресные книги", "no_address_books": "Адресные книги не найдены", "categories_title": "Категории", - "categories_description": "Переименование категорий контактов", + "categories_description": "Переименование и удаление категорий контактов", "no_categories": "Категории не найдены", "group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов", "group_by_letter_label": "Группировать по первой букве", diff --git a/locales/sk/common.json b/locales/sk/common.json index 4a6b34d53..5e4630c44 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2339,10 +2339,10 @@ "export_label": "Exportovať kontakty", "export_description": "Exportovať všetky kontakty ako súbor vCard (.vcf)", "manage_title": "Adresáre", - "manage_description": "Premenovať svoje adresáre", + "manage_description": "Premenovať alebo zmazať svoje adresáre", "no_address_books": "Neboli nájdené žiadne adresáre", "categories_title": "Kategórie", - "categories_description": "Premenovať kategórie kontaktov", + "categories_description": "Premenovať alebo zmazať kategórie kontaktov", "no_categories": "Neboli nájdené žiadne kategórie" }, "calendar_management": { diff --git a/locales/th/common.json b/locales/th/common.json index 463a126c6..507e4a037 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4518,10 +4518,10 @@ "export_label": "ส่งออกผู้ติดต่อ", "export_description": "ส่งออกผู้ติดต่อทั้งหมดเป็นไฟล์ vCard (.vcf)", "manage_title": "สมุดที่อยู่", - "manage_description": "เปลี่ยนชื่อสมุดที่อยู่ของคุณ", + "manage_description": "เปลี่ยนชื่อหรือลบสมุดที่อยู่ของคุณ", "no_address_books": "ไม่พบสมุดที่อยู่", "categories_title": "หมวดหมู่", - "categories_description": "เปลี่ยนชื่อหมวดหมู่ผู้ติดต่อ", + "categories_description": "เปลี่ยนชื่อหรือลบหมวดหมู่ผู้ติดต่อ", "no_categories": "ไม่พบหมวดหมู่" }, "calendar_management": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 053f4fb3d..e10493480 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2332,10 +2332,10 @@ "export_label": "Kişileri Dışa Aktar", "export_description": "Tüm kişileri vCard (.vcf) dosyası olarak dışa aktarın", "manage_title": "Adres Defterleri", - "manage_description": "Adres defterlerinizi yeniden adlandırın", + "manage_description": "Adres defterlerinizi yeniden adlandırın veya silin", "no_address_books": "Adres defteri bulunamadı", "categories_title": "Kategoriler", - "categories_description": "Kişi kategorilerini yeniden adlandırın", + "categories_description": "Kişi kategorilerini yeniden adlandırın veya silin", "no_categories": "Kategori bulunamadı" }, "calendar_management": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 4c9a961b0..2a22d8a45 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2328,10 +2328,10 @@ "export_label": "Експортувати контакти", "export_description": "Експортуйте всі контакти як файл vCard (.vcf).", "manage_title": "Адресні книги", - "manage_description": "Перейменуйте свої адресні книги", + "manage_description": "Перейменуйте або видаліть свої адресні книги", "no_address_books": "Адресних книг не знайдено", "categories_title": "Категорії", - "categories_description": "Перейменувати категорії контактів", + "categories_description": "Перейменувати або видалити категорії контактів", "no_categories": "Категорії не знайдено", "group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів", "group_by_letter_label": "Групувати за першою літерою", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index d4c0791d6..62c80e379 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2339,10 +2339,10 @@ "export_label": "匯出聯絡人", "export_description": "將所有聯絡人匯出為 vCard(.vcf)檔案", "manage_title": "通訊錄", - "manage_description": "重新命名您的通訊錄", + "manage_description": "重新命名或刪除您的通訊錄", "no_address_books": "找不到通訊錄", "categories_title": "類別", - "categories_description": "重新命名聯絡人類別", + "categories_description": "重新命名或刪除聯絡人類別", "no_categories": "找不到類別" }, "calendar_management": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 4fdfb43c5..5092529ca 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2328,10 +2328,10 @@ "export_label": "导出联系人", "export_description": "将所有联系人导出为 vCard (.vcf) 文件", "manage_title": "地址簿", - "manage_description": "重命名您的地址簿", + "manage_description": "重命名或删除您的地址簿", "no_address_books": "未找到地址簿", "categories_title": "类别", - "categories_description": "重命名联系人类别", + "categories_description": "重命名或删除联系人类别", "no_categories": "未找到类别", "group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题", "group_by_letter_label": "按首字母分组", From 715c3f03a322f033530c18ed785a02586b79ed23 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:22:00 +0100 Subject: [PATCH 27/54] fix: let Enter on the skip link move focus to the message list instead of opening a message --- .../__tests__/use-keyboard-shortcuts.test.ts | 35 +++++++++++++++++++ hooks/use-keyboard-shortcuts.ts | 4 ++- lib/keyboard.ts | 17 +++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/hooks/__tests__/use-keyboard-shortcuts.test.ts b/hooks/__tests__/use-keyboard-shortcuts.test.ts index 4e75a0dc4..c47ff91c6 100644 --- a/hooks/__tests__/use-keyboard-shortcuts.test.ts +++ b/hooks/__tests__/use-keyboard-shortcuts.test.ts @@ -130,3 +130,38 @@ describe('useKeyboardShortcuts: n opens the next unread message', () => { expect(KEYBOARD_SHORTCUTS.navigation).toContainEqual({ key: 'n', description: 'shortcuts.navigation.next_unread' }); }); }); + +// Enter on the focused "Skip to message list" link (or any button) belongs to +// that control: it must not open the selected message. +describe('useKeyboardShortcuts: Enter on a link or button', () => { + function setup() { + const onOpenEmail = vi.fn(); + renderHook(() => + useKeyboardShortcuts({ enabled: true, emails: [], selectedEmailId: 'e1', handlers: { onOpenEmail } }), + ); + return onOpenEmail; + } + + it.each([ + ['a link', () => { const a = document.createElement('a'); a.href = '#message-list'; return a; }], + ['a button', () => document.createElement('button')], + ])('leaves Enter on %s to it', (_name, make) => { + const onOpenEmail = setup(); + const el = make(); + document.body.appendChild(el); + try { + const event = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }); + el.dispatchEvent(event); + expect(onOpenEmail).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + } finally { + el.remove(); + } + }); + + it('still opens the selected message with Enter elsewhere', () => { + const onOpenEmail = setup(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true })); + expect(onOpenEmail).toHaveBeenCalledTimes(1); + }); +}); diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts index 6fb79dc2f..0540c9729 100644 --- a/hooks/use-keyboard-shortcuts.ts +++ b/hooks/use-keyboard-shortcuts.ts @@ -2,7 +2,7 @@ import { useEffect, useCallback, useRef } from "react"; import { Email } from "@/lib/jmap/types"; -import { isEditableEventTarget } from "@/lib/keyboard"; +import { isActivatableEventTarget, isEditableEventTarget } from "@/lib/keyboard"; import { keyboardHooks } from "@/lib/plugin-hooks"; export interface KeyboardShortcutHandlers { @@ -198,6 +198,8 @@ export function useKeyboardShortcuts({ case "enter": case "o": + // Enter on a focused link or button (the skip link) is the control's. + if (key === "enter" && isActivatableEventTarget(event)) break; if (selectedEmailId) { event.preventDefault(); run("open-email", h.onOpenEmail); diff --git a/lib/keyboard.ts b/lib/keyboard.ts index 749d585bd..8ab485c07 100644 --- a/lib/keyboard.ts +++ b/lib/keyboard.ts @@ -24,3 +24,20 @@ export function isEditableEventTarget(event: Event): boolean { return attr === "" || attr === "true" || attr === "plaintext-only"; }); } + +/** + * Whether a key event comes from a control that Enter or Space activates + * itself (a link, a button): a page-wide shortcut on those keys must leave it + * alone, or a focused "Skip to message list" link opens a message instead. + */ +export function isActivatableEventTarget(event: Event): boolean { + const path = + typeof event.composedPath === "function" + ? event.composedPath() + : [event.target]; + return path.some( + (node) => + node instanceof HTMLElement && + node.matches('a[href], button, summary, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="option"], [role="tab"], [role="switch"]'), + ); +} From 161c9d1de34b64e4198c4dece1c0c928234e936d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:23:16 +0100 Subject: [PATCH 28/54] fix: ask "delete address book?" with a question mark, like the other delete prompts --- components/contacts/contacts-app.tsx | 2 +- .../__tests__/address-book-management-settings.test.tsx | 2 +- components/settings/address-book-management-settings.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 + 32 files changed, 32 insertions(+), 3 deletions(-) diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 6af969db5..85d02463a 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -1193,7 +1193,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = }))} onDeleteAddressBook={client ? fromDrawer(async (book: AddressBook) => { const ok = await confirmDialog({ - title: t("address_books.delete"), + title: t("address_books.delete_title"), message: t("address_books.confirm_delete", { name: book.name }), variant: "destructive", confirmText: t("address_books.delete"), diff --git a/components/settings/__tests__/address-book-management-settings.test.tsx b/components/settings/__tests__/address-book-management-settings.test.tsx index 69c7fec76..1fd7554ab 100644 --- a/components/settings/__tests__/address-book-management-settings.test.tsx +++ b/components/settings/__tests__/address-book-management-settings.test.tsx @@ -55,7 +55,7 @@ describe('address books in Settings', () => { expect(deletes).toHaveLength(1); fireEvent.click(deletes[0]); - const dialog = within(screen.getByRole('alertdialog')); + const dialog = within(screen.getByRole('alertdialog', { name: 'delete_title' })); expect(removeAddressBook).not.toHaveBeenCalled(); fireEvent.click(dialog.getByRole('button', { name: 'delete' })); await waitFor(() => expect(removeAddressBook).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ id: 'club' }))); diff --git a/components/settings/address-book-management-settings.tsx b/components/settings/address-book-management-settings.tsx index 5df3e5b18..6145958ba 100644 --- a/components/settings/address-book-management-settings.tsx +++ b/components/settings/address-book-management-settings.tsx @@ -138,7 +138,7 @@ export function AddressBookManagementSettings() { const handleDelete = async (book: AddressBook) => { if (!client) return; const ok = await confirm({ - title: t("delete"), + title: t("delete_title"), message: t("confirm_delete", { name: book.name }), confirmText: t("delete"), variant: "destructive", diff --git a/locales/ar/common.json b/locales/ar/common.json index f858d5cae..a8c1bc539 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3293,6 +3293,7 @@ "share": "مشاركة دفتر العناوين", "new_contact_in_book": "جهة اتصال جديدة في دفتر العناوين هذا", "delete": "حذف دفتر العناوين", + "delete_title": "حذف دفتر العناوين؟", "confirm_delete": "حذف \"{name}\"؟ ستتم إزالة جميع جهات الاتصال في دفتر العناوين هذا.", "deleted": "تم حذف دفتر العناوين", "delete_failed": "فشل حذف دفتر العناوين", diff --git a/locales/ca/common.json b/locales/ca/common.json index 9d19ad99a..3685e800a 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3293,6 +3293,7 @@ "share": "Comparteix la llibreta d'adreces", "new_contact_in_book": "Contacte nou en aquesta llibreta d'adreces", "delete": "Suprimeix la llibreta d'adreces", + "delete_title": "Voleu suprimir la llibreta d'adreces?", "confirm_delete": "Voleu suprimir «{name}»? S'eliminaran tots els contactes d'aquesta llibreta d'adreces.", "deleted": "Llibreta d'adreces suprimida", "delete_failed": "No s'ha pogut suprimir la llibreta d'adreces", diff --git a/locales/cs/common.json b/locales/cs/common.json index ab2f21138..2a87b171e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3291,6 +3291,7 @@ "share": "Sdílet adresář", "new_contact_in_book": "Nový kontakt v tomto adresáři", "delete": "Smazat adresář", + "delete_title": "Smazat adresář?", "confirm_delete": "Smazat „{name}\"? Všechny kontakty v tomto adresáři budou odstraněny.", "deleted": "Adresář smazán", "delete_failed": "Adresář se nepodařilo smazat", diff --git a/locales/da/common.json b/locales/da/common.json index 5a30d3d01..614806838 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3291,6 +3291,7 @@ "share": "Del adressebog", "new_contact_in_book": "Ny kontakt i denne adressebog", "delete": "Slet adressebog", + "delete_title": "Slet adressebog?", "confirm_delete": "Slet \"{name}\"? Alle kontakter i denne adressebog fjernes.", "deleted": "Adressebog slettet", "delete_failed": "Kunne ikke slette adressebog", diff --git a/locales/de/common.json b/locales/de/common.json index e18487406..5224c5826 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3291,6 +3291,7 @@ "share": "Adressbuch freigeben", "new_contact_in_book": "Neuer Kontakt in diesem Adressbuch", "delete": "Adressbuch löschen", + "delete_title": "Adressbuch löschen?", "confirm_delete": "„{name}\" löschen? Alle Kontakte in diesem Adressbuch werden entfernt.", "deleted": "Adressbuch gelöscht", "delete_failed": "Adressbuch konnte nicht gelöscht werden", diff --git a/locales/en/common.json b/locales/en/common.json index a7d3b738d..1d0addbc3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3293,6 +3293,7 @@ "share": "Share address book", "new_contact_in_book": "New contact in this address book", "delete": "Delete address book", + "delete_title": "Delete address book?", "confirm_delete": "Delete \"{name}\"? All contacts in this address book will be removed.", "deleted": "Address book deleted", "delete_failed": "Couldn't delete the address book", diff --git a/locales/es/common.json b/locales/es/common.json index 597896dc2..7df489f73 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3291,6 +3291,7 @@ "share": "Compartir libreta de direcciones", "new_contact_in_book": "Nuevo contacto en esta libreta", "delete": "Eliminar libreta de direcciones", + "delete_title": "¿Eliminar la libreta de direcciones?", "confirm_delete": "¿Eliminar «{name}»? Todos los contactos de esta libreta se eliminarán.", "deleted": "Libreta de direcciones eliminada", "delete_failed": "No se pudo eliminar la libreta de direcciones", diff --git a/locales/et/common.json b/locales/et/common.json index 75abcf6cd..397d243c6 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -3293,6 +3293,7 @@ "share": "Jaga aadressiraamatut", "new_contact_in_book": "Uus kontakt selles aadressiraamatus", "delete": "Kustuta aadressiraamat", + "delete_title": "Kas kustutada aadressiraamat?", "confirm_delete": "Kustutada \"{name}\"? Kõik selle aadressiraamatu kontaktid eemaldatakse.", "deleted": "Aadressiraamat kustutatud", "delete_failed": "Aadressiraamatu kustutamine ebaõnnestus", diff --git a/locales/fa/common.json b/locales/fa/common.json index 09a2ab17c..0d83b550e 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3293,6 +3293,7 @@ "share": "اشتراک‌گذاری دفترچه آدرس", "new_contact_in_book": "مخاطب جدید در این دفترچه آدرس", "delete": "حذف دفترچه آدرس", + "delete_title": "دفترچه آدرس حذف شود؟", "confirm_delete": "حذف \"{name}\"؟ همه مخاطبین این دفترچه آدرس حذف خواهند شد.", "deleted": "دفترچه آدرس حذف شد", "delete_failed": "حذف دفترچه آدرس ناموفق بود", diff --git a/locales/fr/common.json b/locales/fr/common.json index 53fee441e..7d307c2a7 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3291,6 +3291,7 @@ "share": "Partager le carnet d'adresses", "new_contact_in_book": "Nouveau contact dans ce carnet d'adresses", "delete": "Supprimer le carnet d'adresses", + "delete_title": "Supprimer le carnet d'adresses ?", "confirm_delete": "Supprimer « {name} » ? Tous les contacts de ce carnet d'adresses seront supprimés.", "deleted": "Carnet d'adresses supprimé", "delete_failed": "Échec de la suppression du carnet d'adresses", diff --git a/locales/he/common.json b/locales/he/common.json index 849dcbb97..1e898fce9 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3206,6 +3206,7 @@ "share": "שתף ספר כתובות", "new_contact_in_book": "איש קשר חדש בספר כתובות זה", "delete": "מחק ספר כתובות", + "delete_title": "למחוק את ספר הכתובות?", "confirm_delete": "מחק \"{name}\"? כל אנשי הקשר בספר כתובות זה יוסרו.", "deleted": "ספר כתובות נמחק", "delete_failed": "נכשל במחיקת ספר כתובות", diff --git a/locales/hu/common.json b/locales/hu/common.json index eba5e9290..a9e6589f4 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3293,6 +3293,7 @@ "share": "Címjegyzék megosztása", "new_contact_in_book": "Új névjegy ebben a címjegyzékben", "delete": "Címjegyzék törlése", + "delete_title": "Törli a címjegyzéket?", "confirm_delete": "Törlöd a \"{name}\" címjegyzéket? Az összes névjegy eltávolításra kerül.", "deleted": "Címjegyzék törölve", "delete_failed": "Nem sikerült törölni a címjegyzéket", diff --git a/locales/it/common.json b/locales/it/common.json index 22df3912d..ac090b14d 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3291,6 +3291,7 @@ "share": "Condividi rubrica", "new_contact_in_book": "Nuovo contatto in questa rubrica", "delete": "Elimina rubrica", + "delete_title": "Eliminare la rubrica?", "confirm_delete": "Eliminare \"{name}\"? Tutti i contatti in questa rubrica verranno rimossi.", "deleted": "Rubrica eliminata", "delete_failed": "Impossibile eliminare la rubrica", diff --git a/locales/ja/common.json b/locales/ja/common.json index 42f6db245..2c2c8f74d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3291,6 +3291,7 @@ "share": "アドレス帳を共有", "new_contact_in_book": "このアドレス帳に新規連絡先", "delete": "アドレス帳を削除", + "delete_title": "アドレス帳を削除しますか?", "confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。", "deleted": "アドレス帳を削除しました", "delete_failed": "アドレス帳の削除に失敗しました", diff --git a/locales/ko/common.json b/locales/ko/common.json index fd46ecd60..28d738efb 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3291,6 +3291,7 @@ "share": "주소록 공유", "new_contact_in_book": "이 주소록에 새 연락처", "delete": "주소록 삭제", + "delete_title": "주소록을 삭제할까요?", "confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.", "deleted": "주소록이 삭제되었습니다", "delete_failed": "주소록 삭제에 실패했습니다", diff --git a/locales/lv/common.json b/locales/lv/common.json index 198e85afb..cfbb4259a 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3283,6 +3283,7 @@ "share": "Kopīgot adrešu grāmatu", "new_contact_in_book": "Jauns kontakts šajā adrešu grāmatā", "delete": "Dzēst adrešu grāmatu", + "delete_title": "Dzēst adrešu grāmatu?", "confirm_delete": "Dzēst \"{name}\"? Visi kontakti šajā adrešu grāmatā tiks noņemti.", "deleted": "Adrešu grāmata dzēsta", "delete_failed": "Neizdevās dzēst adrešu grāmatu", diff --git a/locales/mn/common.json b/locales/mn/common.json index 769d669ec..d6b2f743a 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3293,6 +3293,7 @@ "share": "Хаягийн дэвтэр хуваалцах", "new_contact_in_book": "Энэ хаягийн дэвтэрт шинэ харилцагч", "delete": "Хаягийн дэвтэр устгах", + "delete_title": "Хаягийн дэвтрийг устгах уу?", "confirm_delete": "\"{name}\"-г устгах уу? Энэ хаягийн дэвтэр дэх бүх харилцагчийг устгах болно.", "deleted": "Хаягийн дэвтэр устсан", "delete_failed": "Хаягийн дэвтрийг устгаж чадсангүй", diff --git a/locales/nb/common.json b/locales/nb/common.json index 53aa22a8b..4d1eae298 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3293,6 +3293,7 @@ "share": "Del adressebok", "new_contact_in_book": "Ny kontakt i denne adresseboken", "delete": "Slett adressebok", + "delete_title": "Vil du slette adresseboken?", "confirm_delete": "Vil du slette «{name}»? Alle kontaktene i denne adresseboken fjernes.", "deleted": "Adresseboken er slettet", "delete_failed": "Kunne ikke slette adresseboken", diff --git a/locales/nl/common.json b/locales/nl/common.json index 4fff2a167..50040c782 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3291,6 +3291,7 @@ "share": "Adresboek delen", "new_contact_in_book": "Nieuw contact in dit adresboek", "delete": "Adresboek verwijderen", + "delete_title": "Adresboek verwijderen?", "confirm_delete": "\"{name}\" verwijderen? Alle contacten in dit adresboek worden verwijderd.", "deleted": "Adresboek verwijderd", "delete_failed": "Adresboek kon niet worden verwijderd", diff --git a/locales/pl/common.json b/locales/pl/common.json index f7140d7fe..74dbd3226 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3291,6 +3291,7 @@ "share": "Udostępnij książkę adresową", "new_contact_in_book": "Nowy kontakt w tej książce adresowej", "delete": "Usuń książkę adresową", + "delete_title": "Usunąć książkę adresową?", "confirm_delete": "Usunąć „{name}\"? Wszystkie kontakty w tej książce adresowej zostaną usunięte.", "deleted": "Książka adresowa usunięta", "delete_failed": "Nie udało się usunąć książki adresowej", diff --git a/locales/pt/common.json b/locales/pt/common.json index 47832a11a..eb9c1c567 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3291,6 +3291,7 @@ "share": "Compartilhar lista de contatos", "new_contact_in_book": "Novo contato nesta lista", "delete": "Excluir lista de contatos", + "delete_title": "Excluir lista de contatos?", "confirm_delete": "Excluir \"{name}\"? Todos os contatos desta lista serão removidos.", "deleted": "Lista de contatos excluída", "delete_failed": "Falha ao excluir a lista de contatos", diff --git a/locales/ro/common.json b/locales/ro/common.json index 8009c0e00..f8609555b 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3293,6 +3293,7 @@ "share": "Partajați agenda de adrese", "new_contact_in_book": "Contact nou în această agendă", "delete": "Șterge agenda de adrese", + "delete_title": "Ștergeți agenda de adrese?", "confirm_delete": "Doriți să ștergeți „{name}”? Toate contactele din această agendă vor fi eliminate.", "deleted": "Agenda de adrese a fost ștersă", "delete_failed": "Nu s-a putut șterge agenda de adrese", diff --git a/locales/ru/common.json b/locales/ru/common.json index 2435d6433..38eec4f99 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3291,6 +3291,7 @@ "share": "Поделиться адресной книгой", "new_contact_in_book": "Новый контакт в этой адресной книге", "delete": "Удалить адресную книгу", + "delete_title": "Удалить адресную книгу?", "confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.", "deleted": "Адресная книга удалена", "delete_failed": "Не удалось удалить адресную книгу", diff --git a/locales/sk/common.json b/locales/sk/common.json index 5e4630c44..dd060eee5 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3293,6 +3293,7 @@ "share": "Zdieľať adresár", "new_contact_in_book": "Nový kontakt v tomto adresári", "delete": "Zmazať adresár", + "delete_title": "Zmazať adresár?", "confirm_delete": "Zmazať \"{name}\"? Všetky kontakty v tomto adresári budú odstránené.", "deleted": "Adresár zmazaný", "delete_failed": "Adresár sa nepodarilo zmazať", diff --git a/locales/th/common.json b/locales/th/common.json index 507e4a037..2639db965 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2417,6 +2417,7 @@ "share": "แชร์สมุดที่อยู่", "new_contact_in_book": "เพิ่มผู้ติดต่อในสมุดที่อยู่นี้", "delete": "ลบสมุดที่อยู่", + "delete_title": "ลบสมุดที่อยู่ใช่ไหม", "confirm_delete": "ลบ “{name}” หรือไม่? ผู้ติดต่อทั้งหมดในสมุดที่อยู่นี้จะถูกลบ", "deleted": "ลบสมุดที่อยู่แล้ว", "delete_failed": "ลบสมุดที่อยู่ไม่สำเร็จ", diff --git a/locales/tr/common.json b/locales/tr/common.json index e10493480..724af6b7f 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3291,6 +3291,7 @@ "share": "Adres defterini paylaş", "new_contact_in_book": "Bu adres defterinde yeni kişi", "delete": "Adres defterini sil", + "delete_title": "Adres defteri silinsin mi?", "confirm_delete": "\"{name}\" silinsin mi? Bu adres defterindeki tüm kişiler kaldırılacak.", "deleted": "Adres defteri silindi", "delete_failed": "Adres defteri silinemedi", diff --git a/locales/uk/common.json b/locales/uk/common.json index 2a22d8a45..799d4d249 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3291,6 +3291,7 @@ "share": "Поділитися адресною книгою", "new_contact_in_book": "Новий контакт у цій адресній книзі", "delete": "Видалити адресну книгу", + "delete_title": "Видалити адресну книгу?", "confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.", "deleted": "Адресну книгу видалено", "delete_failed": "Не вдалося видалити адресну книгу", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 62c80e379..28ac638d1 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3293,6 +3293,7 @@ "share": "分享通訊錄", "new_contact_in_book": "在此通訊錄中新增聯絡人", "delete": "刪除通訊錄", + "delete_title": "要刪除通訊錄嗎?", "confirm_delete": "要刪除「{name}」嗎?此通訊錄中的所有聯絡人都會移除。", "deleted": "通訊錄已刪除", "delete_failed": "刪除通訊錄失敗", diff --git a/locales/zh/common.json b/locales/zh/common.json index 5092529ca..0493ba8a6 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3291,6 +3291,7 @@ "share": "共享通讯录", "new_contact_in_book": "在此通讯录中新建联系人", "delete": "删除通讯录", + "delete_title": "删除通讯录?", "confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。", "deleted": "通讯录已删除", "delete_failed": "删除通讯录失败", From 4995024223f7d827468aab654f62a59f46919ca4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:24:03 +0100 Subject: [PATCH 29/54] fix: show the compose and search mail keys in the command palette only in Mail --- .../__tests__/command-palette.test.tsx | 19 +++++++++++++++++++ .../command-palette/command-palette.tsx | 12 +++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx index 274da3805..4a185b44b 100644 --- a/components/command-palette/__tests__/command-palette.test.tsx +++ b/components/command-palette/__tests__/command-palette.test.tsx @@ -97,6 +97,25 @@ describe('command palette', () => { expect(screen.queryByText('group_plugins')).toBeNull(); }); + it("shows Mail's c and / hints only in Mail, where those keys compose and search", () => { + const hint = (label: string) => { + const row = screen.getAllByRole('option').find((o) => o.querySelector('.flex-1')?.textContent === label)!; + return [...row.querySelectorAll('kbd')].map((k) => k.textContent).join(' '); + }; + const { unmount } = render(); + pressCtrlK(); + expect(hint('compose')).toBe('c'); + expect(hint('search')).toBe('/'); + unmount(); + + // Calendar has its own c (new event) and / (search events). + nav.pathname = '/calendar/week/2026-10-28'; + render(); + pressCtrlK(); + expect(hint('compose')).toBe(''); + expect(hint('search')).toBe(''); + }); + it('shows plugin entries with the plugin name and runs them with Enter', async () => { act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }])); render(); diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx index 9cd32331d..7b5982409 100644 --- a/components/command-palette/command-palette.tsx +++ b/components/command-palette/command-palette.tsx @@ -206,10 +206,16 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS run: go(pluginPageHref(page)), }); } + // "c" and "/" compose and search only in Mail; another app may give the + // same keys its own meaning (Calendar: new event, search events), listed + // with its own commands above. + const inMail = pathname === "/" || pathname === "" || pathname === "/mail" || pathname.startsWith("/mail/"); + const appKeys = new Set(list.map((c) => c.shortcut).filter(Boolean)); + const mailKey = (key: string) => (inMail && !appKeys.has(key) ? key : undefined); list.push( { id: "go-settings", group: navigation, icon: Settings, label: t("go_to", { name: tSidebar("settings") }), run: go("/settings") }, - { id: "compose", group: actions, icon: PenSquare, label: t("compose"), shortcut: "c", run: onMail("compose") }, - { id: "search", group: actions, icon: Search, label: t("search"), shortcut: "/", run: onMail("search") }, + { id: "compose", group: actions, icon: PenSquare, label: t("compose"), shortcut: mailKey("c"), run: onMail("compose") }, + { id: "search", group: actions, icon: Search, label: t("search"), shortcut: mailKey("/"), run: onMail("search") }, { id: "search-everything", group: actions, @@ -257,7 +263,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS }); } return list; - }, [client, supportsCalendar, isFeatureEnabled, colorModeLocked, resolvedTheme, pluginActions, pluginPages, registeredGroups, accounts, activeAccountId, router, t, tSidebar, tGlobalSearch, onShowShortcuts, touch]); + }, [client, supportsCalendar, isFeatureEnabled, colorModeLocked, resolvedTheme, pluginActions, pluginPages, registeredGroups, accounts, activeAccountId, router, pathname, t, tSidebar, tGlobalSearch, onShowShortcuts, touch]); const needle = query.trim(); const visible = needle From e3fd576543e250461d40f7082c398b65d67ea3cb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:24:34 +0100 Subject: [PATCH 30/54] fix: hide the next heartbeat and consent date in admin usage stats while heartbeats are off --- .../__tests__/save-bar-switches.test.tsx | 26 +++++++++++++++++++ app/(main)/admin/_pages/telemetry.tsx | 25 +++++++++++++----- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/(main)/admin/__tests__/save-bar-switches.test.tsx b/app/(main)/admin/__tests__/save-bar-switches.test.tsx index 23a5d4ed2..b0d8d63ee 100644 --- a/app/(main)/admin/__tests__/save-bar-switches.test.tsx +++ b/app/(main)/admin/__tests__/save-bar-switches.test.tsx @@ -81,4 +81,30 @@ describe('admin telemetry page', () => { expect.objectContaining({ url: '/api/admin/telemetry', body: { action: 'set-consent', consent: 'on' } }), ])); }); + + it('shows no next send and no consent date while heartbeats are off', async () => { + routes['/api/admin/telemetry'] = { + ...(routes['/api/admin/telemetry'] as object), + consent: 'off', consentedAt: '2026-10-01T10:00:00Z', lastSentAt: '2026-10-02T10:00:00Z', + nextScheduledAt: new Date(Date.now() - 7 * 3600_000).toISOString(), + }; + render(); + await screen.findByRole('switch', { name: 'Send anonymous heartbeats' }); + expect(screen.queryByText('Next scheduled')).toBeNull(); + expect(screen.queryByText('Consented at')).toBeNull(); + expect(screen.queryByText(/hours ago/)).toBeNull(); + expect(screen.getByText('Last sent')).toBeTruthy(); + }); + + it('shows a past next send as due, not as "hours ago", while heartbeats are on', async () => { + routes['/api/admin/telemetry'] = { + ...(routes['/api/admin/telemetry'] as object), + consent: 'on', consentedAt: '2026-10-01T10:00:00Z', + nextScheduledAt: new Date(Date.now() - 7 * 3600_000).toISOString(), + }; + render(); + await screen.findByText('Next scheduled'); + expect(screen.getByText('Due now')).toBeTruthy(); + expect(screen.getByText('Consented at')).toBeTruthy(); + }); }); diff --git a/app/(main)/admin/_pages/telemetry.tsx b/app/(main)/admin/_pages/telemetry.tsx index 73cdae984..7d03ca985 100644 --- a/app/(main)/admin/_pages/telemetry.tsx +++ b/app/(main)/admin/_pages/telemetry.tsx @@ -40,6 +40,13 @@ function timeAgo(iso: string | null): string { return `${days} days ago`; } +/** When the next heartbeat goes out; a time already past means it is due. */ +function nextRun(iso: string | null): string { + if (!iso) return '-'; + const at = new Date(iso); + return at.getTime() <= Date.now() ? 'Due now' : at.toLocaleString(); +} + export function TelemetryPage() { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); @@ -177,12 +184,18 @@ export function TelemetryPage() { {timeAgo(status.lastSentAt)} - - {timeAgo(status.nextScheduledAt)} - - - {status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'} - + {/* Only a running schedule has a next send and a consent date; with + heartbeats off they were stale ("Next scheduled: 7 hours ago"). */} + {isOn && ( + <> + + {nextRun(status.nextScheduledAt)} + + + {status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'} + + + )} Date: Wed, 28 Oct 2026 10:24:55 +0100 Subject: [PATCH 31/54] 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 1934584eb6b454b563fe0248aea3b71a0e8b752e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:26:32 +0100 Subject: [PATCH 32/54] fix: show short, accurate placeholders with a tooltip on the admin branding fields of a domain --- .../__tests__/branding-placeholders.test.tsx | 37 +++++++++++++++++++ app/(main)/admin/_pages/branding.tsx | 6 ++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 app/(main)/admin/__tests__/branding-placeholders.test.tsx diff --git a/app/(main)/admin/__tests__/branding-placeholders.test.tsx b/app/(main)/admin/__tests__/branding-placeholders.test.tsx new file mode 100644 index 000000000..9918c3030 --- /dev/null +++ b/app/(main)/admin/__tests__/branding-placeholders.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; + +// Branding per domain: the image fields said "Enter URL (uploads only for +// default scope)", cut off in the field and wrong for a single domain, which +// can upload. Only a wildcard domain takes a URL alone. + +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async () => new Response(JSON.stringify({ + domainBranding: { source: 'admin', value: [{ host: 'mail.example.com' }, { host: '*.example.com' }] }, + }), { status: 200, headers: { 'content-type': 'application/json' } })), + withBasePath: (p: string) => p, +})); +vi.mock('next/link', () => ({ default: ({ children }: { children: React.ReactNode }) => children })); + +import { BrandingPage } from '../_pages/branding'; + +afterEach(() => { + useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null }); +}); + +describe('admin branding image fields', () => { + it('offers upload for a single domain and a URL only for a wildcard, with the full hint as a title', async () => { + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'mail.example.com' })); + const favicon = screen.getByRole('textbox', { name: 'Favicon' }); + expect(favicon.getAttribute('placeholder')).toBe('Enter URL or upload a file'); + expect(favicon.getAttribute('title')).toBe('Enter URL or upload a file'); + + fireEvent.click(screen.getByRole('button', { name: '*.example.com' })); + const wildcardFavicon = screen.getByRole('textbox', { name: 'Favicon' }); + expect(wildcardFavicon.getAttribute('placeholder')).toBe('Enter URL'); + expect(wildcardFavicon.getAttribute('title')).toBe('Enter URL'); + }); +}); diff --git a/app/(main)/admin/_pages/branding.tsx b/app/(main)/admin/_pages/branding.tsx index 70446999e..f398455e2 100644 --- a/app/(main)/admin/_pages/branding.tsx +++ b/app/(main)/admin/_pages/branding.tsx @@ -396,7 +396,9 @@ export function BrandingPage() { accept={field.accept} value={currentValue(field.key)} badge={overrideBadge(field.key)} - placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'} + // Files upload to the default scope and to exact hosts; a wildcard has + // no single host to serve them from (the scope line says so). + placeholder={wildcardScope ? 'Enter URL' : 'Enter URL or upload a file'} uploading={uploading === field.key} uploadDisabled={wildcardScope} uploaded={isUploadedFile(field.key)} @@ -417,6 +419,7 @@ export function BrandingPage() { value={currentValue(field.key)} onChange={(e) => handleChange(field.key, e.target.value)} placeholder={placeholder} + title={placeholder} aria-label={field.label} autoComplete="off" spellCheck={false} @@ -592,6 +595,7 @@ function ImageItem({ label, accept, value, badge, placeholder, uploading, upload value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} + title={placeholder} aria-label={label} autoComplete="off" spellCheck={false} From f1c209beaa16145dc41a500dfac4b53245087135 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:26:55 +0100 Subject: [PATCH 33/54] fix: name the account or parent folder in the palette's go to folder entries --- .../__tests__/mail-palette-commands.test.tsx | 18 ++++++++++++++++++ components/mail/mail-app.tsx | 13 ++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/components/mail/__tests__/mail-palette-commands.test.tsx b/components/mail/__tests__/mail-palette-commands.test.tsx index 11744d27d..1c9539fa8 100644 --- a/components/mail/__tests__/mail-palette-commands.test.tsx +++ b/components/mail/__tests__/mail-palette-commands.test.tsx @@ -108,6 +108,24 @@ describe('Mail in the command palette', () => { expect(ids).not.toContain('mark-read'); }); + it("says whose a shared folder is and where a subfolder is, so two Inboxes don't look the same", async () => { + const shared = { ...folder('support:inbox', 'inbox'), isShared: true, accountName: 'Support', accountId: 'support', originalId: 'inbox' } as Mailbox; + const archive = folder('archive', 'archive', 'Archive'); + const year = { ...folder('y2025', undefined, '2025'), parentId: 'archive' } as Mailbox; + const all = [...folders, shared, archive, year]; + useEmailStore.setState({ mailboxes: all, selectedMailbox: 'work' } as never); + client.getMailboxes = vi.fn().mockResolvedValue(all); + let groups: CommandGroup[] = []; + render(<> { groups = g; }} />); + await screen.findByText('viewer', {}, { timeout: 5000 }); + await waitFor(() => expect(groups.find((g) => g.label === 'sidebar.mail')).toBeDefined()); + // The test translator drops parameters; the place also goes in the keywords. + const where = (id: string) => groups.find((g) => g.label === 'sidebar.mail')!.commands.find((c) => c.id === id)!.keywords?.slice(1); + expect(where('go-support:inbox')).toEqual(['Support']); + expect(where('go-inbox')).toEqual([]); + expect(where('go-y2025')?.[0]).toMatch(/archive/i); + }); + it('moves the open message from "Move to…"', async () => { let groups: CommandGroup[] = []; render(<> { groups = g; }} />); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index f54110043..72ff4d9be 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -4226,6 +4226,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // "Go to" for every folder. Compose is the palette's own. const paletteEmail = isScheduledView ? null : selectedEmail; const hasPaletteEmail = () => !!paletteEmail; + const paletteMailboxesById = new Map(mailboxes.map((mb) => [mb.id, mb])); + const paletteMailboxName = (mb: Mailbox) => localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`)); useRegisterCommands(t('sidebar.mail'), [ { id: 'reply', label: t('context_menu.reply'), icon: Reply, shortcut: 'r', when: hasPaletteEmail, run: () => void handleReply() }, { id: 'reply-all', label: t('context_menu.reply_all'), icon: ReplyAll, shortcut: 'a', when: hasPaletteEmail, run: () => void handleReplyAll() }, @@ -4259,12 +4261,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { ...mailboxes .filter((mb) => !mb.id.startsWith('shared-') && mb.id !== selectedMailbox) .map((mb) => { - const name = localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`)); + const name = paletteMailboxName(mb); + // Two Inboxes (yours and a shared Support account's), or "2025" in + // Archive and in Receipts: say whose, or where, it is. + const parent = mb.parentId ? paletteMailboxesById.get(mb.parentId) : undefined; + const where = [mb.isShared ? mb.accountName : undefined, parent ? paletteMailboxName(parent) : undefined] + .filter(Boolean).join(' / '); return { id: `go-${mb.id}`, - label: t('context_menu.go_to_folder', { folder: name }), + label: t('context_menu.go_to_folder', { folder: where ? `${name} · ${where}` : name }), icon: Folder, - keywords: [name], + keywords: where ? [name, where] : [name], run: () => void handleMailboxSelect(mb.id), }; }), From 4dc13c229b2adcb6a23d49c39991c80b5467daf8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:28:13 +0100 Subject: [PATCH 34/54] fix: list a repeating event under its own title in calendar search when one occurrence was renamed --- hooks/__tests__/use-calendar-event-search.test.tsx | 11 +++++++++++ hooks/use-calendar-event-search.ts | 7 +++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/hooks/__tests__/use-calendar-event-search.test.tsx b/hooks/__tests__/use-calendar-event-search.test.tsx index 931d69726..a91bd1d3d 100644 --- a/hooks/__tests__/use-calendar-event-search.test.tsx +++ b/hooks/__tests__/use-calendar-event-search.test.tsx @@ -60,6 +60,17 @@ describe('useCalendarEventSearch (CAL-16)', () => { expect(result.current?.past.map((e) => e.id)).toEqual(['old', 'older']); }); + it('shows a series under its own title when its next occurrence was renamed', () => { + const { client } = clientWith(() => new Promise(() => {})); + const series = [ + makeEvent('sync-1', 'Sync edited', '2026-10-29T10:00:00', { uid: 'sync', recurrenceId: '2026-10-29T10:00:00' }), + makeEvent('sync-2', 'Sync', '2026-11-05T10:00:00', { uid: 'sync', recurrenceId: '2026-11-05T10:00:00' }), + makeEvent('sync-3', 'Sync', '2026-11-12T10:00:00', { uid: 'sync', recurrenceId: '2026-11-12T10:00:00' }), + ]; + const { result } = renderHook(() => useCalendarEventSearch('sync', client, series)); + expect(result.current?.upcoming.map((e) => e.id)).toEqual(['sync-1', 'sync-2']); + }); + it('keeps the loaded matches when the server can not search', async () => { const { client } = clientWith(async () => { throw new Error('unsupportedFilter'); }); const { result } = renderHook(() => useCalendarEventSearch('review', client, loaded)); diff --git a/hooks/use-calendar-event-search.ts b/hooks/use-calendar-event-search.ts index 9d8122c28..a1f48e830 100644 --- a/hooks/use-calendar-event-search.ts +++ b/hooks/use-calendar-event-search.ts @@ -22,8 +22,11 @@ export interface CalendarSearchResults { } // One row per event: a series found as loaded occurrences and as the -// server's base event shows once. -const seriesKey = (event: CalendarEvent) => `${event.accountId ?? ""} ${event.uid || event.originalId || event.id}`; +// server's base event shows once. An occurrence renamed on its own gets a +// row of its own, so the series still shows under its own title: the next +// occurrence being the renamed one hid the series. +const seriesKey = (event: CalendarEvent) => + `${event.accountId ?? ""} ${event.uid || event.originalId || event.id} ${event.title ?? ""}`; /** * Searches the user's events by title, place, description and participants From 44abfdb989852e4733003553f3ce78b9a7336d56 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:28:41 +0100 Subject: [PATCH 35/54] fix: name the admin update dot in the user's language, as on the navigation rail --- components/admin/__tests__/admin-shell.test.tsx | 14 ++++++++++++-- components/admin/admin-shell.tsx | 5 ++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/components/admin/__tests__/admin-shell.test.tsx b/components/admin/__tests__/admin-shell.test.tsx index 6c4027212..84bd5fbfd 100644 --- a/components/admin/__tests__/admin-shell.test.tsx +++ b/components/admin/__tests__/admin-shell.test.tsx @@ -66,7 +66,17 @@ describe('AdminShell', () => { startPolling: () => {}, } as never); render(shell({ authenticated: true, isStalwartAutoLogin: false })); - const dot = screen.getAllByRole('img', { name: 'Update available' })[0]; - expect(dot.getAttribute('title')).toBe('Update available'); + const dot = screen.getAllByRole('img', { name: 'update_available' })[0]; + expect(dot.getAttribute('title')).toBe('update_available'); + }); + + // Translated like the same dot on the navigation rail (shell namespace). + it('names an important update in the language of the user', () => { + useUpdateStore.setState({ + status: { updateAvailable: true, severity: 'security', current: process.env.NEXT_PUBLIC_APP_VERSION ?? '', latest: '9.9.9' }, + startPolling: () => {}, + } as never); + render(shell({ authenticated: true, isStalwartAutoLogin: false })); + expect(screen.getAllByRole('img', { name: 'update_important' })[0].getAttribute('title')).toBe('update_important'); }); }); diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index e3a1e5b38..9a8de7779 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; import { usePathname } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import { ArrowLeft, ArrowUpCircle, @@ -80,6 +81,7 @@ const subscribeNothing = () => () => {}; */ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, onLogout, children }: AdminShellProps) { const pathname = usePathname(); + const tShell = useTranslations('shell'); const location = useAdminLocation(); const isDesktop = useIsDesktop(); // The server can't know the window width, so its HTML would be the phone @@ -183,7 +185,8 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, if (id === 'version' && hasUpdate) { // A dot only: role="img" makes screen readers announce its label, which // they skip on a plain span; the title shows it on hover. - const label = updateImportant ? 'Important update available' : 'Update available'; + // The same words as the dot on the rail's Settings entry. + const label = tShell(updateImportant ? 'update_important' : 'update_available'); return ( Date: Wed, 28 Oct 2026 10:29:41 +0100 Subject: [PATCH 36/54] fix: keep keys on one line in the keyboard shortcuts help and line up its global section --- components/keyboard-shortcuts-modal.tsx | 19 ++++++++++++------- locales/en/common.json | 4 ++-- locales/es/common.json | 4 ++-- locales/pt/common.json | 4 ++-- locales/tr/common.json | 6 +++--- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/components/keyboard-shortcuts-modal.tsx b/components/keyboard-shortcuts-modal.tsx index 6ba8ccb69..ec66d8caf 100644 --- a/components/keyboard-shortcuts-modal.tsx +++ b/components/keyboard-shortcuts-modal.tsx @@ -130,7 +130,9 @@ export function KeyboardShortcutsModal({ isOpen, onClose, app }: KeyboardShortcu

{t(section.title)}

-
+ {/* A wide section's two columns line up with the columns of the + sections above it: the same 2rem gap as the panel grid. */} +
{rows.map((shortcut) => ( - {description} -
+
+ {description} + {/* The description wraps first. A key never breaks inside ("Shift + ↓"), + and an alternative keeps its "or" with it ("n or c"); only a very + long list wraps, between alternatives. */} +
{keys.map((key, index) => ( - - {index > 0 && {t("or")}} + + {index > 0 && {t("or")}} Date: Wed, 28 Oct 2026 10:29:43 +0100 Subject: [PATCH 37/54] fix: leave read-only calendars out of the task editor's calendar list --- .../__tests__/task-modal-calendars.test.tsx | 36 +++++++++++++++++++ components/calendar/calendar-app.tsx | 2 ++ components/calendar/task-modal.tsx | 14 ++++++-- 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 components/calendar/__tests__/task-modal-calendars.test.tsx diff --git a/components/calendar/__tests__/task-modal-calendars.test.tsx b/components/calendar/__tests__/task-modal-calendars.test.tsx new file mode 100644 index 000000000..1bd0abf30 --- /dev/null +++ b/components/calendar/__tests__/task-modal-calendars.test.tsx @@ -0,0 +1,36 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { TaskModal } from '../task-modal'; +import type { Calendar, CalendarTask } from '@/lib/jmap/types'; + +// The task editor's Calendar select offered the read-only Birthdays +// calendar, which the event editor leaves out. + +const writable = { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true }; +const readOnly = { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayDelete: false }; +const calendars = [ + { id: 'work', name: 'Work', isShared: false, myRights: writable }, + { id: 'personal', name: 'Personal', isShared: false, myRights: writable }, + { id: 'birthdays', name: 'Birthdays', isShared: false, myRights: readOnly }, + { id: 'feed', name: 'Holidays feed', isShared: false, myRights: writable }, +] as unknown as Calendar[]; +const isSubscriptionCalendar = (id: string) => id === 'feed'; + +describe('task editor calendar select', () => { + it('lists only the calendars a task can go into', () => { + render(); + const select = screen.getByLabelText('tasks.calendar'); + const names = within(select).getAllByRole('option').map((o) => o.textContent); + expect(names).toEqual(['Work', 'Personal']); + }); + + it('keeps the calendar of a task that is already in a read-only one', () => { + const task = { + id: 't1', '@type': 'Task', title: 'Card for Ann', calendarIds: { birthdays: true }, + progress: 'needs-action', priority: 0, due: null, showWithoutTime: true, alerts: null, + } as unknown as CalendarTask; + render(); + const select = screen.getByLabelText('tasks.calendar') as HTMLSelectElement; + expect(select.value).toBe('birthdays'); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index b9d63985b..b56e29ceb 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -2219,6 +2219,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = key={editTask?.id ?? 'new-task'} task={editTask} calendars={displayCalendars} + isSubscriptionCalendar={isSubscriptionCalendar} onSave={handleSaveTask} onDelete={handleDeleteTask} onDirtyChange={setTaskEditorDirty} @@ -2380,6 +2381,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = key={editTask?.id ?? 'new-task'} task={editTask} calendars={displayCalendars} + isSubscriptionCalendar={isSubscriptionCalendar} onSave={handleSaveTask} onDelete={handleDeleteTask} onDirtyChange={setTaskEditorDirty} diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 6d3069d4a..26507e54d 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -14,6 +14,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; import { taskToView } from "@/lib/plugin-projection"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { canCreateEventsIn } from "@/lib/calendar-editability"; interface TaskModalProps { task?: CalendarTask | null; @@ -25,6 +26,8 @@ interface TaskModalProps { /** Told whether the form holds changes that aren't saved, so the page can ask before it drops them. */ onDirtyChange?: (dirty: boolean) => void; isMobile?: boolean; + /** Subscribed feeds are read-only: no tasks go there. */ + isSubscriptionCalendar?: (calendarId: string) => boolean; } type PriorityLevel = "none" | "high" | "medium" | "low"; @@ -54,6 +57,7 @@ export function TaskModal({ onClose, onDirtyChange, isMobile: _isMobile, + isSubscriptionCalendar, }: TaskModalProps) { const t = useTranslations("calendar"); const tCommon = useTranslations("common"); @@ -63,7 +67,9 @@ export function TaskModal({ // task-detail-section: the saved task, for an existing task only. const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]); - const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn); + // The calendars a task can go into: not read-only ones (Birthdays, + // feeds), the same rule as the event editor. + const writableCalendars = calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar)); const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? ""; const [title, setTitle] = useState(task?.title ?? ""); @@ -80,6 +86,8 @@ export function TaskModal({ } return defaultCalendarId; }); + // A task already in a read-only calendar keeps it listed. + const calendarOptions = calendars.filter((c) => c.id === calendarId || writableCalendars.includes(c)); const [alertOption, setAlertOption] = useState(() => { if (!task?.alerts) return "none"; const first = Object.values(task.alerts)[0]; @@ -313,7 +321,7 @@ export function TaskModal({
{/* Calendar */} - {writableCalendars.length > 1 && ( + {calendarOptions.length > 1 && (
diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index 8514f5218..11697c9b6 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -522,7 +522,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem

{tk('description')}

@@ -750,7 +750,7 @@ function PublicKeysSection() {

{t('encryption.description')}

@@ -780,7 +780,7 @@ function PublicKeysSection() {
From efa5aa2308c3e2b122fd58a6b1b23e16e4450321 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:33:15 +0100 Subject: [PATCH 40/54] fix: write the sidebar apps dialog titles and labels in sentence case --- locales/en/common.json | 16 ++++++++-------- locales/tr/common.json | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/locales/en/common.json b/locales/en/common.json index 276aa3f01..6b7c65327 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -232,25 +232,25 @@ "switching_account": "Switching account..." }, "sidebar_apps": { - "modal_title": "Sidebar Apps", - "add_new": "Add App", - "edit_app": "Edit App", + "modal_title": "Sidebar apps", + "add_new": "Add app", + "edit_app": "Edit app", "name_label": "Name", - "name_placeholder": "My App", + "name_placeholder": "My app", "name_required": "Name is required", "url_label": "URL", "url_required": "URL is required", "url_invalid": "Enter a valid http or https URL", "icon_label": "Icon", "icon_required": "Icon is required", - "open_mode_label": "Open Mode", - "open_new_tab": "New Tab", + "open_mode_label": "Open mode", + "open_new_tab": "New tab", "open_inline": "Inline", "cancel": "Cancel", "add": "Add", "update": "Update", "delete": "Delete", - "delete_confirm_title": "Delete App", + "delete_confirm_title": "Delete app", "delete_confirm": "Are you sure you want to delete \"{name}\"?", "no_apps": "No apps added yet", "no_apps_hint": "Add custom apps and links to your sidebar", @@ -263,7 +263,7 @@ "no_icons_found": "No icons found", "inline_badge": "Inline", "tab_badge": "Tab", - "show_on_mobile": "Show on Mobile" + "show_on_mobile": "Show on mobile" }, "email_list": { "sections": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 7123bec3f..8b56a809e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -232,9 +232,9 @@ "switching_account": "Hesap değiştiriliyor..." }, "sidebar_apps": { - "modal_title": "Kenar Çubuğu Uygulamaları", - "add_new": "Uygulama Ekle", - "edit_app": "Uygulamayı Düzenle", + "modal_title": "Kenar çubuğu uygulamaları", + "add_new": "Uygulama ekle", + "edit_app": "Uygulamayı düzenle", "name_label": "Ad", "name_placeholder": "Uygulamam", "name_required": "Ad zorunludur", @@ -243,14 +243,14 @@ "url_invalid": "Geçerli bir http veya https URL girin", "icon_label": "Simge", "icon_required": "Simge zorunludur", - "open_mode_label": "Açılış Modu", - "open_new_tab": "Yeni Sekme", + "open_mode_label": "Açılış modu", + "open_new_tab": "Yeni sekme", "open_inline": "Yerleşik", "cancel": "İptal", "add": "Ekle", "update": "Güncelle", "delete": "Sil", - "delete_confirm_title": "Uygulamayı Sil", + "delete_confirm_title": "Uygulamayı sil", "delete_confirm": "\"{name}\" uygulamasını silmek istediğinizden emin misiniz?", "no_apps": "Henüz uygulama eklenmedi", "no_apps_hint": "Kenar çubuğunuza özel uygulamalar ve bağlantılar ekleyin", @@ -263,7 +263,7 @@ "no_icons_found": "Simge bulunamadı", "inline_badge": "Yerleşik", "tab_badge": "Sekme", - "show_on_mobile": "Mobilde Göster" + "show_on_mobile": "Mobilde göster" }, "email_list": { "sections": { From 89128d49c4dfdc185e817eb672b20d91863e6503 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:33:36 +0100 Subject: [PATCH 41/54] fix: make the desktop task editor a labelled dialog like the event editor --- .../__tests__/task-modal-calendars.test.tsx | 13 +++++++++++++ components/calendar/task-modal.tsx | 13 ++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/components/calendar/__tests__/task-modal-calendars.test.tsx b/components/calendar/__tests__/task-modal-calendars.test.tsx index 1bd0abf30..15eb01375 100644 --- a/components/calendar/__tests__/task-modal-calendars.test.tsx +++ b/components/calendar/__tests__/task-modal-calendars.test.tsx @@ -34,3 +34,16 @@ describe('task editor calendar select', () => { expect(select.value).toBe('birthdays'); }); }); + +// The desktop task panel had no dialog role, unlike the event editor. +describe('task editor panel', () => { + it('is a dialog named by its title on wide screens', () => { + render(); + expect(screen.getByRole('dialog', { name: 'tasks.create' })).toBeInTheDocument(); + }); + + it('leaves the role to the full-screen wrapper on a phone', () => { + render(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 26507e54d..fb89b9505 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -56,7 +56,7 @@ export function TaskModal({ onDelete, onClose, onDirtyChange, - isMobile: _isMobile, + isMobile = false, isSubscriptionCalendar, }: TaskModalProps) { const t = useTranslations("calendar"); @@ -213,10 +213,17 @@ export function TaskModal({ }, [requestClose, handleSave]); return ( -
+ // The desktop side panel is a dialog named by its title, like the event + // editor; on a phone the full-screen wrapper around it is. +
{/* Header */}
-

+

{isEdit ? t("tasks.edit") : t("tasks.create")}

From 5d95dc0830b15ec95e297cca31a0ecfc541ab868 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:37:33 +0100 Subject: [PATCH 42/54] fix: name the missing folder, tag or address under a rule's actions, mark the empty condition and focus the first field to fix --- .../__tests__/filter-rule-modal.test.tsx | 43 ++++++++++- components/filters/filter-rule-modal.tsx | 75 +++++++++++++++---- locales/ar/common.json | 5 ++ locales/ca/common.json | 5 ++ locales/cs/common.json | 5 ++ locales/da/common.json | 5 ++ locales/de/common.json | 5 ++ locales/en/common.json | 5 ++ locales/es/common.json | 5 ++ locales/et/common.json | 5 ++ locales/fa/common.json | 5 ++ locales/fr/common.json | 5 ++ locales/he/common.json | 5 ++ locales/hu/common.json | 5 ++ locales/it/common.json | 5 ++ locales/ja/common.json | 5 ++ locales/ko/common.json | 5 ++ locales/lv/common.json | 5 ++ locales/mn/common.json | 5 ++ locales/nb/common.json | 5 ++ locales/nl/common.json | 5 ++ locales/pl/common.json | 5 ++ locales/pt/common.json | 5 ++ locales/ro/common.json | 5 ++ locales/ru/common.json | 5 ++ locales/sk/common.json | 5 ++ locales/th/common.json | 5 ++ locales/tr/common.json | 5 ++ locales/uk/common.json | 5 ++ locales/zh-TW/common.json | 5 ++ locales/zh/common.json | 5 ++ 31 files changed, 247 insertions(+), 16 deletions(-) diff --git a/components/filters/__tests__/filter-rule-modal.test.tsx b/components/filters/__tests__/filter-rule-modal.test.tsx index 6e9062919..b0fd0937b 100644 --- a/components/filters/__tests__/filter-rule-modal.test.tsx +++ b/components/filters/__tests__/filter-rule-modal.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { FilterRule } from '@/lib/jmap/sieve-types'; import { toast } from '@/stores/toast-store'; @@ -82,7 +82,8 @@ describe('FilterRuleModal incomplete action', () => { expect(onSave).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled(); - expect(screen.getByRole('alert').textContent).toBe('validation_empty_actions'); + // The row is there; what it misses is the folder, not "an action". + expect(screen.getByRole('alert').textContent).toBe('validation_action_folder'); const folder = screen.getByRole('combobox', { name: 'move_to_folder' }); expect(folder.getAttribute('aria-invalid')).toBe('true'); @@ -92,6 +93,44 @@ describe('FilterRuleModal incomplete action', () => { expect(onSave.mock.calls[0][0].actions).toEqual([{ type: 'move', mailboxId: 'news', value: 'News' }]); }); + it('marks the empty condition too, and focuses the first field to fix', async () => { + const onSave = vi.fn(); + render( {}} />); + fireEvent.change(screen.getByPlaceholderText('rule_name_placeholder'), { target: { value: 'News' } }); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + const value = screen.getByRole('textbox', { name: 'condition_value' }); + const folder = screen.getByRole('combobox', { name: 'move_to_folder' }); + expect(value.getAttribute('aria-invalid')).toBe('true'); + expect(folder.getAttribute('aria-invalid')).toBe('true'); + expect(screen.getAllByRole('alert').map((a) => a.textContent)).toEqual([ + 'validation_empty_conditions', + 'validation_action_folder', + ]); + // The condition comes first on the form, so focus goes there, not to + // the folder. + await waitFor(() => expect(document.activeElement).toBe(value)); + + fireEvent.change(value, { target: { value: 'news@example.com' } }); + expect(value.getAttribute('aria-invalid')).toBe('false'); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('does not save a second action left without its folder', () => { + const onSave = vi.fn(); + render( {}} />); + fireEvent.change(screen.getByPlaceholderText('rule_name_placeholder'), { target: { value: 'News' } }); + fireEvent.change(screen.getByPlaceholderText('value_placeholder_multi'), { target: { value: 'news@example.com' } }); + fireEvent.change(screen.getByRole('combobox', { name: 'move_to_folder' }), { target: { value: 'news' } }); + fireEvent.click(screen.getByText('add_action')); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + expect(onSave).not.toHaveBeenCalled(); + const folders = screen.getAllByRole('combobox', { name: 'move_to_folder' }); + expect(folders.map((f) => f.getAttribute('aria-invalid'))).toEqual(['false', 'true']); + expect(screen.getByRole('alert').textContent).toBe('validation_action_folder'); + }); + it('starts a changed action without a folder too, like a new row', () => { render( {}} />); fireEvent.change(screen.getByRole('combobox', { name: 'actions' }), { target: { value: 'copy' } }); diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index e813d2331..9f4dabf21 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -260,7 +260,7 @@ export function FilterRuleModal({ }) .map((c) => (c.field === "header" ? { ...c, headerName: (c.headerName ?? "").trim() } : c)), [conditions]); - const validActions = useMemo(() => actions + const resolvedActions = useMemo(() => actions .map((a) => { if (!ACTIONS_WITH_MAILBOX.has(a.type)) return a; // Store the folder id next to the path and refresh the path from it, @@ -268,8 +268,17 @@ export function FilterRuleModal({ const mailboxId = mailboxIdFor(a); const path = mailboxId ? mailboxPathMap.get(mailboxId) : undefined; return mailboxId ? { ...a, mailboxId, value: path ?? a.value } : a; - }) - .filter((a) => !ACTIONS_WITH_VALUE.has(a.type) || a.value?.trim()), [actions, mailboxIdFor, mailboxPathMap]); + }), [actions, mailboxIdFor, mailboxPathMap]); + // A row still missing its folder, tag, address or message. + const actionIncomplete = useMemo( + () => resolvedActions.map((a) => ACTIONS_WITH_VALUE.has(a.type) && !a.value?.trim()), + [resolvedActions], + ); + const validActions = useMemo( + () => resolvedActions.filter((_, i) => !actionIncomplete[i]), + [resolvedActions, actionIncomplete], + ); + const firstIncompleteAction = resolvedActions.find((_, i) => actionIncomplete[i]); // Old mail can only be sorted by what the client can check the way Sieve // does. Until an action is complete (a Move still without its folder), only @@ -298,7 +307,17 @@ export function FilterRuleModal({ : validConditions.some((c) => c.field === "header" && !isValidSieveHeaderName(c.headerName ?? "")) ? t("validation_header_name") : null, - actions: validActions.length === 0 ? t("validation_empty_actions") : null, + // A row that is there but unfinished says what it misses; "at least one + // action" is left for when no row has a type at all. + actions: firstIncompleteAction + ? t(ACTIONS_WITH_MAILBOX.has(firstIncompleteAction.type) + ? "validation_action_folder" + : firstIncompleteAction.type === "add_label" + ? "validation_action_tag" + : firstIncompleteAction.type === "forward" + ? "validation_action_forward" + : "validation_action_reject") + : validActions.length === 0 ? t("validation_empty_actions") : null, period: !periodOn ? null // A field the browser holds half filled in (a date without its time) @@ -313,6 +332,14 @@ export function FilterRuleModal({ }; const hasErrors = Object.values(errors).some(Boolean); const shown = (error: string | null) => (showErrors ? error : null); + // The fields the shown errors are about: every empty value while no + // condition has one, a header name Sieve refuses, an unfinished action. + const valueMissing = (c: FilterCondition) => + !!shown(errors.conditions) && validConditions.length === 0 && !takesNoValue(c) && isConditionValueEmpty(c.value); + const headerInvalid = (c: FilterCondition) => + !!shown(errors.conditions) && c.field === "header" && !isConditionValueEmpty(c.value) && + !isValidSieveHeaderName((c.headerName ?? "").trim()); + const actionInvalid = (index: number) => !!shown(errors.actions) && actionIncomplete[index]; const handleSave = useCallback(() => { const halfFilled = periodOn && @@ -535,7 +562,10 @@ export function FilterRuleModal({ updateCondition(index, { headerName: e.target.value }) } placeholder={t("header_name")} - className="w-28" + aria-label={t("header_name")} + aria-invalid={headerInvalid(condition)} + data-rule-error={headerInvalid(condition)} + className={cn("w-28", headerInvalid(condition) && "border-destructive")} /> )} @@ -598,7 +628,11 @@ export function FilterRuleModal({ ? t("attachment_type_placeholder") : t("value_placeholder_multi") } - className="flex-1 min-w-[120px]" + aria-label={t("condition_value")} + aria-invalid={valueMissing(condition)} + aria-describedby={valueMissing(condition) ? "rule-conditions-error" : undefined} + data-rule-error={valueMissing(condition)} + className={cn("flex-1 min-w-[120px]", valueMissing(condition) && "border-destructive")} type={condition.field === "size" ? "number" : "text"} /> )} @@ -622,7 +656,7 @@ export function FilterRuleModal({ {t("add_condition")} - +
@@ -656,6 +690,7 @@ export function FilterRuleModal({ {ACTIONS_WITH_MAILBOX.has(action.type) && (() => { const selectedId = mailboxIdFor(action); + const invalid = actionInvalid(index); const missing = !!selectedId && !mailboxPathMap.has(selectedId); return ( updateAction(index, { value: e.target.value })} placeholder={t("reject_placeholder")} - className="flex-1 min-w-[180px]" + aria-label={t("reject_message")} + aria-invalid={actionInvalid(index)} + aria-describedby={actionInvalid(index) ? "rule-actions-error" : undefined} + data-rule-error={actionInvalid(index)} + className={cn("flex-1 min-w-[180px]", actionInvalid(index) && "border-destructive")} /> )} @@ -717,8 +761,11 @@ export function FilterRuleModal({ setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" /> + setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="@lg:w-20" />
@@ -839,7 +841,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
- setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" /> + setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="@lg:w-20" />
{/* Either name is enough (or an organization), so neither is starred. */} From 5abc4a5334747e3ad5eb5f99a02246e5384ecc79 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:44:18 +0100 Subject: [PATCH 48/54] fix: make the tag and address book row buttons and New tag full-size buttons with a touch hit area, and name each tag button after its tag --- .../__tests__/keyword-settings.test.tsx | 11 ++++ .../__tests__/settings-delete-undo.test.tsx | 3 +- .../address-book-management-settings.tsx | 53 +++++++------------ components/settings/keyword-settings.tsx | 39 +++++++------- locales/ar/common.json | 2 + locales/ca/common.json | 2 + locales/cs/common.json | 2 + locales/da/common.json | 2 + locales/de/common.json | 2 + locales/en/common.json | 2 + locales/es/common.json | 2 + locales/et/common.json | 2 + locales/fa/common.json | 2 + locales/fr/common.json | 2 + locales/he/common.json | 2 + locales/hu/common.json | 2 + locales/it/common.json | 2 + locales/ja/common.json | 2 + locales/ko/common.json | 2 + locales/lv/common.json | 2 + locales/mn/common.json | 2 + locales/nb/common.json | 2 + locales/nl/common.json | 2 + locales/pl/common.json | 2 + locales/pt/common.json | 2 + locales/ro/common.json | 2 + locales/ru/common.json | 2 + locales/sk/common.json | 2 + locales/th/common.json | 2 + locales/tr/common.json | 2 + locales/uk/common.json | 2 + locales/zh-TW/common.json | 2 + locales/zh/common.json | 2 + 33 files changed, 108 insertions(+), 56 deletions(-) diff --git a/components/settings/__tests__/keyword-settings.test.tsx b/components/settings/__tests__/keyword-settings.test.tsx index 333a37ecf..6e994ce58 100644 --- a/components/settings/__tests__/keyword-settings.test.tsx +++ b/components/settings/__tests__/keyword-settings.test.tsx @@ -89,6 +89,17 @@ describe('KeywordSettings', () => { expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')).toBeUndefined(); }); + // A list of "Edit" / "Delete" with nothing else said which tag a screen + // reader user was on; each button is named after its tag now, as a full + // size icon button (32px, 44px hit area on touch). + it('names each tag row button after its tag', () => { + render(); + expect(screen.getAllByRole('button', { name: 'edit_tag' })).toHaveLength(DEFAULT_KEYWORDS.length); + const deletes = screen.getAllByRole('button', { name: 'delete_tag' }); + expect(deletes).toHaveLength(DEFAULT_KEYWORDS.length); + expect(deletes[0].className).toContain('h-8 w-8'); + }); + it('shows edit form when edit button clicked', () => { render(); const editButtons = screen.getAllByTitle('edit'); diff --git a/components/settings/__tests__/settings-delete-undo.test.tsx b/components/settings/__tests__/settings-delete-undo.test.tsx index 63b633221..5433e965a 100644 --- a/components/settings/__tests__/settings-delete-undo.test.tsx +++ b/components/settings/__tests__/settings-delete-undo.test.tsx @@ -45,7 +45,8 @@ describe('deleting a tag', () => { render(); const second = DEFAULT_KEYWORDS[1]; - fireEvent.click(screen.getAllByRole('button', { name: 'delete' })[1]); + // Each Delete is named after its tag (the mocked t returns the key). + fireEvent.click(screen.getAllByRole('button', { name: 'delete_tag' })[1]); expect(useSettingsStore.getState().emailKeywords.map((k) => k.id)).not.toContain(second.id); expect(screen.queryByRole('alertdialog')).toBeNull(); diff --git a/components/settings/address-book-management-settings.tsx b/components/settings/address-book-management-settings.tsx index 6145958ba..a9f78de91 100644 --- a/components/settings/address-book-management-settings.tsx +++ b/components/settings/address-book-management-settings.tsx @@ -9,6 +9,7 @@ import { useManagedAccountStore } from "@/stores/managed-account-store"; import { toast } from "@/stores/toast-store"; import { SettingsSection } from "./settings-section"; import { cn } from "@/lib/utils"; +import { IconButton } from "@/components/ui/icon-button"; import type { AddressBook, AddressBookRights } from "@/lib/jmap/types"; import { ShareCollectionDialog } from "./share-collection-dialog"; import { PromptDialog } from "@/components/ui/prompt-dialog"; @@ -207,10 +208,10 @@ export function AddressBookManagementSettings() { >
- {book.name} + {book.name}
{book.isDefault && ( - + {t("default")} )} @@ -227,49 +228,31 @@ export function AddressBookManagementSettings() { ); })()} -
+
{canRename && ( - + setEditingId(book.id)} className="text-muted-foreground"> + + )} {canSetDefault && ( - + handleSetDefault(book)} disabled={isLoading} className="text-muted-foreground"> + + )} {!book.isShared && book.myRights?.mayShare && ( - + setSharingId(book.id)} className="text-muted-foreground"> + + )} {canDelete && ( - + + )}
diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index 27539452c..d2438404f 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -16,6 +16,8 @@ import { useEmailStore } from "@/stores/email-store"; import { useFilterStore } from "@/stores/filter-store"; import { SettingsSection, SettingItem, ToggleSwitch, Select, AdvancedOnly } from './settings-section'; import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2, Search } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { IconButton } from "@/components/ui/icon-button"; import { cn } from "@/lib/utils"; import { KEYWORD_PREFIX } from "@/lib/thread-utils"; import { findUnrecognizedKeywords, type UnrecognizedKeyword } from "@/lib/keyword-discovery"; @@ -93,6 +95,7 @@ function KeywordRow({ }) { const t = useTranslations("settings.keywords"); const hasChildren = hasChildKeywords(keyword.id, keywords); + const tagLabel = keyword.label || keyword.id; // Measured with the prefix attached, since that is what occupies the column. const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id]) .map((rendering) => KEYWORD_PREFIX + rendering); @@ -134,24 +137,19 @@ function KeywordRow({ ariaLabel={t("visibility_field")} className="text-xs py-1" /> -
- - + +
); @@ -727,17 +725,18 @@ export function KeywordSettings() { /> ) : (
- +
)}
diff --git a/locales/ar/common.json b/locales/ar/common.json index ed53371f8..823c03578 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1353,6 +1353,8 @@ "id_exists": "معرّف الوسم هذا موجود بالفعل", "edit": "تعديل الوسم", "delete": "حذف الوسم", + "edit_tag": "تعديل {name}", + "delete_tag": "حذف {name}", "deleted": "تم حذف الوسم", "save": "حفظ", "add": "إضافة", diff --git a/locales/ca/common.json b/locales/ca/common.json index d1140cb30..067b7f116 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1353,6 +1353,8 @@ "id_exists": "Aquest ID d'etiqueta ja existeix", "edit": "Edita l'etiqueta", "delete": "Suprimeix l'etiqueta", + "edit_tag": "Edita {name}", + "delete_tag": "Suprimeix {name}", "deleted": "Etiqueta suprimida", "save": "Desa", "add": "Afegeix", diff --git a/locales/cs/common.json b/locales/cs/common.json index a4de56156..3ea590e49 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1350,6 +1350,8 @@ "id_exists": "Toto ID štítku již existuje", "edit": "Upravit štítek", "delete": "Odstranit štítek", + "edit_tag": "Upravit {name}", + "delete_tag": "Smazat {name}", "deleted": "Štítek byl odstraněn", "save": "Uložit", "add": "Přidat", diff --git a/locales/da/common.json b/locales/da/common.json index d9b47bc99..e6518f354 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1353,6 +1353,8 @@ "id_exists": "Dette tag-ID findes allerede", "edit": "Redigér tag", "delete": "Slet tag", + "edit_tag": "Rediger {name}", + "delete_tag": "Slet {name}", "deleted": "Tag slettet", "save": "Gem", "add": "Tilføj", diff --git a/locales/de/common.json b/locales/de/common.json index 7a3c452b4..00ee17341 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1350,6 +1350,8 @@ "id_exists": "Diese Label-ID existiert bereits", "edit": "Label bearbeiten", "delete": "Label löschen", + "edit_tag": "{name} bearbeiten", + "delete_tag": "{name} löschen", "deleted": "Label gelöscht", "save": "Speichern", "add": "Hinzufügen", diff --git a/locales/en/common.json b/locales/en/common.json index 91582f0e1..a2323d3c5 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1353,6 +1353,8 @@ "id_exists": "This tag ID already exists", "edit": "Edit tag", "delete": "Delete tag", + "edit_tag": "Edit {name}", + "delete_tag": "Delete {name}", "deleted": "Tag deleted", "save": "Save", "add": "Add", diff --git a/locales/es/common.json b/locales/es/common.json index e199b0670..91602432e 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1350,6 +1350,8 @@ "id_exists": "Este ID de etiqueta ya existe", "edit": "Editar etiqueta", "delete": "Eliminar etiqueta", + "edit_tag": "Editar {name}", + "delete_tag": "Eliminar {name}", "deleted": "Etiqueta eliminada", "save": "Guardar", "add": "Añadir", diff --git a/locales/et/common.json b/locales/et/common.json index 85055ddce..a486a7ccb 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -1353,6 +1353,8 @@ "id_exists": "See sildi ID on juba kasutusel", "edit": "Muuda silti", "delete": "Kustuta silt", + "edit_tag": "Muuda {name}", + "delete_tag": "Kustuta {name}", "deleted": "Silt kustutatud", "save": "Salvesta", "add": "Lisa", diff --git a/locales/fa/common.json b/locales/fa/common.json index 6e6bbf648..32cca33bf 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1353,6 +1353,8 @@ "id_exists": "این شناسه قبلاً وجود دارد", "edit": "ویرایش برچسب", "delete": "حذف برچسب", + "edit_tag": "ویرایش {name}", + "delete_tag": "حذف {name}", "deleted": "برچسب حذف شد", "save": "ذخیره", "add": "افزودن", diff --git a/locales/fr/common.json b/locales/fr/common.json index 3bcef07e3..ba68726aa 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1350,6 +1350,8 @@ "id_exists": "Cet ID d'étiquette existe déjà", "edit": "Éditer l'étiquette", "delete": "Supprimer l'étiquette", + "edit_tag": "Modifier {name}", + "delete_tag": "Supprimer {name}", "deleted": "Étiquette supprimée", "save": "Enregistrer", "add": "Ajouter", diff --git a/locales/he/common.json b/locales/he/common.json index 9cb89dd7f..f3acd04f5 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1315,6 +1315,8 @@ "id_exists": "מזהה מילת מפתח זה כבר קיים", "edit": "ערוך מילת מפתח", "delete": "מחק מילת מפתח", + "edit_tag": "עריכת {name}", + "delete_tag": "מחיקת {name}", "deleted": "מילת המפתח נמחקה", "save": "שמור", "add": "לְהוֹסִיף", diff --git a/locales/hu/common.json b/locales/hu/common.json index 94f8ad664..f0ab1175d 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1353,6 +1353,8 @@ "id_exists": "Ez a címke azonosító már létezik", "edit": "Címke szerkesztése", "delete": "Címke törlése", + "edit_tag": "{name} szerkesztése", + "delete_tag": "{name} törlése", "deleted": "Címke törölve", "save": "Mentés", "add": "Hozzáadás", diff --git a/locales/it/common.json b/locales/it/common.json index 2de6a01ff..dc504b500 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1350,6 +1350,8 @@ "id_exists": "Questo ID etichetta esiste già", "edit": "Modifica etichetta", "delete": "Elimina etichetta", + "edit_tag": "Modifica {name}", + "delete_tag": "Elimina {name}", "deleted": "Etichetta eliminata", "save": "Salva", "add": "Aggiungi", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7ce5da10f..03b8fff4f 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1350,6 +1350,8 @@ "id_exists": "このラベルIDは既に存在します", "edit": "ラベルを編集", "delete": "ラベルを削除", + "edit_tag": "{name} を編集", + "delete_tag": "{name} を削除", "deleted": "ラベルを削除しました", "save": "保存", "add": "追加", diff --git a/locales/ko/common.json b/locales/ko/common.json index 29f52e38d..5bff2b2fe 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1350,6 +1350,8 @@ "id_exists": "이 태그 ID는 이미 존재합니다", "edit": "태그 편집", "delete": "태그 삭제", + "edit_tag": "{name} 편집", + "delete_tag": "{name} 삭제", "deleted": "태그가 삭제되었어요", "save": "저장", "add": "추가", diff --git a/locales/lv/common.json b/locales/lv/common.json index 5e6a711ec..ccc0e0277 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1350,6 +1350,8 @@ "id_exists": "Šāds taga identifikators jau pastāv", "edit": "Rediģēt tagu", "delete": "Dzēst tagu", + "edit_tag": "Rediģēt {name}", + "delete_tag": "Dzēst {name}", "deleted": "Tags izdzēsts", "save": "Saglabāt", "add": "Pievienot", diff --git a/locales/mn/common.json b/locales/mn/common.json index 2bc750466..13d2163cb 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1353,6 +1353,8 @@ "id_exists": "Энэ хаягийн ID аль хэдийн байна", "edit": "Таг засах", "delete": "Тагыг устгах", + "edit_tag": "{name}-г засах", + "delete_tag": "{name}-г устгах", "deleted": "Таг устгасан", "save": "Хадгалах", "add": "Нэмэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index 66f39372a..b52134802 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1353,6 +1353,8 @@ "id_exists": "Denne etikett-ID-en finnes allerede", "edit": "Rediger etikett", "delete": "Slett etikett", + "edit_tag": "Rediger {name}", + "delete_tag": "Slett {name}", "deleted": "Etikett slettet", "save": "Lagre", "add": "Legg til", diff --git a/locales/nl/common.json b/locales/nl/common.json index fd595555b..0471e7fba 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1350,6 +1350,8 @@ "id_exists": "Deze label-ID bestaat al", "edit": "Label bewerken", "delete": "Label verwijderen", + "edit_tag": "{name} bewerken", + "delete_tag": "{name} verwijderen", "deleted": "Label verwijderd", "save": "Opslaan", "add": "Toevoegen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 568bd8911..6688b5327 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1350,6 +1350,8 @@ "id_exists": "Ten ID etykiety już istnieje", "edit": "Edytuj etykietę", "delete": "Usuń etykietę", + "edit_tag": "Edytuj {name}", + "delete_tag": "Usuń {name}", "deleted": "Etykieta usunięta", "save": "Zapisz", "add": "Dodaj", diff --git a/locales/pt/common.json b/locales/pt/common.json index b4f28e0fd..62bbca327 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1350,6 +1350,8 @@ "id_exists": "Este ID de etiqueta já existe", "edit": "Editar etiqueta", "delete": "Eliminar etiqueta", + "edit_tag": "Editar {name}", + "delete_tag": "Excluir {name}", "deleted": "Etiqueta eliminada", "save": "Salvar", "add": "Adicionar", diff --git a/locales/ro/common.json b/locales/ro/common.json index de504c70a..8d18147b9 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1353,6 +1353,8 @@ "id_exists": "Această etichetă ID există deja", "edit": "Editați eticheta", "delete": "Șterge eticheta", + "edit_tag": "Editați {name}", + "delete_tag": "Ștergeți {name}", "deleted": "Eticheta a fost ștearsă", "save": "Salvare", "add": "Adăugați", diff --git a/locales/ru/common.json b/locales/ru/common.json index eeba5d2d3..819a5a418 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1350,6 +1350,8 @@ "id_exists": "Этот идентификатор тега уже существует", "edit": "Редактировать тег", "delete": "Удалить тег", + "edit_tag": "Изменить «{name}»", + "delete_tag": "Удалить «{name}»", "deleted": "Тег удалён", "save": "Сохранить", "add": "Добавить", diff --git a/locales/sk/common.json b/locales/sk/common.json index 66d1d0b58..bbabc7687 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1353,6 +1353,8 @@ "id_exists": "Toto ID štítku už existuje", "edit": "Upraviť štítok", "delete": "Odstrániť štítok", + "edit_tag": "Upraviť {name}", + "delete_tag": "Zmazať {name}", "deleted": "Štítok bol odstránený", "save": "Uložiť", "add": "Pridať", diff --git a/locales/th/common.json b/locales/th/common.json index cd740b2da..6e98d8b85 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -3534,6 +3534,8 @@ "id_exists": "รหัสแท็กนี้มีอยู่แล้ว", "edit": "แก้ไขแท็ก", "delete": "ลบแท็ก", + "edit_tag": "แก้ไข {name}", + "delete_tag": "ลบ {name}", "deleted": "ลบแท็กแล้ว", "save": "บันทึก", "add": "เพิ่ม", diff --git a/locales/tr/common.json b/locales/tr/common.json index dd9fdf7a3..392a7102e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1350,6 +1350,8 @@ "id_exists": "Bu etiket kimliği zaten mevcut", "edit": "Etiketi düzenle", "delete": "Etiketi sil", + "edit_tag": "{name} düzenle", + "delete_tag": "{name} sil", "deleted": "Etiket silindi", "save": "Kaydet", "add": "Ekle", diff --git a/locales/uk/common.json b/locales/uk/common.json index 0594999a3..ffcecb572 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1350,6 +1350,8 @@ "id_exists": "Цей ідентифікатор ключового слова вже існує", "edit": "Редагувати ключове слово", "delete": "Видалити ключове слово", + "edit_tag": "Редагувати «{name}»", + "delete_tag": "Видалити «{name}»", "deleted": "Ключове слово видалено", "save": "зберегти", "add": "додати", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index d40cab6f6..2b4e6f737 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1353,6 +1353,8 @@ "id_exists": "此標籤 ID 已存在", "edit": "編輯標籤", "delete": "刪除標籤", + "edit_tag": "編輯 {name}", + "delete_tag": "刪除 {name}", "deleted": "標籤已刪除", "save": "儲存", "add": "新增", diff --git a/locales/zh/common.json b/locales/zh/common.json index 9265219ce..d9fd86e5f 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1350,6 +1350,8 @@ "id_exists": "此标签ID已存在", "edit": "编辑标签", "delete": "删除标签", + "edit_tag": "编辑 {name}", + "delete_tag": "删除 {name}", "deleted": "标签已删除", "save": "保存", "add": "添加", From 87323d4137b79b126e7528abc3bf742c428838b1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:45:42 +0100 Subject: [PATCH 49/54] fix: keep tab in the week or day in view instead of walking into the hidden days on either side --- .../calendar-week-view-tab-order.test.tsx | 90 +++++++++++++++++++ components/calendar/calendar-day-view.tsx | 10 +++ components/calendar/calendar-week-view.tsx | 17 ++++ .../calendar/display-hours-controls.tsx | 5 +- components/calendar/event-card.tsx | 7 +- components/calendar/task-chip.tsx | 6 +- hooks/use-time-grid-focus.ts | 26 ++++-- 7 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 components/calendar/__tests__/calendar-week-view-tab-order.test.tsx diff --git a/components/calendar/__tests__/calendar-week-view-tab-order.test.tsx b/components/calendar/__tests__/calendar-week-view-tab-order.test.tsx new file mode 100644 index 000000000..a188afdf6 --- /dev/null +++ b/components/calendar/__tests__/calendar-week-view-tab-order.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { CalendarWeekView } from '../calendar-week-view'; +import { CalendarDayView } from '../calendar-day-view'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// Tab in the week view walked into the events of the weeks loaded on +// either side and scrolled the grid off its week. + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +function makeEvent(id: string, title: string, start: string, extra: Partial = {}): CalendarEvent { + return { + id, uid: id, '@type': 'Event', title, start, duration: 'PT1H', showWithoutTime: false, + calendarIds: { 'cal-1': true }, ...extra, + } as unknown as CalendarEvent; +} + +const events = [ + makeEvent('before', 'Last week', '2026-09-02T10:00:00'), + makeEvent('now', 'This week', '2026-09-09T10:00:00'), + makeEvent('after', 'Next week', '2026-09-16T10:00:00'), + makeEvent('trip', 'Trip', '2026-09-03T00:00:00', { showWithoutTime: true, duration: 'P1D' }), + makeEvent('fair', 'Fair', '2026-09-08T00:00:00', { showWithoutTime: true, duration: 'P1D' }), +]; + +const tabbable = (name: RegExp) => screen.getByRole('button', { name }).tabIndex >= 0; + +describe('tab order of the week view', () => { + it('leaves the events of the weeks on either side out of the tab order', () => { + render( + , + ); + expect(tabbable(/^This week/)).toBe(true); + expect(tabbable(/^Fair/)).toBe(true); + expect(tabbable(/^Last week/)).toBe(false); + expect(tabbable(/^Next week/)).toBe(false); + expect(tabbable(/^Trip/)).toBe(false); + }); + + it('keeps the grid tab stop on a day in view when the selected day is scrolled away', () => { + render( + , + ); + const stops = screen.getAllByRole('gridcell').filter((cell) => cell.tabIndex === 0); + expect(stops).toHaveLength(1); + expect(stops[0].getAttribute('data-slot')).toMatch(/^2026-09-07T/); + }); + + it('does the same in the day view', () => { + render( + , + ); + expect(tabbable(/^Tuesday/)).toBe(true); + expect(tabbable(/^Monday/)).toBe(false); + }); +}); diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index b2518068b..0d532d52c 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -295,6 +295,10 @@ export function CalendarDayView({ onMoveEvent, }); + // Tab stays in the day in view: the loaded days on either side are out of + // the tab order, or Tab walked into them and scrolled the strip. + const isInView = useCallback((key: string) => key === visibleKey, [visibleKey]); + const { slotProps } = useTimeGridFocus({ rootRef, headerRef, @@ -303,6 +307,7 @@ export function CalendarDayView({ hours, selectedDate, onCreate: onCreateAtTime, + isInView, }); const formatHour = (h: number): string => { @@ -408,6 +413,7 @@ export function CalendarDayView({ onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} currentUserEmails={currentUserEmails} + tabbable={key === visibleKey} /> ); })} @@ -427,6 +433,7 @@ export function CalendarDayView({ onToggleComplete={onToggleTaskComplete} onSelect={onSelectTask} touch={isMobile} + tabbable={key === visibleKey} />
))} @@ -504,6 +511,7 @@ export function CalendarDayView({ count={dayLayout.before} direction="before" onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)} + tabbable={key === visibleKey} /> )} {dayLayout && dayLayout.after > 0 && ( @@ -511,6 +519,7 @@ export function CalendarDayView({ count={dayLayout.after} direction="after" onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)} + tabbable={key === visibleKey} /> )} @@ -548,6 +557,7 @@ export function CalendarDayView({ onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} currentUserEmails={currentUserEmails} + tabbable={key === visibleKey} draggable={editable} continuesBefore={clip.clippedStart} continuesAfter={clip.clippedEnd} diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 506e394d4..892db04eb 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -394,6 +394,17 @@ export function CalendarWeekView({ onMoveEvent, }); + // The days in view. Tab stays in them: events of the weeks loaded on + // either side are out of the tab order, or Tab walked into them and + // scrolled the grid off its week. + const inViewKeys = useMemo( + () => new Set(days.slice(visibleStartIndex, visibleStartIndex + visibleColumns).map(dayKey)), + [days, visibleStartIndex, visibleColumns], + ); + const isInView = useCallback((key: string) => inViewKeys.has(key), [inViewKeys]); + const spanInView = (startIndex: number, span: number) => + startIndex < visibleStartIndex + visibleColumns && startIndex + span > visibleStartIndex; + const { slotProps, tabStopDayKey } = useTimeGridFocus({ rootRef, headerRef, @@ -402,6 +413,7 @@ export function CalendarWeekView({ hours, selectedDate, onCreate: onCreateAtTime, + isInView, }); const formatHour = (h: number): string => { @@ -553,6 +565,7 @@ export function CalendarWeekView({ onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} currentUserEmails={currentUserEmails} + tabbable={spanInView(segment.startIndex, segment.span)} />
); @@ -606,6 +619,7 @@ export function CalendarWeekView({ calendar={calendars.find(c => task.calendarIds[c.id])} onToggleComplete={onToggleTaskComplete} onSelect={onSelectTask} + tabbable={inViewKeys.has(key)} />
)); @@ -681,6 +695,7 @@ export function CalendarWeekView({ count={dayLayout.before} direction="before" onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)} + tabbable={inViewKeys.has(key)} /> )} {dayLayout && dayLayout.after > 0 && ( @@ -688,6 +703,7 @@ export function CalendarWeekView({ count={dayLayout.after} direction="after" onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)} + tabbable={inViewKeys.has(key)} /> )} @@ -728,6 +744,7 @@ export function CalendarWeekView({ draggable={editable} continuesBefore={clip.clippedStart} continuesAfter={clip.clippedEnd} + tabbable={inViewKeys.has(key)} /> {/* An edge beyond the visible hours has nothing to grab. */} {editable && !clip.clippedStart && ( diff --git a/components/calendar/display-hours-controls.tsx b/components/calendar/display-hours-controls.tsx index 01cd9ab6a..09ddbe575 100644 --- a/components/calendar/display-hours-controls.tsx +++ b/components/calendar/display-hours-controls.tsx @@ -9,13 +9,15 @@ interface HiddenEventsIndicatorProps { count: number; direction: "before" | "after"; onReveal: () => void; + /** False for a day scrolled out of view: Tab skips it. */ + tabbable?: boolean; } /** * Marks a day column that has events above or below the visible hours * (#1164); clicking it shows the whole day at the first of them. */ -export function HiddenEventsIndicator({ count, direction, onReveal }: HiddenEventsIndicatorProps) { +export function HiddenEventsIndicator({ count, direction, onReveal, tabbable = true }: HiddenEventsIndicatorProps) { const t = useTranslations("calendar.events"); const label = direction === "before" ? t("hidden_before", { count }) : t("hidden_after", { count }); const Icon = direction === "before" ? ChevronUp : ChevronDown; @@ -23,6 +25,7 @@ export function HiddenEventsIndicator({ count, direction, onReveal }: HiddenEven
)} @@ -851,7 +851,7 @@ export function CalendarWeekView({ {pendingPreview.title}
- {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} + {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
); diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 76f9f1143..9bc5c4073 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -197,7 +197,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM )} style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected }), ...style }} > - {event.title || t("events.no_title")} + {event.title || t("events.no_title")} ); } @@ -227,7 +227,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM {showTimeInMonthView && !event.showWithoutTime && ( {format(startDate, timeFmt)} )} - {event.title || t("events.no_title")} + {event.title || t("events.no_title")}
); @@ -268,7 +268,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM block is wide enough to keep the title readable. */}
- {event.title || t("events.no_title")} + {event.title || t("events.no_title")} {!event.showWithoutTime && durationMinutes < 45 && ( {safeFormat(startDate, timeFmt)} )} @@ -276,7 +276,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{!event.showWithoutTime && durationMinutes >= 45 && (
- {timeString} + {/* Start before end in a right-to-left layout too. */} + {timeString}
)} {getParticipantCount(event) > 0 && ( diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 63399b897..5402d6dbb 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -423,7 +423,8 @@ export function EventDetailPopover({ "text-lg font-semibold leading-snug break-words text-foreground", event.status === "cancelled" && "line-through text-muted-foreground" )}> - {event.title || t("events.no_title")} + {/* Its own direction, next to the colour square. */} + {event.title || t("events.no_title")} {event.status === "tentative" && ( @@ -486,7 +487,7 @@ export function EventDetailPopover({ {t("events.all_day")} ) : (
- {formatTime(startDate)} – {formatTime(endDate)} + {formatTime(startDate)} – {formatTime(endDate)} ({formatDurationDisplay(durationMinutes)})
)} @@ -504,6 +505,7 @@ export function EventDetailPopover({ target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline min-w-0 break-words" + dir="auto" title={locationName} > {/^https?:\/\//i.test(locationName) @@ -557,7 +559,7 @@ export function EventDetailPopover({
{participants.slice(0, 5).map((p) => (
- + {p.name || p.email} {p.isOrganizer && ( @@ -598,7 +600,7 @@ export function EventDetailPopover({ {calendar && (
- {calendar.name} + {calendar.name}
)} @@ -606,7 +608,7 @@ export function EventDetailPopover({ {event.description && (
-

+

diff --git a/components/calendar/task-chip.tsx b/components/calendar/task-chip.tsx index 687787a6f..e44ee92be 100644 --- a/components/calendar/task-chip.tsx +++ b/components/calendar/task-chip.tsx @@ -68,6 +68,7 @@ export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, t isCompleted && "line-through text-muted-foreground", )} title={title} + dir="auto" > {title} From 8e278b940a5d107c0719eb0768ab73515390ef17 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:58:35 +0100 Subject: [PATCH 53/54] fix: fit long view names in the phone view switcher and show the focus ring of a time slot under an event --- components/calendar/calendar-day-view.tsx | 2 +- components/calendar/calendar-toolbar.tsx | 4 +++- components/calendar/calendar-week-view.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index e5ee06ff6..db225598a 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -501,7 +501,7 @@ export function CalendarDayView({ onClick={() => handleSlotClick(day, h)} onDoubleClick={() => handleSlotDoubleClick(day, h)} onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined} - className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary" + className="relative border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[15] focus-visible:after:pointer-events-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-primary" style={{ height: hourHeight }} /> ))} diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 5ef164429..e22a46ecc 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -267,6 +267,8 @@ export function CalendarToolbar({ )} {/* Row 2: View switcher pills + calendar toggle */} + {/* Each pill is as wide as its label plus an even share of the rest, so a + long label ("جدول الأعمال", "Aufgaben") neither wraps nor touches the edge. */}
{views.map((v) => ( @@ -274,7 +276,7 @@ export function CalendarToolbar({ key={v} onClick={() => onViewModeChange(v)} className={cn( - "flex-1 min-h-11 text-[11px] font-medium transition-colors touch-manipulation", + "flex-auto min-h-11 px-1.5 text-[11px] font-medium transition-colors touch-manipulation", v === viewMode ? "bg-primary text-primary-foreground" : "text-muted-foreground active:bg-muted" diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 2656d7ccd..20618acfb 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -685,7 +685,7 @@ export function CalendarWeekView({ onClick={() => handleSlotClick(day, h)} onDoubleClick={() => handleSlotDoubleClick(day, h)} onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined} - className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary" + className="relative border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[15] focus-visible:after:pointer-events-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-primary" style={{ height: hourHeight }} /> ))} From 19369be8b6690ad2355b055e9ef201e5f2f16c47 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:01:41 +0100 Subject: [PATCH 54/54] test: drop a prop the agenda view doesn't take from the right-to-left test --- components/calendar/__tests__/calendar-rtl-text.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/calendar/__tests__/calendar-rtl-text.test.tsx b/components/calendar/__tests__/calendar-rtl-text.test.tsx index b43bbc478..9b728c1f6 100644 --- a/components/calendar/__tests__/calendar-rtl-text.test.tsx +++ b/components/calendar/__tests__/calendar-rtl-text.test.tsx @@ -49,7 +49,6 @@ describe('user text and times in a right-to-left layout', () => { it('does the same in the agenda', () => { render(