From 811212a944d51df576566085b6d18aa9ac0f24a1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:03:21 +0100 Subject: [PATCH 01/50] fix: show the date for mail older than six days instead of a weekday that reads as today --- lib/__tests__/format-date-weekday.test.ts | 33 +++++++++++++++++++++++ lib/utils.ts | 9 +++++-- 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 lib/__tests__/format-date-weekday.test.ts diff --git a/lib/__tests__/format-date-weekday.test.ts b/lib/__tests__/format-date-weekday.test.ts new file mode 100644 index 000000000..d0f3db4ec --- /dev/null +++ b/lib/__tests__/format-date-weekday.test.ts @@ -0,0 +1,33 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { formatDate } from '../utils'; +import { useSettingsStore } from '@/stores/settings-store'; + +// mail-p2-10: on Wednesday Nov 4 at 10:40, a mail from Wednesday Oct 28 at +// 17:40 read "Wed 17:40", as if it came later today. The weekday names only +// the last six calendar days; older mail shows its date. + +describe('formatDate weekday window', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-11-04T09:40:00Z')); // Wed 10:40 in Berlin + useSettingsStore.setState({ dateFormat: 'smart', timeFormat: '24h', dateLocale: 'auto', timeZone: 'Europe/Berlin' }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('gives a mail from the same weekday last week its date, not the weekday', () => { + expect(formatDate(new Date('2026-10-28T16:40:00Z'))).toBe('Oct 28'); + }); + + it('keeps the weekday for the last six calendar days', () => { + expect(formatDate(new Date('2026-10-29T16:40:00Z'))).toBe('Thu 17:40'); + expect(formatDate(new Date('2026-11-03T22:40:00Z'))).toBe('Tue 23:40'); + }); + + it('counts days in the display zone, not 24-hour periods', () => { + // Late on Oct 28 in Berlin is less than seven 24-hour periods ago but + // still last Wednesday. + expect(formatDate(new Date('2026-10-28T22:30:00Z'))).toBe('Oct 28'); + }); +}); diff --git a/lib/utils.ts b/lib/utils.ts index b7ad19197..03f8e928f 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -193,8 +193,13 @@ export function formatDate( return cachedDateFormat(numericLocale, { timeZone, year: "2-digit", month: "numeric", day: "numeric" }).format(d); } - const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000); - if (daysAgo < 7) { + // Count calendar days in the display zone, not 24-hour periods: a mail + // from last Wednesday must never read "Wed" on a Wednesday (mail-p2-10). + // The weekday only names the last six days; older mail gets its date. + const daysAgo = Math.round( + (Date.UTC(nowWall.year, nowWall.month - 1, nowWall.day) - Date.UTC(dWall.year, dWall.month - 1, dWall.day)) / 86400000, + ); + if (daysAgo >= 0 && daysAgo < 7) { // German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`; // strip it so the result reads cleanly next to the time. const weekday = cachedDateFormat(uiLocale, { timeZone, weekday: "short" }) From c28e2ec5cb8db4a0a7473ecfde463c292b7eb31b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:06:36 +0100 Subject: [PATCH 02/50] fix: keep the names of Reply, Archive, Delete and Move in the reader toolbar while they fit --- app/globals.css | 6 ++-- .../reader-toolbar-labels-first.test.tsx | 18 +++++++++++ components/email/email-viewer.tsx | 30 +++++++++++-------- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/app/globals.css b/app/globals.css index 6ecc0f095..315f59981 100644 --- a/app/globals.css +++ b/app/globals.css @@ -593,8 +593,10 @@ optgroup { animation: toast-exit 0.28s cubic-bezier(0.06, 0.71, 0.55, 1) forwards; } -/* The reader toolbar with no room for its labels: icons only, the names stay in the tooltips. */ -[data-toolbar-compact] [data-toolbar-label] { +/* The reader toolbar with no room for its labels: icons only, the names stay in the tooltips. + "secondary" first keeps the names of Reply, Archive, Delete and Move (mail-p2-15). */ +[data-toolbar-compact="secondary"] [data-toolbar-label]:not([data-toolbar-label="primary"]), +[data-toolbar-compact="all"] [data-toolbar-label] { display: none !important; } diff --git a/components/email/__tests__/reader-toolbar-labels-first.test.tsx b/components/email/__tests__/reader-toolbar-labels-first.test.tsx index ea51036a2..47c8f6d50 100644 --- a/components/email/__tests__/reader-toolbar-labels-first.test.tsx +++ b/components/email/__tests__/reader-toolbar-labels-first.test.tsx @@ -96,6 +96,24 @@ describe('reader toolbar overflow', () => { expect(labels).toEqual(expect.arrayContaining(['reply', 'reply_all', 'forward', 'archive', 'delete', 'move'])); }); + it('keeps the names of Reply, Archive, Delete and Move while only those fit (mail-p2-15)', () => { + // 600 px: every label needs 400 per group, the main four's 280, none 250. + const spies = [ + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { + const step = this.closest('[data-toolbar-compact]')?.getAttribute('data-toolbar-compact'); + return step === 'all' ? 250 : step === 'secondary' ? 280 : 400; + }), + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(600), + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(40), + ]; + restore = () => spies.forEach((spy) => spy.mockRestore()); + const { container } = viewer(); + expect(toolbar(container).getAttribute('data-toolbar-compact')).toBe('secondary'); + expect(hiddenPriorities(container)).toEqual([]); + const kept = Array.from(container.querySelectorAll('[data-toolbar-label="primary"]')).map((label) => label.textContent); + expect(kept.sort()).toEqual(['archive', 'delete', 'move', 'reply']); + }); + it('then hides the optional buttons, never Reply, Reply all, Forward, Archive or Move', () => { restore = layout(500, 400); const { container } = viewer(); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 80ffc1c4c..6c2ae86a0 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -231,6 +231,8 @@ const TOOLBAR_PRIORITY_ACTIONS: Record = { const REQUIRED_TOOLBAR_PRIORITIES = [5, 3, 2, 4, 1] as const; /** Set on the toolbar while it shows icons without their labels. */ const TOOLBAR_COMPACT_ATTRIBUTE = 'data-toolbar-compact'; +/** Which labels the reader toolbar has dropped: none, all but the main four, or all. */ +type ToolbarCompact = false | 'secondary' | 'all'; const NO_ACTION_KEYS: ReadonlySet = new Set(); const NO_PLUGIN_KEYS: ReadonlySet = new Set(); @@ -1854,7 +1856,7 @@ export function EmailViewer({ // looked like (see layoutKey), and the width it last had: an open that // brings back a toolbar already measured at this width needs no measuring. // A loading message and a loaded one can differ, so a few are kept. - const measuredToolbarRef = useRef(new Map; compact: boolean }>()); + const measuredToolbarRef = useRef(new Map; compact: ToolbarCompact }>()); const toolbarWidthRef = useRef(undefined); const currentTagIds = getEmailTagIds(email?.keywords); const sortedTagIds = sortTagIds(currentTagIds); @@ -2117,7 +2119,8 @@ export function EmailViewer({ const key = layoutKey(items); const known = toolbarWidthRef.current !== undefined ? measuredToolbarRef.current.get(key) : undefined; if (known) { - el.toggleAttribute(TOOLBAR_COMPACT_ATTRIBUTE, known.compact); + if (known.compact) el.setAttribute(TOOLBAR_COMPACT_ATTRIBUTE, known.compact); + else el.removeAttribute(TOOLBAR_COMPACT_ATTRIBUTE); for (const item of items) item.style.display = known.hidden.has(Number(item.dataset.overflowPriority)) ? 'none' : ''; applyHidden(known.hidden); return; @@ -2135,10 +2138,13 @@ export function EmailViewer({ const containerWidth = el.clientWidth; const overflowNow = () => leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap - (containerWidth + 1); let overflow = overflowNow(); - // The labels go before any button does (icons keep their tooltips). - const compact = overflow > 0; - if (compact) { - el.setAttribute(TOOLBAR_COMPACT_ATTRIBUTE, ''); + // The labels go before any button does (icons keep their tooltips): + // first all but Reply, Archive, Delete and Move, then those too. + let compact: ToolbarCompact = false; + for (const step of ['secondary', 'all'] as const) { + if (overflow <= 0) break; + compact = step; + el.setAttribute(TOOLBAR_COMPACT_ATTRIBUTE, step); overflow = overflowNow(); } const hidden = new Set(); @@ -4477,7 +4483,7 @@ ${printWatermark ? printWatermark.html : ''} title={t('tooltips.reply')} > - {showToolbarLabels && {t('reply')}} + {showToolbarLabels && {t('reply')}} )} {isInTrashFolder && restoreTarget && onMoveToMailbox && ( @@ -4551,7 +4557,7 @@ ${printWatermark ? printWatermark.html : ''} title={tActions('restore_to', { folder: localizeMailboxName(restoreTarget.role, restoreTarget.name, (key) => tSidebarNames(`mailboxes.${key}`)) })} > - {showToolbarLabels && {tActions('restore')}} + {showToolbarLabels && {tActions('restore')}} )} {archiveActionEnabled && !isInJunkFolder && !isInTrashFolder && ( @@ -4565,7 +4571,7 @@ ${printWatermark ? printWatermark.html : ''} title={t('tooltips.archive')} > - {showToolbarLabels && {t('archive')}} + {showToolbarLabels && {t('archive')}} )} {/* Delete */} @@ -4578,7 +4584,7 @@ ${printWatermark ? printWatermark.html : ''} data-testid="reader-delete" > - {showToolbarLabels && {deleteIsPermanent ? tActions('delete_forever') : t('delete')}} + {showToolbarLabels && {deleteIsPermanent ? tActions('delete_forever') : t('delete')}} {/* Move to folder */} {canMoveAnywhere && onMoveToMailbox && ( @@ -4598,7 +4604,7 @@ ${printWatermark ? printWatermark.html : ''} aria-expanded={moveMenuOpen} > - {showToolbarLabels && {t('move')}} + {showToolbarLabels && {t('move')}}
From ac4c53b247282ca36713b19d6637e11e80cf30fd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:08:16 +0100 Subject: [PATCH 03/50] fix: show the welcome tips once per device and point them at the bar on the left --- .../ui/__tests__/welcome-banner.test.tsx | 39 ++++++++++++++++- components/ui/welcome-banner.tsx | 42 +++++++++---------- 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/vi/common.json | 2 +- locales/zh-TW/common.json | 2 +- locales/zh/common.json | 2 +- 32 files changed, 87 insertions(+), 54 deletions(-) diff --git a/components/ui/__tests__/welcome-banner.test.tsx b/components/ui/__tests__/welcome-banner.test.tsx index 2ff6b50fb..f1637455e 100644 --- a/components/ui/__tests__/welcome-banner.test.tsx +++ b/components/ui/__tests__/welcome-banner.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, renderHook, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; @@ -12,7 +12,7 @@ vi.mock('@/hooks/use-media-query', () => ({ vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) })); vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) })); -const { WelcomeBanner } = await import('../welcome-banner'); +const { WelcomeBanner, useWelcomeBannerDue } = await import('../welcome-banner'); const { useSettingsStore } = await import('@/stores/settings-store'); describe('WelcomeBanner tips', () => { @@ -50,3 +50,38 @@ describe('WelcomeBanner and Escape', () => { expect(useSettingsStore.getState().onboardingCompleted).toBe(false); }); }); + +// pass2 mail-p2-17: settings are per account, so a second account started +// with onboardingCompleted false and showed the tips again on the same device. +describe('WelcomeBanner once per device', () => { + beforeEach(() => { + localStorage.clear(); + device.isMobile = false; + }); + + it('stays away for an account added on a device that has seen the tips', () => { + localStorage.setItem('onboarding_completed', 'true'); + useSettingsStore.setState({ onboardingCompleted: false, showOnboardingOnNewDevices: false }); + const { container } = render(); + expect(container.querySelector('[data-welcome]')).toBeNull(); + }); + + it('is not due for that account either, so first-start notices are not held back', () => { + localStorage.setItem('onboarding_completed', 'true'); + useSettingsStore.setState({ onboardingCompleted: false, showOnboardingOnNewDevices: false }); + const { result } = renderHook(() => useWelcomeBannerDue()); + expect(result.current).toBe(false); + }); + + it('shows on a device that has not seen them', () => { + useSettingsStore.setState({ onboardingCompleted: false, showOnboardingOnNewDevices: false }); + render(); + expect(screen.getByRole('complementary')).toBeInTheDocument(); + }); + + it('points at the bar on the left, not a sidebar menu', () => { + useSettingsStore.setState({ onboardingCompleted: false }); + render(); + expect(screen.getByText('tip_sidebar')).toBeInTheDocument(); + }); +}); diff --git a/components/ui/welcome-banner.tsx b/components/ui/welcome-banner.tsx index 1b2a9b5cb..3845aa4ee 100644 --- a/components/ui/welcome-banner.tsx +++ b/components/ui/welcome-banner.tsx @@ -18,19 +18,29 @@ export function useWelcomeBannerDue(): boolean { const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices); const [due, setDue] = useState(false); useEffect(() => { - if (!onboardingCompleted) { - setDue(true); - return; - } - let seenHere = true; - try { - seenHere = localStorage.getItem(ONBOARDING_KEY) === "true"; - } catch { /* localStorage unavailable */ } - setDue(showOnboardingOnNewDevices && !seenHere); + setDue(welcomeTipsDue(onboardingCompleted, showOnboardingOnNewDevices, seenOnThisDevice())); }, [onboardingCompleted, showOnboardingOnNewDevices]); return due; } +function seenOnThisDevice(): boolean { + try { + return localStorage.getItem(ONBOARDING_KEY) === "true"; + } catch { + return false; + } +} + +/** + * The tips show once per device. Settings are kept per account, so an + * account added later starts with `onboardingCompleted` false; the device's + * own flag keeps the tips from coming back for it. + */ +export function welcomeTipsDue(onboardingCompleted: boolean, showOnNewDevices: boolean, seenHere: boolean): boolean { + if (seenHere) return false; + return !onboardingCompleted || showOnNewDevices; +} + /** * First-run tips. `list` sits above the message list (phones, and layouts * without a reading pane beside the list); `pane` is a card in the empty @@ -61,19 +71,7 @@ export function WelcomeBanner({ variant = "list" }: { variant?: "list" | "pane" }, [onboardingCompleted, updateSetting]); useEffect(() => { - if (!onboardingCompleted) { - setVisible(true); - return; - } - if (showOnboardingOnNewDevices) { - try { - if (localStorage.getItem(ONBOARDING_KEY) !== "true") { - setVisible(true); - return; - } - } catch { /* localStorage unavailable */ } - } - setVisible(false); + setVisible(welcomeTipsDue(onboardingCompleted, showOnboardingOnNewDevices, seenOnThisDevice())); }, [onboardingCompleted, showOnboardingOnNewDevices]); const dismiss = useCallback(() => { diff --git a/locales/ar/common.json b/locales/ar/common.json index 999cafaf0..f70c1c951 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5367,7 +5367,7 @@ "title": "مرحبًا بك في صندوق بريدك", "tip_compose": "اضغط c لإنشاء رسالة جديدة", "tip_shortcuts": "اضغط ؟ لعرض جميع اختصارات لوحة المفاتيح", - "tip_sidebar": "ستجد التقويم وجهات الاتصال والإعدادات في قائمة الشريط الجانبي", + "tip_sidebar": "التقويم وجهات الاتصال والإعدادات في الشريط على اليمين", "tip_settings": "خصّص تجربتك من الإعدادات", "tip_compose_touch": "اضغط زر القلم لكتابة رسالة جديدة", "tip_sidebar_touch": "ستجد التقويم وجهات الاتصال والإعدادات في الشريط السفلي", diff --git a/locales/ca/common.json b/locales/ca/common.json index 091cd601c..710f9a27d 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5367,7 +5367,7 @@ "title": "Us donem la benvinguda a la bústia", "tip_compose": "Premeu c per redactar un correu nou", "tip_shortcuts": "Premeu ? per veure totes les dreceres de teclat", - "tip_sidebar": "Trobeu el Calendari, els Contactes i la Configuració al menú de la barra lateral", + "tip_sidebar": "Calendari, Contactes i Configuració són a la barra de l'esquerra", "tip_settings": "Personalitzeu la vostra experiència a la Configuració", "tip_compose_touch": "Toca el botó del llapis per escriure un correu nou", "tip_sidebar_touch": "Trobaràs el Calendari, els Contactes i la Configuració a la barra inferior", diff --git a/locales/cs/common.json b/locales/cs/common.json index b2ae541cf..919009228 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5192,7 +5192,7 @@ "title": "Vítejte ve své poštovní schránce", "tip_compose": "Stiskněte c pro napsání nové zprávy", "tip_shortcuts": "Stiskněte ? pro zobrazení všech klávesových zkratek", - "tip_sidebar": "Kalendář, Kontakty a Nastavení najdete v postranním panelu", + "tip_sidebar": "Kalendář, Kontakty a Nastavení najdete na liště vlevo", "tip_settings": "Přizpůsobte si aplikaci v Nastavení", "tip_compose_touch": "Klepnutím na tlačítko s tužkou napíšete nový e-mail", "tip_sidebar_touch": "Kalendář, Kontakty a Nastavení najdete na liště dole", diff --git a/locales/da/common.json b/locales/da/common.json index 7d48f4b15..e5a7eb79a 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5367,7 +5367,7 @@ "title": "Velkommen til din postkasse", "tip_compose": "Tryk c for at skrive en ny e-mail", "tip_shortcuts": "Tryk ? for at se alle tastaturgenveje", - "tip_sidebar": "Find Kalender, Kontakter og Indstillinger i sidepanelets menu", + "tip_sidebar": "Kalender, Kontakter og Indstillinger findes i bjælken til venstre", "tip_settings": "Tilpas din oplevelse i Indstillinger", "tip_compose_touch": "Tryk på blyantknappen for at skrive en ny e-mail", "tip_sidebar_touch": "Kalender, Kontakter og Indstillinger finder du i bjælken nederst", diff --git a/locales/de/common.json b/locales/de/common.json index e1e33c934..b328243c0 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5192,7 +5192,7 @@ "title": "Willkommen in Ihrem Postfach", "tip_compose": "Drücken Sie c, um eine neue E-Mail zu verfassen", "tip_shortcuts": "Drücken Sie ?, um alle Tastaturkürzel anzuzeigen", - "tip_sidebar": "Finden Sie Kalender, Kontakte und Einstellungen im Seitenmenü", + "tip_sidebar": "Kalender, Kontakte und Einstellungen finden Sie in der Leiste links", "tip_settings": "Passen Sie Ihre Erfahrung in den Einstellungen an", "tip_compose_touch": "Tippen Sie auf den Stift, um eine neue E-Mail zu schreiben", "tip_sidebar_touch": "Kalender, Kontakte und Einstellungen finden Sie in der Leiste unten", diff --git a/locales/en/common.json b/locales/en/common.json index ae08611b3..f1b0024c5 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5367,7 +5367,7 @@ "title": "Welcome to your mailbox", "tip_compose": "Press c to compose a new email", "tip_shortcuts": "Press ? to see all keyboard shortcuts", - "tip_sidebar": "Find Calendar, Contacts and Settings in the sidebar menu", + "tip_sidebar": "Calendar, Contacts and Settings are in the bar on the left", "tip_settings": "Customize your experience in Settings", "tip_compose_touch": "Tap the pencil button to write a new email", "tip_sidebar_touch": "Find Calendar, Contacts and Settings in the bar at the bottom", diff --git a/locales/es/common.json b/locales/es/common.json index e4c6f39e9..553014e4b 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5192,7 +5192,7 @@ "title": "Bienvenido a tu buzón", "tip_compose": "Pulsa c para redactar un nuevo correo", "tip_shortcuts": "Pulsa ? para ver todos los atajos de teclado", - "tip_sidebar": "Encuentra Calendario, Contactos y Ajustes en el menú lateral", + "tip_sidebar": "Calendario, Contactos y Ajustes están en la barra de la izquierda", "tip_settings": "Personaliza tu experiencia en Ajustes", "tip_compose_touch": "Toca el botón del lápiz para escribir un correo nuevo", "tip_sidebar_touch": "Encontrarás Calendario, Contactos y Ajustes en la barra inferior", diff --git a/locales/et/common.json b/locales/et/common.json index 30ac1ee96..347c84e64 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5367,7 +5367,7 @@ "title": "Tere tulemast sinu postkasti", "tip_compose": "Vajuta c uue e-kirja kirjutamiseks", "tip_shortcuts": "Vajuta ? kõigi kiirklahvide nägemiseks", - "tip_sidebar": "Kalender, Kontaktid ja Seaded leiad külgmenüüst", + "tip_sidebar": "Kalender, Kontaktid ja Seaded on vasakul ribal", "tip_settings": "Kohanda oma kogemust seadetes", "tip_compose_touch": "Uue kirja kirjutamiseks puuduta pliiatsinuppu", "tip_sidebar_touch": "Kalender, kontaktid ja seaded asuvad alumisel ribal", diff --git a/locales/fa/common.json b/locales/fa/common.json index 117abe12f..ec006e882 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5367,7 +5367,7 @@ "title": "به صندوق پستی خود خوش آمدید", "tip_compose": "کلید c را برای ایمیل جدید فشار دهید", "tip_shortcuts": "کلید ? را برای مشاهده میانبرها فشار دهید", - "tip_sidebar": "تقویم، مخاطبین و تنظیمات در نوار کناری", + "tip_sidebar": "تقویم، مخاطبان و تنظیمات در نوار سمت راست هستند", "tip_settings": "تجربه خود را در تنظیمات سفارشی کنید", "tip_compose_touch": "برای نوشتن ایمیل جدید روی دکمه مداد بزنید", "tip_sidebar_touch": "تقویم، مخاطبین و تنظیمات در نوار پایین هستند", diff --git a/locales/fr/common.json b/locales/fr/common.json index 920bc103e..1a7bcd83c 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5192,7 +5192,7 @@ "title": "Bienvenue dans votre boite mail", "tip_compose": "Appuyez sur c pour composer un nouvel email", "tip_shortcuts": "Appuyez sur ? pour voir tous les raccourcis clavier", - "tip_sidebar": "Retrouvez le Calendrier, les Contacts et les Parametres dans le menu lateral", + "tip_sidebar": "Calendrier, Contacts et Paramètres sont dans la barre à gauche", "tip_settings": "Personnalisez votre experience dans les Parametres", "tip_compose_touch": "Touchez le bouton crayon pour écrire un nouvel e-mail", "tip_sidebar_touch": "Agenda, Contacts et Paramètres se trouvent dans la barre du bas", diff --git a/locales/he/common.json b/locales/he/common.json index 2ddbebc18..3d8c5e641 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5085,7 +5085,7 @@ "title": "ברוך הבא לתיבת הדואר שלך", "tip_compose": "הקש c כדי לכתוב דוא\"ל חדש", "tip_shortcuts": "לחץ על? כדי לראות את כל קיצורי המקלדת", - "tip_sidebar": "מצא את לוח השנה, אנשי הקשר וההגדרות בתפריט סרגל הצד", + "tip_sidebar": "לוח השנה, אנשי הקשר וההגדרות נמצאים בסרגל שבצד ימין", "tip_settings": "התאם אישית את החוויה שלך בהגדרות", "tip_compose_touch": "הקישו על כפתור העיפרון כדי לכתוב הודעה חדשה", "tip_sidebar_touch": "יומן, אנשי קשר והגדרות נמצאים בסרגל התחתון", diff --git a/locales/hu/common.json b/locales/hu/common.json index 40440385b..30ef45c04 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5367,7 +5367,7 @@ "title": "Üdvözlünk a postafiókodban", "tip_compose": "Nyomd meg a c billentyűt új e-mail írásához", "tip_shortcuts": "Nyomd meg a ? billentyűt az összes billentyűparancs megtekintéséhez", - "tip_sidebar": "Találd meg a Naptár, Névjegyek és Beállítások menüpontokat az oldalsávban", + "tip_sidebar": "A Naptár, a Névjegyek és a Beállítások a bal oldali sávban vannak", "tip_settings": "Testreszabhatod a felhasználói élményt a Beállításokban", "tip_compose_touch": "Új e-mail írásához koppints a ceruza gombra", "tip_sidebar_touch": "A Naptár, a Névjegyek és a Beállítások az alsó sávban vannak", diff --git a/locales/it/common.json b/locales/it/common.json index 4c928e274..79f844ec6 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5192,7 +5192,7 @@ "title": "Benvenuto nella tua casella di posta", "tip_compose": "Premi c per scrivere una nuova email", "tip_shortcuts": "Premi ? per vedere tutte le scorciatoie da tastiera", - "tip_sidebar": "Trova Calendario, Contatti e Impostazioni nel menu laterale", + "tip_sidebar": "Calendario, Contatti e Impostazioni sono nella barra a sinistra", "tip_settings": "Personalizza la tua esperienza nelle Impostazioni", "tip_compose_touch": "Tocca il pulsante a matita per scrivere una nuova email", "tip_sidebar_touch": "Calendario, Contatti e Impostazioni sono nella barra in basso", diff --git a/locales/ja/common.json b/locales/ja/common.json index a4788a73e..b00271414 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5192,7 +5192,7 @@ "title": "メールボックスへようこそ", "tip_compose": "cキーで新しいメールを作成", "tip_shortcuts": "?キーですべてのショートカットを表示", - "tip_sidebar": "サイドバーからカレンダー、連絡先、設定にアクセス", + "tip_sidebar": "カレンダー、連絡先、設定は左側のバーにあります", "tip_settings": "設定でカスタマイズできます", "tip_compose_touch": "鉛筆ボタンをタップして新しいメールを作成", "tip_sidebar_touch": "カレンダー、連絡先、設定は下部のバーにあります", diff --git a/locales/ko/common.json b/locales/ko/common.json index 8692e53c6..9d13a1470 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5192,7 +5192,7 @@ "title": "웹메일에 오신 것을 환영해요!", "tip_compose": "c 키를 누르면 바로 새 메일을 쓸 수 있어요", "tip_shortcuts": "? 키를 누르면 모든 단축키를 확인할 수 있어요", - "tip_sidebar": "캘린더, 연락처, 설정은 사이드바 메뉴에 있어요", + "tip_sidebar": "캘린더, 연락처, 설정은 왼쪽 막대에 있습니다", "tip_settings": "설정에서 나만의 스타일로 꾸며보세요", "tip_compose_touch": "연필 버튼을 눌러 새 메일을 작성하세요", "tip_sidebar_touch": "캘린더, 연락처, 설정은 하단 막대에 있습니다", diff --git a/locales/lv/common.json b/locales/lv/common.json index 30ae51e41..5f3a03ae9 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5192,7 +5192,7 @@ "title": "Laipni lūdzam jūsu pastkastē", "tip_compose": "Nospiediet c, lai rakstītu jaunu vēstuli", "tip_shortcuts": "Nospiediet ?, lai skatītu visus īsinājumtaustiņus", - "tip_sidebar": "Kalendāru, Kontaktus un Iestatījumus atradīsiet sānu joslā", + "tip_sidebar": "Kalendārs, Kontakti un Iestatījumi ir joslā kreisajā pusē", "tip_settings": "Pielāgojiet saskarni sadaļā «Iestatījumi»", "tip_compose_touch": "Pieskarieties zīmuļa pogai, lai rakstītu jaunu e-pastu", "tip_sidebar_touch": "Kalendārs, Kontakti un Iestatījumi ir apakšējā joslā", diff --git a/locales/mn/common.json b/locales/mn/common.json index 677689faa..142f77127 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5367,7 +5367,7 @@ "title": "Таны шуудангийн хайрцагт тавтай морил", "tip_compose": "Шинэ имэйл бичихийн тулд c товчийг дарна уу", "tip_shortcuts": "Дарах уу? бүх гарын товчлолыг харахын тулд", - "tip_sidebar": "Хажуугийн цэснээс Хуанли, Харилцагчид болон Тохиргоог олоорой", + "tip_sidebar": "Хуанли, Харилцагчид, Тохиргоо зүүн талын самбарт байна", "tip_settings": "Тохиргооноос өөрийн туршлагаа тохируулна уу", "tip_compose_touch": "Шинэ имэйл бичихийн тулд харандаа товчийг дарна уу", "tip_sidebar_touch": "Хуанли, Харилцагчид болон Тохиргоо доод самбарт байна", diff --git a/locales/nb/common.json b/locales/nb/common.json index bc849a939..016aba1c6 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5367,7 +5367,7 @@ "title": "Velkommen til postkassen din", "tip_compose": "Trykk c for å skrive en ny e-post", "tip_shortcuts": "Trykk på ? for å se alle tastatursnarveier", - "tip_sidebar": "Finn kalender, kontakter og innstillinger i menyen i sidefeltet", + "tip_sidebar": "Kalender, Kontakter og Innstillinger finner du i feltet til venstre", "tip_settings": "Tilpass nettposten under Innstillinger", "tip_compose_touch": "Trykk på blyantknappen for å skrive en ny e-post", "tip_sidebar_touch": "Kalender, Kontakter og Innstillinger finner du i linjen nederst", diff --git a/locales/nl/common.json b/locales/nl/common.json index 40c5b7aba..0b3cf1bf4 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5192,7 +5192,7 @@ "title": "Welkom bij uw mailbox", "tip_compose": "Druk op c om een nieuwe e-mail te schrijven", "tip_shortcuts": "Druk op ? om alle sneltoetsen te bekijken", - "tip_sidebar": "Vind Agenda, Contacten en Instellingen in het zijmenu", + "tip_sidebar": "Agenda, Contacten en Instellingen staan in de balk links", "tip_settings": "Pas uw ervaring aan in Instellingen", "tip_compose_touch": "Tik op de potloodknop om een nieuwe e-mail te schrijven", "tip_sidebar_touch": "Agenda, Contacten en Instellingen vind je in de balk onderaan", diff --git a/locales/pl/common.json b/locales/pl/common.json index 977c2f348..9560efc81 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5192,7 +5192,7 @@ "title": "Witaj w swojej skrzynce pocztowej", "tip_compose": "Naciśnij c, aby napisać nową wiadomość e-mail", "tip_shortcuts": "Naciśnij ?, aby zobaczyć wszystkie skróty klawiszowe", - "tip_sidebar": "Kalendarz, Kontakty i Ustawienia znajdziesz w menu bocznym", + "tip_sidebar": "Kalendarz, Kontakty i Ustawienia są na pasku po lewej", "tip_settings": "Dostosuj działanie aplikacji w Ustawieniach", "tip_compose_touch": "Stuknij przycisk z ołówkiem, aby napisać nowy e-mail", "tip_sidebar_touch": "Kalendarz, Kontakty i Ustawienia znajdziesz na dolnym pasku", diff --git a/locales/pt/common.json b/locales/pt/common.json index 4b3b27d99..2a403209a 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5192,7 +5192,7 @@ "title": "Bem-vindo à sua caixa de entrada", "tip_compose": "Pressione c para escrever um novo e-mail", "tip_shortcuts": "Pressione ? para ver todos os atalhos de teclado", - "tip_sidebar": "Encontre Calendário, Contatos e Configurações no menu lateral", + "tip_sidebar": "Calendário, Contactos e Definições estão na barra à esquerda", "tip_settings": "Personalize sua experiência nas Configurações", "tip_compose_touch": "Toque no botão de lápis para escrever um novo e-mail", "tip_sidebar_touch": "Calendário, Contatos e Configurações ficam na barra inferior", diff --git a/locales/ro/common.json b/locales/ro/common.json index 60c77c966..631e8ccbc 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5367,7 +5367,7 @@ "title": "Bine ați venit în căsuța dvs. poștală", "tip_compose": "Apăsați tasta c pentru a redacta un e-mail nou", "tip_shortcuts": "Apăsați tasta „?” pentru a vedea toate comenzile rapide de la tastatură", - "tip_sidebar": "Găsiți Calendar, Contacte și Setări în meniul din bara laterală", + "tip_sidebar": "Calendarul, Contactele și Setările sunt în bara din stânga", "tip_settings": "Personalizați-vă experiența în Setări", "tip_compose_touch": "Atinge butonul cu creion pentru a scrie un e-mail nou", "tip_sidebar_touch": "Calendarul, Contactele și Setările sunt în bara de jos", diff --git a/locales/ru/common.json b/locales/ru/common.json index 3a0c9fcec..a79f8acbc 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5192,7 +5192,7 @@ "title": "Добро пожаловать в ваш почтовый ящик", "tip_compose": "Нажмите c для создания нового письма", "tip_shortcuts": "Нажмите ? для просмотра всех сочетаний клавиш", - "tip_sidebar": "Найдите Календарь, Контакты и Настройки в боковом меню", + "tip_sidebar": "Календарь, контакты и настройки находятся на панели слева", "tip_settings": "Настройте интерфейс в разделе «Настройки»", "tip_compose_touch": "Нажмите кнопку с карандашом, чтобы написать новое письмо", "tip_sidebar_touch": "Календарь, Контакты и Настройки находятся на нижней панели", diff --git a/locales/sk/common.json b/locales/sk/common.json index 502164d6d..a6fd7b8f6 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5367,7 +5367,7 @@ "title": "Vitajte vo svojej poštovej schránke", "tip_compose": "Stlačte c na napísanie nového e-mailu", "tip_shortcuts": "Stlačte ? na zobrazenie všetkých klávesových skratiek", - "tip_sidebar": "Kalendár, Kontakty a Nastavenia nájdete v postrannom paneli", + "tip_sidebar": "Kalendár, Kontakty a Nastavenia nájdete na lište vľavo", "tip_settings": "Prispôsobte si aplikáciu v Nastaveniach", "tip_compose_touch": "Ťuknutím na tlačidlo s ceruzkou napíšete nový e-mail", "tip_sidebar_touch": "Kalendár, Kontakty a Nastavenia nájdete na lište dole", diff --git a/locales/th/common.json b/locales/th/common.json index 852be7f21..ed869e981 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2150,7 +2150,7 @@ "title": "ยินดีต้อนรับสู่กล่องจดหมายของคุณ", "tip_compose": "กด c เพื่อเขียนอีเมลใหม่", "tip_shortcuts": "กด ? เพื่อดูแป้นพิมพ์ลัดทั้งหมด", - "tip_sidebar": "เข้าถึง ปฏิทิน ผู้ติดต่อ และการตั้งค่า ได้จากเมนูแถบด้านข้าง", + "tip_sidebar": "ปฏิทิน รายชื่อติดต่อ และการตั้งค่าอยู่ในแถบด้านซ้าย", "tip_settings": "ปรับแต่งประสบการณ์การใช้งานของคุณได้ที่ การตั้งค่า", "tip_compose_touch": "แตะไอคอนดินสอเพื่อเขียนอีเมลใหม่", "tip_sidebar_touch": "เข้าถึง ปฏิทิน ผู้ติดต่อ และการตั้งค่า ได้จากแถบเมนูด้านล่าง", diff --git a/locales/tr/common.json b/locales/tr/common.json index 8e246b38f..ec3914836 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5367,7 +5367,7 @@ "title": "Posta kutunuza hoş geldiniz", "tip_compose": "Yeni e-posta oluşturmak için c tuşuna basın", "tip_shortcuts": "Tüm klavye kısayollarını görmek için ? tuşuna basın", - "tip_sidebar": "Takvim, Kişiler ve Ayarlar'ı kenar çubuğu menüsünde bulabilirsiniz", + "tip_sidebar": "Takvim, Kişiler ve Ayarlar soldaki çubukta", "tip_settings": "Deneyiminizi Ayarlar'dan özelleştirin", "tip_compose_touch": "Yeni bir e-posta yazmak için kalem düğmesine dokunun", "tip_sidebar_touch": "Takvim, Kişiler ve Ayarlar alttaki çubukta", diff --git a/locales/uk/common.json b/locales/uk/common.json index 52bf3725a..bafc70bf7 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5192,7 +5192,7 @@ "title": "Ласкаво просимо до вашої поштової скриньки", "tip_compose": "Натисніть c, щоб створити новий електронний лист", "tip_shortcuts": "Натисніть ? щоб побачити всі комбінації клавіш", - "tip_sidebar": "Знайдіть Календар, Контакти та Налаштування в меню бічної панелі", + "tip_sidebar": "Календар, контакти й налаштування на панелі ліворуч", "tip_settings": "Налаштуйте свій досвід у налаштуваннях", "tip_compose_touch": "Торкніться кнопки з олівцем, щоб написати новий лист", "tip_sidebar_touch": "Календар, Контакти й Налаштування є на нижній панелі", diff --git a/locales/vi/common.json b/locales/vi/common.json index a02cf42d1..0b06abad1 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5379,7 +5379,7 @@ "title": "Chào mừng đến với hộp thư của bạn", "tip_compose": "Nhấn c để soạn email mới", "tip_shortcuts": "Nhấn ? để xem tất cả phím tắt", - "tip_sidebar": "Tìm Lịch, Danh bạ và Cài đặt trong menu thanh bên", + "tip_sidebar": "Lịch, Danh bạ và Cài đặt nằm ở thanh bên trái", "tip_settings": "Tùy chỉnh trải nghiệm của bạn trong Cài đặt", "tip_compose_touch": "Nhấn vào nút bút chì để viết email mới", "tip_sidebar_touch": "Tìm Lịch, Danh bạ và Cài đặt ở thanh phía dưới", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index afe32e3ed..ef228213e 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5367,7 +5367,7 @@ "title": "歡迎使用您的信件匣", "tip_compose": "按 c 撰寫新郵件", "tip_shortcuts": "按 ? 檢視所有鍵盤快速鍵", - "tip_sidebar": "可在側邊欄選單找到行事曆、聯絡人與設定", + "tip_sidebar": "行事曆、聯絡人和設定位於左側欄", "tip_settings": "在「設定」中自訂您的使用體驗", "tip_compose_touch": "點按鉛筆按鈕撰寫新郵件", "tip_sidebar_touch": "行事曆、聯絡人和設定位於底部列", diff --git a/locales/zh/common.json b/locales/zh/common.json index a5fd459b2..ad525ff3b 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5192,7 +5192,7 @@ "title": "欢迎使用邮箱", "tip_compose": "按 c 撰写新邮件", "tip_shortcuts": "按?查看所有键盘快捷键", - "tip_sidebar": "在侧边栏菜单中查找日历、联系人和设置", + "tip_sidebar": "日历、联系人和设置在左侧栏中", "tip_settings": "在\"设置\"中自定义您的体验", "tip_compose_touch": "点按铅笔按钮写新邮件", "tip_sidebar_touch": "日历、联系人和设置位于底部栏", From d59a896558d3b5b82b2de341482a3d44c505acfd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:09:29 +0100 Subject: [PATCH 04/50] fix: show the same Reply all and Delete keys in tooltips, menus and help The desktop shortcut test expected the help's old "R / a" for Reply all; it now lists "Shift + R", the key the menus show. --- .../__tests__/shortcut-hints-agree.test.tsx | 63 +++++++++++++++++++ components/email/email-viewer.tsx | 18 ++++-- lib/__tests__/desktop-shortcut-keys.test.ts | 2 +- lib/keyboard-shortcuts/mail.ts | 7 ++- 4 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 components/email/__tests__/shortcut-hints-agree.test.tsx diff --git a/components/email/__tests__/shortcut-hints-agree.test.tsx b/components/email/__tests__/shortcut-hints-agree.test.tsx new file mode 100644 index 000000000..04107fb83 --- /dev/null +++ b/components/email/__tests__/shortcut-hints-agree.test.tsx @@ -0,0 +1,63 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { MAIL_SHORTCUTS } from '@/lib/keyboard-shortcuts/mail'; +import { messageActionShortcut } from '../message-action-shortcuts'; + +// mail-p2-13: Reply all read "Reply all (a)" in the toolbar, "Shift + R" in +// the menus and "R" in the help; Delete "# or Del" against "Del". The three +// places now name the same key. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; +const email = { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [], subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', + preview: '', hasAttachment: false, size: 1, textBody: [], htmlBody: [], attachments: [], bodyValues: {}, +} as unknown as Email; + +const helpKey = (description: string) => + MAIL_SHORTCUTS.actions.find((entry) => entry.description === description)!.key; + +describe('shortcut hints', () => { + it('give Reply all and Delete the same key in the help as in the menus', () => { + expect(helpKey('shortcuts.actions.reply_all')).toBe(messageActionShortcut('reply_all', { singleKeys: true })); + expect(helpKey('shortcuts.actions.delete')).toBe(messageActionShortcut('delete', { singleKeys: true })); + }); + + it('name the menus\' keys in the toolbar tooltips', () => { + const { container } = render( + , + ); + const titles = Array.from(container.querySelectorAll('button[title]')).map((b) => b.title); + expect(titles).toContain('reply_all (Shift + R)'); + expect(titles).toContain('delete (Del)'); + expect(titles).toContain('reply (R)'); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 6c2ae86a0..6596d928c 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -38,6 +38,7 @@ import { useContextMenu, type UseContextMenuReturn } from "@/hooks/use-context-m import { EmailContextMenu } from "./email-context-menu"; import { rememberedTrashOrigin } from "@/lib/trash-origin"; import { useMessageActionList, type MessageActionEntry, type MessageActionKey, type MessageActionOptions, type MessageSubmenuKey } from "./message-actions"; +import { useMessageActionShortcut } from "./message-action-shortcuts"; import { PhishingMarkBanner } from "./phishing-mark-banner"; import { turnOffLinks } from "@/lib/phishing-links"; import { usePhishingMarked } from "@/hooks/use-phishing-report"; @@ -1592,6 +1593,13 @@ export function EmailViewer({ const simpleMode = useSettingsStore((state) => state.simpleMode); // Simple mode always names the toolbar's buttons in words. const showToolbarLabels = (showToolbarLabelsSetting || simpleMode) && interfaceLayout !== 'mountain-view'; + // Tooltips name the same key the message menus and the shortcut help do + // (mail-p2-13), and none when single keys are off. + const messageActionShortcut = useMessageActionShortcut(); + const withShortcut = (label: string, action: MessageActionKey) => { + const key = messageActionShortcut(action); + return key ? `${label} (${key})` : label; + }; // Inside a Pro pane, `isMobile` above is pane-width based: overlays that // would go viewport-fixed must instead cover just the pane (via // PaneOverlay + absolute positioning), and viewport CSS breakpoints like @@ -4480,7 +4488,7 @@ ${printWatermark ? printWatermark.html : ''} data-overflow-item data-overflow-priority="1" className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0" - title={t('tooltips.reply')} + title={withShortcut(t('reply'), 'reply')} > {showToolbarLabels && {t('reply')}} @@ -4492,7 +4500,7 @@ ${printWatermark ? printWatermark.html : ''} data-overflow-item data-overflow-priority="2" className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0 sm:px-3" - title={t('tooltips.reply_all')} + title={withShortcut(t('reply_all'), 'reply_all')} > {showToolbarLabels && {t('reply_all')}} @@ -4504,7 +4512,7 @@ ${printWatermark ? printWatermark.html : ''} data-overflow-item data-overflow-priority="3" className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0" - title={t('tooltips.forward')} + title={withShortcut(t('forward'), 'forward')} > {showToolbarLabels && {t('forward')}} @@ -4568,7 +4576,7 @@ ${printWatermark ? printWatermark.html : ''} data-overflow-item data-overflow-priority="4" className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-1")} - title={t('tooltips.archive')} + title={withShortcut(t('archive'), 'archive')} > {showToolbarLabels && {t('archive')}} @@ -4580,7 +4588,7 @@ ${printWatermark ? printWatermark.html : ''} size="sm" onClick={onDelete} className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-3")} - title={deleteIsPermanent ? tActions('delete_forever') : t('tooltips.delete')} + title={deleteIsPermanent ? tActions('delete_forever') : withShortcut(t('delete'), 'delete')} data-testid="reader-delete" > diff --git a/lib/__tests__/desktop-shortcut-keys.test.ts b/lib/__tests__/desktop-shortcut-keys.test.ts index ffe0fd0be..0c1a296ee 100644 --- a/lib/__tests__/desktop-shortcut-keys.test.ts +++ b/lib/__tests__/desktop-shortcut-keys.test.ts @@ -13,7 +13,7 @@ const row = (description: string): ShortcutEntry => { describe('the shortcut help in the desktop app', () => { it('lists the Outlook keys its menus run beside the single keys', () => { expect(shortcutKeyFor(row('shortcuts.actions.reply'), 'windows')).toBe('Mod + R / r'); - expect(shortcutKeyFor(row('shortcuts.actions.reply_all'), 'linux')).toBe('Mod + Shift + R / R / a'); + expect(shortcutKeyFor(row('shortcuts.actions.reply_all'), 'linux')).toBe('Mod + Shift + R / Shift + R'); expect(shortcutKeyFor(row('shortcuts.actions.forward'), 'mac')).toBe('Mod + Shift + F / f'); expect(shortcutKeyFor(row('shortcuts.global.search'), 'windows')).toBe('Ctrl + E / /'); expect(shortcutKeyFor(row('shortcuts.global.refresh'), 'windows')).toBe('F9 / F5 / Shift + G'); diff --git a/lib/keyboard-shortcuts/mail.ts b/lib/keyboard-shortcuts/mail.ts index c5a950309..5f805fdf6 100644 --- a/lib/keyboard-shortcuts/mail.ts +++ b/lib/keyboard-shortcuts/mail.ts @@ -10,13 +10,16 @@ export const MAIL_SHORTCUTS = { { key: "n", description: "shortcuts.navigation.next_unread" }, { key: "g → i / s / t / d / a", description: "shortcuts.navigation.go_to_folder", layout: "mountain-view" }, ], + // The same keys the toolbar tooltips and message menus show + // (components/email/message-action-shortcuts.ts). `a` and `#` still work, + // unadvertised (mail-p2-13). actions: [ { key: "r", description: "shortcuts.actions.reply", desktopKey: "Mod + R" }, - { key: "R / a", description: "shortcuts.actions.reply_all", desktopKey: "Mod + Shift + R" }, + { key: "Shift + R", description: "shortcuts.actions.reply_all", desktopKey: "Mod + Shift + R" }, { key: "f", description: "shortcuts.actions.forward", desktopKey: "Mod + Shift + F" }, { key: "s", description: "shortcuts.actions.star" }, { key: "e", description: "shortcuts.actions.archive", feature: "archiveActionEnabled" }, - { key: "# / Del", description: "shortcuts.actions.delete" }, + { key: "Del", description: "shortcuts.actions.delete" }, { key: "u", description: "shortcuts.actions.mark_unread", desktopPcKey: "Ctrl + U" }, { key: "Shift + I", description: "shortcuts.actions.mark_read", desktopWindowsKey: "Ctrl + Q" }, { key: "!", description: "shortcuts.actions.toggle_spam" }, From a834960d08387ef82b76655997366022bdfc837b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:10:27 +0100 Subject: [PATCH 05/50] fix: count conversations above grouped search results while more can load The test for "10+ results" now runs with conversations off, the only case that still says results. --- components/email/__tests__/search-result-count.test.tsx | 8 +++++++- components/email/search-result-count.tsx | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/components/email/__tests__/search-result-count.test.tsx b/components/email/__tests__/search-result-count.test.tsx index 79e2feb57..477e01c52 100644 --- a/components/email/__tests__/search-result-count.test.tsx +++ b/components/email/__tests__/search-result-count.test.tsx @@ -47,7 +47,13 @@ describe('SearchResultCount', () => { expect(container.textContent).toBe('advanced_search.results_found {"count":10}'); }); - it('keeps "10+ results" while more can load', () => { + it('counts conversations while more can load and the list groups them', () => { + const { container } = render(); + expect(container.textContent).toBe('email_list.conversations_count_plus {"count":3}'); + }); + + it('keeps "10+ results" while more can load and conversations are off', () => { + useSettingsStore.setState({ disableThreading: true }); const { container } = render(); expect(container.textContent).toBe('advanced_search.results_found_more {"count":10}'); }); diff --git a/components/email/search-result-count.tsx b/components/email/search-result-count.tsx index cd83d38ef..5ccb21e7c 100644 --- a/components/email/search-result-count.tsx +++ b/components/email/search-result-count.tsx @@ -18,6 +18,9 @@ export function SearchResultCount({ emails, hasMore }: { emails: Email[]; hasMor () => (disableThreading ? emails.length : groupEmailsByThread(emails).length), [emails, disableThreading], ); + // More to load: the rows are conversations when the list groups them, so + // "100+ results" above them reads wrong as well (mail-16). + if (hasMore && !disableThreading) return <>{t("email_list.conversations_count_plus", { count: conversations })}; if (hasMore) return <>{t("advanced_search.results_found_more", { count: emails.length })}; if (conversations === emails.length) return <>{t("advanced_search.results_found", { count: emails.length })}; return <>{t("mail_list_extra.search_count", { conversations, messages: emails.length })}; From 814945a445382962748827320f9a23a2b3e13c04 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:19:45 +0100 Subject: [PATCH 06/50] fix: say Restore and Delete forever in Trash and stop offering Report spam there The reader toolbar tests now find the quick reply box by either placeholder: since the box names its recipients ("quick_reply_to"), the old placeholder assertion failed and the Spam test passed vacuously. --- .../__tests__/email-context-menu-row.test.tsx | 19 +++++++++++++++++++ .../reader-spam-trash-actions.test.tsx | 18 ++++++++++++++++-- components/email/email-context-menu.tsx | 11 +++++++---- components/email/email-list.tsx | 3 ++- components/email/email-viewer.tsx | 12 ++++++------ components/email/message-actions.ts | 13 +++++++++---- components/email/mv-list-toolbar.tsx | 3 ++- components/email/thread-list-item.tsx | 5 +++-- lib/__tests__/spam-applicable.test.ts | 12 ++++++++++++ lib/spam-applicable.ts | 8 ++++++++ 10 files changed, 84 insertions(+), 20 deletions(-) create mode 100644 lib/__tests__/spam-applicable.test.ts create mode 100644 lib/spam-applicable.ts diff --git a/components/email/__tests__/email-context-menu-row.test.tsx b/components/email/__tests__/email-context-menu-row.test.tsx index 87eb9c323..f6aaa871c 100644 --- a/components/email/__tests__/email-context-menu-row.test.tsx +++ b/components/email/__tests__/email-context-menu-row.test.tsx @@ -125,6 +125,25 @@ describe('Restore in Trash', () => { expect(onMoveToMailbox).toHaveBeenCalledWith('inbox'); }); + // Pass 2, mail-9: Trash's Delete removes for good, so it says so, and + // reporting spam from the bin makes no sense. + it('says Delete forever and offers no Report spam', () => { + renderMenu({ email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash' }); + expect(screen.getByTestId('ctx-delete').textContent).toContain('delete_forever'); + expect(screen.queryByTestId('ctx-spam')).toBeNull(); + fireEvent.click(screen.getByTestId('ctx-more')); + expect(screen.queryByTestId('ctx-spam')).toBeNull(); + }); + + it('says Delete forever for a selection in Trash too', () => { + renderMenu({ + email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash', isMultiSelect: true, selectedCount: 2, + batchEmails: [trashed, { ...trashed, id: 'e2' } as Email], onBatchDelete: vi.fn(), onBatchMarkAsSpam: vi.fn(), + }); + expect(screen.getByTestId('ctx-delete').textContent).toContain('delete_forever'); + expect(screen.queryByTestId('ctx-spam')).toBeNull(); + }); + it('is in the selection menu too', () => { rememberTrashOrigins([{ id: 'e1', mailboxIds: { work: true } }, { id: 'e2', mailboxIds: { work: true } }], mailboxes); const onBatchMoveToMailbox = vi.fn(); diff --git a/components/email/__tests__/reader-spam-trash-actions.test.tsx b/components/email/__tests__/reader-spam-trash-actions.test.tsx index 815449deb..20b19cf15 100644 --- a/components/email/__tests__/reader-spam-trash-actions.test.tsx +++ b/components/email/__tests__/reader-spam-trash-actions.test.tsx @@ -74,7 +74,7 @@ describe('reader toolbar in Spam', () => { it('has no quick reply', async () => { await viewer('junk'); - expect(screen.queryByPlaceholderText('quick_reply_placeholder')).toBeNull(); + expect(screen.queryByPlaceholderText(/^quick_reply/)).toBeNull(); }); }); @@ -86,6 +86,20 @@ describe('reader toolbar in Trash', () => { expect(h.onMoveToMailbox).toHaveBeenCalledWith('inbox'); }); + // Pass 2, mail-9: the button said "Move back to Inbox" while the row + // menu and ⋮ said "Restore", for the same action. + it('names Restore as the menus do', async () => { + await viewer('trash'); + const button = screen.getByTestId('reader-restore'); + expect(button.getAttribute('title')).toBe('restore'); + expect(button.getAttribute('aria-label')).toBe('restore'); + }); + + it('offers no Report spam', async () => { + await viewer('trash'); + expect(document.querySelector('button[title="spam.button_title"]')).toBeNull(); + }); + it('says Delete forever', async () => { await viewer('trash'); expect(screen.getByTestId('reader-delete').textContent).toContain('delete_forever'); @@ -99,7 +113,7 @@ describe('reader toolbar in the Inbox', () => { expect(screen.queryByTestId('reader-restore')).toBeNull(); expect(screen.queryByTestId('reader-not-spam')).toBeNull(); expect(screen.getByTestId('reader-delete').textContent).not.toContain('delete_forever'); - expect(screen.getByPlaceholderText('quick_reply_placeholder')).toBeTruthy(); + expect(screen.getByPlaceholderText(/^quick_reply/)).toBeTruthy(); }); }); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 3ee1bd823..0d0d2eb0b 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -2,8 +2,9 @@ import { useMemo } from "react"; import { useLocale, useTranslations } from "next-intl"; +import { spamApplicableIn } from "@/lib/spam-applicable"; import { useSettingsStore } from "@/stores/settings-store"; -import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store"; +import { useEmailStore, removalFolder, canRemoveFromFolder, bulkDeleteIsPermanent } from "@/stores/email-store"; import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags"; import { Email, Mailbox } from "@/lib/jmap/types"; import { @@ -248,8 +249,10 @@ export function EmailContextMenu({ const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; // Marking your own outgoing mail as spam makes no sense - hide the action - // in Sent, Drafts and Scheduled. - const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || ''); + // in Sent, Drafts and Scheduled, and in Trash, where it is already thrown out. + const spamApplicable = spamApplicableIn(currentMailboxRole); + const deleteForever = bulkDeleteIsPermanent({ role: currentMailboxRole }); + const tActions = useTranslations("reader_actions"); const isScheduled = email.isScheduled === true; // One report per selected message, behind one confirm (enterprise pack). const batchPhishing = usePhishingReport(showBatchActions ? (batchEmails ?? []) : [], currentMailboxRole); @@ -397,7 +400,7 @@ export function EmailContextMenu({ {/* Delete */} handleAction(onBatchDelete!)} diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 62e8dd6f3..3e741a747 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -1,5 +1,6 @@ "use client"; +import { spamApplicableIn } from "@/lib/spam-applicable"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { MemoizedThreadListItem, listMailboxRole } from "./thread-list-item"; import { ListColumnsHeader } from "./list-columns"; @@ -283,7 +284,7 @@ export function EmailList({ mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); // Spam is pointless on your own outgoing mail, as in the context menu. - const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(effectiveMailboxRole || ''); + const spamApplicable = spamApplicableIn(effectiveMailboxRole); const disableThreading = useSettingsStore((state) => state.disableThreading); // The order the current folder view was fetched in (#718), so thread diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 80ffc1c4c..738ce4ac9 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1,6 +1,7 @@ "use client"; import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react"; +import { spamApplicableIn } from "@/lib/spam-applicable"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; @@ -100,7 +101,7 @@ import { Minus, ShieldCheck, ShieldAlert, - RotateCcw, + Undo, Copy, Brain, Keyboard, @@ -146,7 +147,6 @@ import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useEmailStore, resolveEmailBlobContext, emailWriteSource, bulkDeleteIsPermanent, type EmailBlobContext } from "@/stores/email-store"; import { trashRestoreTarget } from "@/lib/trash-restore-target"; -import { localizeMailboxName } from "@/lib/mailbox-label"; import { useListSelection } from "@/lib/plugin-sandbox/assistant/b.list-selection"; import { readability } from "@/lib/plugin-sandbox/assistant/b.projection"; import { useThemeStore } from "@/stores/theme-store"; @@ -1491,14 +1491,13 @@ export function EmailViewer({ // destroys the mail, Delete says "Delete forever". const isInTrashFolder = currentMailboxRole === 'trash'; const tActions = useTranslations('reader_actions'); - const tSidebarNames = useTranslations('sidebar'); // Read for the re-render: bulkDeleteIsPermanent reads them from the store. useSettingsStore((state) => state.deleteAction); useSettingsStore((state) => state.permanentlyDeleteJunk); const deleteIsPermanent = bulkDeleteIsPermanent({ role: currentMailboxRole }); // Marking your own outgoing mail as spam makes no sense - hide the action // in Sent, Drafts and Scheduled. - const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || ''); + const spamApplicable = spamApplicableIn(currentMailboxRole); // Detect if the email is a draft const isDraft = email?.keywords?.['$draft'] === true; @@ -4548,9 +4547,10 @@ ${printWatermark ? printWatermark.html : ''} data-overflow-action="" data-testid="reader-restore" className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-1")} - title={tActions('restore_to', { folder: localizeMailboxName(restoreTarget.role, restoreTarget.name, (key) => tSidebarNames(`mailboxes.${key}`)) })} + title={tActions('restore')} + aria-label={tActions('restore')} > - + {showToolbarLabels && {tActions('restore')}} )} diff --git a/components/email/message-actions.ts b/components/email/message-actions.ts index 6fdfb03f7..8e707087e 100644 --- a/components/email/message-actions.ts +++ b/components/email/message-actions.ts @@ -2,8 +2,9 @@ import type { ComponentType } from "react"; import { useTranslations } from "next-intl"; +import { spamApplicableIn } from "@/lib/spam-applicable"; import { useSettingsStore } from "@/stores/settings-store"; -import { emailLinkSource } from "@/stores/email-store"; +import { emailLinkSource, bulkDeleteIsPermanent } from "@/stores/email-store"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; import { buildMailPath } from "@/lib/deep-links"; import { useCopyLink } from "@/hooks/use-copy-link"; @@ -161,6 +162,7 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr const tPhishing = useTranslations("phishing_report"); const tWindows = useTranslations("pwaWindows"); const tRecover = useTranslations("recover_deleted"); + const tActions = useTranslations("reader_actions"); const copyLink = useCopyLink(); const archiveEnabled = useArchiveActionEnabled(); const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); @@ -175,8 +177,11 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr const isDraft = email.keywords?.["$draft"] === true; const isScheduled = email.isScheduled === true; const inJunk = o.currentMailboxRole === "junk"; - // Marking your own outgoing mail as spam makes no sense. - const spamApplicable = !["sent", "drafts", "scheduled"].includes(o.currentMailboxRole || ""); + // Marking your own outgoing mail as spam makes no sense, nor reporting + // what already sits in the bin. + const spamApplicable = spamApplicableIn(o.currentMailboxRole); + // Deleting from Trash (or with deletes set to be permanent) can't be undone. + const deleteForever = bulkDeleteIsPermanent({ role: o.currentMailboxRole }); const item = ( key: MessageActionKey, @@ -243,7 +248,7 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr ? [item("restore", Undo, tRecover("restore"), o.onRestore, { testId: "ctx-restore" }), SEPARATOR] : []; const archive = archiveEnabled ? [item("archive", Archive, t("archive"), o.onArchive)] : []; - const del = item("delete", Trash2, t("delete"), o.onDelete, { destructive: !mountainView, testId: "ctx-delete" }); + const del = item("delete", Trash2, deleteForever ? tActions("delete_forever") : t("delete"), o.onDelete, { destructive: !mountainView, testId: "ctx-delete" }); const read = item("read", isUnread ? MailOpen : Mail, isUnread ? t("mark_read") : t("mark_unread"), () => o.onMarkAsRead?.(isUnread), { disabled: false, testId: isUnread ? "ctx-mark-read" : "ctx-mark-unread", diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index ae56cb2fc..00692388b 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -18,6 +18,7 @@ import { RefreshCw, Fish, } from "@/components/icons"; +import { spamApplicableIn } from "@/lib/spam-applicable"; import { usePhishingReport } from "@/hooks/use-phishing-report"; import { useTranslations } from "next-intl"; import { useAuthStore } from "@/stores/auth-store"; @@ -200,7 +201,7 @@ export function MvListToolbar({ const isInJunk = currentRole === "junk"; const isInTrash = currentRole === "trash"; // Marking your own drafts or sent mail as spam is meaningless. - const spamApplicable = !["sent", "drafts", "scheduled"].includes(currentRole ?? ""); + const spamApplicable = spamApplicableIn(currentRole); // Report phishing for the selection, one report per message (enterprise pack). const selectionForPhishing = useEmailStore(useShallow((state) => resolveSelectedEmails(state))); const phishing = usePhishingReport(selectionForPhishing, currentRole); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index a65986286..2c04cbfed 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useCallback } from "react"; +import { spamApplicableIn } from "@/lib/spam-applicable"; import { cleanPreview, formatDate } from "@/lib/utils"; import { rowA11yProps, rowLabel } from "./list-a11y"; import { emailListDate } from "@/lib/email-date"; @@ -1031,7 +1032,7 @@ const SingleEmailItem = React.forwardRef( onMarkAsSpam={onMarkAsSpam} onUndoSpam={onUndoSpam} isInJunk={currentMailboxRole === 'junk'} - spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} + spamApplicable={spamApplicableIn(currentMailboxRole)} mailboxRole={currentMailboxRole ?? null} pinToFirstLine={mvRow} /> @@ -1823,7 +1824,7 @@ export const ThreadListItem = React.forwardRef onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} isInJunk={currentMailboxRole === 'junk'} - spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} + spamApplicable={spamApplicableIn(currentMailboxRole)} mailboxRole={currentMailboxRole ?? null} pinToFirstLine={mvRow} /> diff --git a/lib/__tests__/spam-applicable.test.ts b/lib/__tests__/spam-applicable.test.ts new file mode 100644 index 000000000..0f39c0b1f --- /dev/null +++ b/lib/__tests__/spam-applicable.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { spamApplicableIn } from '../spam-applicable'; + +describe('spamApplicableIn', () => { + it('offers Report spam on received mail', () => { + for (const role of ['inbox', 'archive', 'junk', null, undefined]) expect(spamApplicableIn(role)).toBe(true); + }); + + it('not on your own outgoing mail, nor in Trash', () => { + for (const role of ['sent', 'drafts', 'scheduled', 'trash']) expect(spamApplicableIn(role)).toBe(false); + }); +}); diff --git a/lib/spam-applicable.ts b/lib/spam-applicable.ts new file mode 100644 index 000000000..df7cb8e5f --- /dev/null +++ b/lib/spam-applicable.ts @@ -0,0 +1,8 @@ +/** + * Whether "Report spam" makes sense for mail in a folder of this role. Not on + * your own outgoing mail (Sent, Drafts, Scheduled), and not in Trash, where + * the mail is already thrown out. + */ +export function spamApplicableIn(role: string | null | undefined): boolean { + return !["sent", "drafts", "scheduled", "trash"].includes(role || ""); +} From fdd7651e0551a5aa3cb33c2ec241af776260890a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:21:28 +0100 Subject: [PATCH 07/50] fix: show the whole conversation in a message window and answer the reader's keys there --- .../__tests__/popout-message-app.test.tsx | 7 + components/popout/popout-message-app.tsx | 1 + .../pro-email-window-conversation.test.tsx | 161 ++++++++++++++++++ components/pro/pro-email-tab-body.tsx | 75 ++++++-- components/pro/use-view-conversation.ts | 100 +++++++++++ 5 files changed, 327 insertions(+), 17 deletions(-) create mode 100644 components/pro/__tests__/pro-email-window-conversation.test.tsx create mode 100644 components/pro/use-view-conversation.ts diff --git a/components/popout/__tests__/popout-message-app.test.tsx b/components/popout/__tests__/popout-message-app.test.tsx index 82e6a7d69..9c1e8e48f 100644 --- a/components/popout/__tests__/popout-message-app.test.tsx +++ b/components/popout/__tests__/popout-message-app.test.tsx @@ -94,6 +94,13 @@ describe('a message opened from a bulwark:// link', () => { }); describe('PopoutMessageApp', () => { + // pass-2 mail-p2-5: the window showed one message of a conversation. + it('reads the message in its conversation, as the reading pane does', async () => { + render(); + await settle(); + expect((view as unknown as { conversation?: boolean }).conversation).toBe(true); + }); + it('opens the message of the address, through its login and account', async () => { render(); await settle(); diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx index 0c68ce71a..74273e0c7 100644 --- a/components/popout/popout-message-app.tsx +++ b/components/popout/popout-message-app.tsx @@ -195,6 +195,7 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email liveState={liveState} onMailChanged={handleMailChanged} noAutomaticActions={fromLink} + conversation className="min-h-0 w-full flex-1" /> diff --git a/components/pro/__tests__/pro-email-window-conversation.test.tsx b/components/pro/__tests__/pro-email-window-conversation.test.tsx new file mode 100644 index 000000000..0a0805061 --- /dev/null +++ b/components/pro/__tests__/pro-email-window-conversation.test.tsx @@ -0,0 +1,161 @@ +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; + +/** + * A message window (desktop app, installed app) showed only the message it was + * opened with, and r, e, # and ? did nothing there: its shortcuts waited for a + * Pro tab to be in front, and a window has no tabs (pass-2 mail-p2-5). + */ + +const viewerProps = vi.hoisted(() => ({ current: null as Record | null })); + +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: (props: Record) => { + viewerProps.current = props; + return null; + }, +})); + +vi.mock('@/components/error', () => ({ + ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), + EmailViewerErrorFallback: () => null, +})); + +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); + +vi.mock('@/lib/popout/window-role', async (importOriginal) => ({ + ...await importOriginal(), + isPopoutWindow: () => true, +})); +vi.mock('@/components/email/move-to-dialog', () => ({ MoveToDialog: () => null })); +vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } })); +const help = vi.hoisted(() => ({ open: false })); +vi.mock('@/components/keyboard-shortcuts-modal', () => ({ + KeyboardShortcutsModal: ({ isOpen }: { isOpen: boolean }) => { + help.open = isOpen; + return null; + }, +})); + +function selectorStore(state: T) { + const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state); + hook.getState = () => state; + return hook; +} + +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: selectorStore({ client: null, activeAccountId: 'dso', getClientForAccount: () => undefined }), +})); +vi.mock('@/stores/email-store', () => ({ + resolveEmailBlobContext: (_email: unknown, client: unknown) => ({ client }), + useEmailStore: selectorStore({ + fetchEmailContent: async () => null, + deleteEmail: async () => {}, + markAsRead: async () => {}, + toggleStar: async () => {}, + moveToMailbox: async () => {}, + setEmailKeywordsLocal: () => {}, + mailboxes: [], + emails: [], + selectedEmail: null, + }), +})); +vi.mock('@/stores/identity-store', () => ({ + useIdentityStore: selectorStore({ identities: [{ id: 'b', email: 'dso@example.org', name: 'Dso' }] }), +})); +const settings = vi.hoisted(() => ({ state: { singleKeyShortcuts: true, disableThreading: false } })); +vi.mock('@/stores/settings-store', () => ({ useSettingsStore: selectorStore(settings.state) })); +vi.mock('@/stores/pro-tab-store', () => ({ + useProTabStore: selectorStore({ openComposeTab: () => {}, closeTab: () => {}, updateTabTitle: () => {} }), +})); + +import { ProEmailView } from '../pro-email-tab-body'; + +const OLDER = { + id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ email: 'aylin@example.org', name: 'Aylin' }], subject: 'Question about hiring plan', + receivedAt: '2026-10-20T08:00:00Z', preview: 'First', +}; +const NEWER = { + ...OLDER, id: 'm2', from: [{ email: 'robin@example.org', name: 'Robin' }], subject: 'Re: Question about hiring plan', + receivedAt: '2026-10-21T08:00:00Z', preview: 'Second', keywords: {}, +}; +const BODY = { + bodyValues: { '1': { value: 'Hi' } }, + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [{ partId: '1', type: 'text/plain' }], + attachments: [], +}; + +function makeClient() { + return { + getEmail: vi.fn(async (id: string) => (id === 'm1' ? { ...OLDER, ...BODY } : { ...NEWER, ...BODY })), + getThread: vi.fn(async () => ({ id: 't1', emailIds: ['m2', 'm1'] })), + // Not in date order: the window sorts. + getSomeEmails: vi.fn(async () => [NEWER, OLDER]), + markAsRead: vi.fn(async () => {}), + getUsername: () => 'dso@example.org', + getAccountId: () => 'a', + }; +} + +describe('a message window', () => { + beforeEach(() => { + viewerProps.current = null; + help.open = false; + settings.state.disableThreading = false; + }); + + async function open() { + const client = makeClient(); + const onOpenCompose = vi.fn(async (_data: Record) => true); + render(); + await waitFor(() => expect(viewerProps.current?.email).toBeTruthy()); + return { client, onOpenCompose }; + } + + it('shows the whole conversation, oldest first', async () => { + const { client } = await open(); + await waitFor(() => expect((viewerProps.current?.mvConversation as Array<{ id: string }> | undefined)?.map((e) => e.id)).toEqual(['m1', 'm2'])); + expect(viewerProps.current?.conversationPane).toBe(true); + expect(client.getThread).toHaveBeenCalledWith('t1', 'a'); + }); + + it('keeps one message with "Show emails as individual messages"', async () => { + settings.state.disableThreading = true; + const { client } = await open(); + await act(async () => { await new Promise((r) => setTimeout(r, 5)); }); + expect(viewerProps.current?.mvConversation).toBeUndefined(); + expect(client.getThread).not.toHaveBeenCalled(); + }); + + it('replies to the message of the conversation whose button was pressed', async () => { + const { onOpenCompose } = await open(); + await waitFor(() => expect(viewerProps.current?.mvConversation).toBeTruthy()); + await act(async () => { + await (viewerProps.current!.onReplyConversationEmail as (e: unknown) => Promise)(NEWER); + }); + await waitFor(() => expect(onOpenCompose).toHaveBeenCalled()); + expect(onOpenCompose.mock.calls[0][0]).toMatchObject({ mode: 'reply', sourceEmailId: 'm2' }); + }); + + it('marks an unfolded unread message read on the server', async () => { + const { client } = await open(); + await waitFor(() => expect(viewerProps.current?.mvConversation).toBeTruthy()); + await act(async () => { + (viewerProps.current!.onMarkConversationEmailRead as (e: unknown) => void)(NEWER); + await new Promise((r) => setTimeout(r, 5)); + }); + expect(client.markAsRead).toHaveBeenCalledWith('m2', true, 'a'); + }); + + it('answers r and ? with no Pro tab around it', async () => { + const { onOpenCompose } = await open(); + fireEvent.keyDown(document.body, { key: 'r', code: 'KeyR' }); + await waitFor(() => expect(onOpenCompose).toHaveBeenCalledTimes(1)); + expect(onOpenCompose.mock.calls[0][0]).toMatchObject({ mode: 'reply', sourceEmailId: 'm1' }); + fireEvent.keyDown(document.body, { key: '?', code: 'Slash', shiftKey: true }); + await waitFor(() => expect(help.open).toBe(true)); + }); +}); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 5ddae4ffc..72c36b7a1 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -41,10 +41,13 @@ import { isFilePreviewable } from '@/lib/file-preview'; import { useDesktopCommands } from '@/lib/desktop/commands'; import { isPopoutWindow } from '@/lib/popout/window-role'; import { offerMoveUndo } from '@/lib/popout/moved-undo'; +import { useViewConversation } from './use-view-conversation'; import dynamic from 'next/dynamic'; // Only needed when the device policy turns downloads off. const FilePreviewModal = dynamic(() => import('@/components/files/file-preview-modal').then((m) => m.FilePreviewModal), { ssr: false }); +// Only a message window opens the shortcuts help from here. +const KeyboardShortcutsModal = dynamic(() => import('@/components/keyboard-shortcuts-modal').then((m) => m.KeyboardShortcutsModal), { ssr: false }); interface ProEmailTabBodyProps { tabId: string; @@ -86,6 +89,11 @@ interface ProEmailViewProps { onMailChanged?: () => void; /** See EmailViewer. */ noAutomaticActions?: boolean; + /** + * Show the message inside its conversation, as the mail page's reading + * pane does (a message window). "Show emails as individual messages" keeps one. + */ + conversation?: boolean; /** * The message left this view: deleted, archived, moved, or reopened in the * composer. The host decides what that means (close the tab / clear the @@ -152,7 +160,7 @@ function buildReplyContext(email: Email, source: EmailWriteSource): ProReplyCont * Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's * reading pane. */ -export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, onClose, className }: ProEmailViewProps) { +export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, conversation, onClose, className }: ProEmailViewProps) { const t = useTranslations(); const tNotifications = useTranslations('notifications'); const tRemove = useTranslations('email_list.confirm_remove'); @@ -295,17 +303,21 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, return mb?.role; }, [email, mailboxes]); - const handleReply = useCallback((draftText?: string) => { - if (!email) return; + const replyToMessage = useCallback((target: Email, draftText?: string) => { openComposeTab({ mode: 'reply', - replyTo: buildReplyContext(email, writeSource), - sourceEmailId: email.id, + replyTo: buildReplyContext(target, writeSource), + sourceEmailId: target.id, sourceEmailSource: writeSource, initialDraftText: draftText, - title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')), - }, { source: email, body: draftText ?? '' }); - }, [email, openComposeTab, t, writeSource]); + title: buildReplySubject(target.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')), + }, { source: target, body: draftText ?? '' }); + }, [openComposeTab, t, writeSource]); + + const handleReply = useCallback((draftText?: string) => { + if (!email) return; + replyToMessage(email, draftText); + }, [email, replyToMessage]); // A notification's Reply, once this tab has the message (mail-app.tsx). const pendingTabReply = useNotificationTabReply((s) => s.pending); @@ -315,16 +327,25 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, if (text !== null) handleReply(text); }, [email, pendingTabReply, handleReply]); - const handleReplyAll = useCallback(() => { - if (!email) return; + const replyAllToMessage = useCallback((target: Email) => { openComposeTab({ mode: 'replyAll', - replyTo: buildReplyContext(email, writeSource), - sourceEmailId: email.id, + replyTo: buildReplyContext(target, writeSource), + sourceEmailId: target.id, sourceEmailSource: writeSource, - title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')), - }, { source: email }); - }, [email, openComposeTab, t, writeSource]); + title: buildReplySubject(target.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')), + }, { source: target }); + }, [openComposeTab, t, writeSource]); + + const handleReplyAll = useCallback(() => { + if (!email) return; + replyAllToMessage(email); + }, [email, replyAllToMessage]); + + // A message window reads the message in its conversation (mail-p2-5). + const disableThreading = useSettingsStore((s) => s.disableThreading); + const conversationView = !!conversation && !disableThreading; + const viewConversation = useViewConversation({ enabled: conversationView, email, source: blobSource, onChanged: onMailChanged }); const handleForward = useCallback(() => { if (!email) return; @@ -521,6 +542,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, // This view's message answers the same keys as a message in the mail tab, // while its tab is the one in front. c comes from the mail page, as on any tab. const isFocusedTab = useIsFocusedProTab(); + const [showShortcuts, setShowShortcuts] = useState(false); const singleKeyShortcuts = useSettingsStore((s) => s.singleKeyShortcuts); const shortcutEmails = useMemo(() => (email ? [email] : []), [email]); const shortcutHandlers = useMemo(() => ({ @@ -532,9 +554,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, onDelete: () => void handleDelete(), onMarkAsUnread: email ? () => void handleMarkAsRead(email.id, false) : undefined, onMarkAsRead: email ? () => void handleMarkAsRead(email.id, true) : undefined, - }), [email, handleReply, handleReplyAll, handleForward, handleToggleStar, archiveActionEnabled, handleArchive, handleDelete, handleMarkAsRead]); + // A message window has no mail page behind it to answer ?. + onShowHelp: inOwnWindow ? () => setShowShortcuts(true) : undefined, + }), [email, handleReply, handleReplyAll, handleForward, handleToggleStar, archiveActionEnabled, handleArchive, handleDelete, handleMarkAsRead, inOwnWindow]); + // A Pro tab answers while it is the one in front; a message window is + // always in front of itself (it has no tabs). useKeyboardShortcuts({ - enabled: isFocusedTab && !!email, + enabled: (isFocusedTab || inOwnWindow) && !!email && !showShortcuts, singleKeyShortcuts, emails: shortcutEmails, selectedEmailId: email?.id, @@ -633,6 +659,20 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, void viewConversation.loadBody(e) : undefined} + onMarkConversationEmailRead={conversationView ? (e) => void viewConversation.markRead(e) : undefined} + onReplyConversationEmail={conversationView ? async (e) => { + // The reply quotes the message, so it needs the body. + const full = e.bodyValues ? e : await viewConversation.loadBody(e); + replyToMessage(full ?? e); + } : undefined} + onReplyAllConversationEmail={conversationView ? async (e) => { + const full = e.bodyValues ? e : await viewConversation.loadBody(e); + replyAllToMessage(full ?? e); + } : undefined} blobSource={blobSource} isLoading={isLoading} onReply={handleReply} @@ -659,6 +699,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, /> + {inOwnWindow && setShowShortcuts(false)} />} {inOwnWindow && ( Promise; + /** An unfolded message was read. */ + markRead: (target: Email) => Promise; +} + +/** + * The conversation around a message read outside the mail page (a message + * window), fetched the way the mail page's reading pane fetches it: the + * thread's ids, then their list properties, drafts left out except the open + * one, oldest first. A conversation of one message is none. + */ +export function useViewConversation({ enabled, email, source, onChanged }: { + enabled: boolean; + email: Email | null; + source: EmailBlobContext; + onChanged?: () => void; +}): ViewConversation { + const threadId = enabled ? email?.threadId ?? null : null; + const openId = email?.id ?? null; + const [result, setResult] = useState<{ threadId: string; emails: Email[] } | null>(null); + const [pending, setPending] = useState(false); + const seqRef = useRef(0); + const onChangedRef = useRef(onChanged); + onChangedRef.current = onChanged; + + useEffect(() => { + const seq = ++seqRef.current; + const client = source.client; + if (!threadId || !client) { + setResult(null); + setPending(false); + return; + } + setPending(true); + void (async () => { + const thread = await client.getThread(threadId, source.accountId); + const ids = thread?.emailIds ?? []; + if (ids.length < 2) return null; + const emails = await client.getSomeEmails(ids, source.accountId); + return emails + .filter((e) => e.id === openId || !e.keywords?.$draft) + .sort((a, b) => a.receivedAt.localeCompare(b.receivedAt)); + })().catch(() => null).then((emails) => { + if (seq !== seqRef.current) return; + setResult(emails && emails.length > 1 ? { threadId, emails } : null); + setPending(false); + }); + }, [threadId, openId, source.client, source.accountId]); + + const patch = useCallback((id: string, change: (e: Email) => Email) => { + setResult((prev) => (prev ? { ...prev, emails: prev.emails.map((e) => (e.id === id ? change(e) : e)) } : prev)); + }, []); + + const loadBody = useCallback(async (target: Email) => { + const client = source.client; + if (!client) return null; + const seq = seqRef.current; + const full = await client.getEmail(target.id, source.accountId).catch(() => null); + if (!full || seq !== seqRef.current) return null; + patch(target.id, (e) => ({ ...full, keywords: e.keywords })); + return full; + }, [source.client, source.accountId, patch]); + + const markRead = useCallback(async (target: Email) => { + const client = source.client; + if (!client || target.keywords?.$seen) return; + try { + if (emailHooks.onBeforeEmailReadStateChange.size > 0 + && !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [target.id], read: true }))) { + return; + } + await client.markAsRead(target.id, true, source.accountId); + void emailHooks.onEmailReadStateChange.emit({ emailIds: [target.id], read: true }); + patch(target.id, (e) => ({ ...e, keywords: { ...e.keywords, $seen: true } })); + onChangedRef.current?.(); + } catch (error) { + console.error("Failed to mark conversation message read:", error); + } + }, [source.client, source.accountId, patch]); + + return { + emails: result && result.threadId === threadId ? result.emails : undefined, + pending: pending && !!threadId, + loadBody, + markRead, + }; +} From ea6b20d7ffd4b9e3af0e2f762f6aedc73e097f07 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:21:35 +0100 Subject: [PATCH 08/50] fix: show the selection in the reading pane once rows beyond the open message are ticked --- .../mail/__tests__/mail-app-list-ux.test.tsx | 35 +++++++++++++++++-- components/mail/mail-app.tsx | 21 +++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/components/mail/__tests__/mail-app-list-ux.test.tsx b/components/mail/__tests__/mail-app-list-ux.test.tsx index 7f11c1bf9..b209cea91 100644 --- a/components/mail/__tests__/mail-app-list-ux.test.tsx +++ b/components/mail/__tests__/mail-app-list-ux.test.tsx @@ -37,8 +37,8 @@ vi.mock('@/components/ui/welcome-banner', () => ({ useWelcomeBannerDue: () => false, })); vi.mock('@/components/email/email-viewer', () => ({ - EmailViewer: ({ emptyPaneContent, emptyPaneFooter }: { emptyPaneContent?: React.ReactNode; emptyPaneFooter?: React.ReactNode }) => ( -
{emptyPaneContent ??

no conversation selected

}{emptyPaneFooter}
+ EmailViewer: ({ email, emptyPaneContent, emptyPaneFooter }: { email?: { id: string } | null; emptyPaneContent?: React.ReactNode; emptyPaneFooter?: React.ReactNode }) => ( +
{email ?

reading {email.id}

: <>{emptyPaneContent ??

no conversation selected

}{emptyPaneFooter}}
), })); vi.mock('@/components/email/email-list', () => ({ @@ -94,7 +94,7 @@ beforeEach(() => { viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false, - selectedEmailKeys: new Set(), unreadSection: null, + selectedEmailKeys: new Set(), unreadSection: null, listRows: null, fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), @@ -138,6 +138,35 @@ describe('the reading pane for a selection', () => { expect(screen.getByTestId('viewer').contains(listProps.host!)).toBe(true); }); + it('replaces the open message once other rows are ticked too (mail-p2-3)', () => { + useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m1', 'm2']) } as never); + render(); + expect(screen.queryByText('reading m1')).toBeNull(); + expect(listProps.host).toBeInstanceOf(HTMLElement); + expect(screen.getByTestId('viewer').contains(listProps.host!)).toBe(true); + }); + + it('replaces the open message when another single row is ticked', () => { + useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m2']) } as never); + render(); + expect(screen.queryByText('reading m1')).toBeNull(); + }); + + it('keeps the open message when only its own row is ticked', () => { + useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m1']) } as never); + render(); + expect(screen.getByText('reading m1')).toBeTruthy(); + }); + + it('keeps the open message when its conversation row is ticked', () => { + useEmailStore.setState({ + selectedEmail: message, selectedEmailKeys: new Set(['m1', 'm1-reply']), + listRows: [{ key: 'm1', members: ['m1', 'm1-reply'] }, { key: 'm2', members: ['m2'] }], + } as never); + render(); + expect(screen.getByText('reading m1')).toBeTruthy(); + }); + it('says nothing is selected without a selection', () => { render(); expect(screen.getByText('no conversation selected')).toBeTruthy(); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index bdc888cfc..4853163de 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -4927,8 +4927,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const dockedReply = dockCandidate && dockedSessionRef.current === composerSessionId; const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : dockedReply ? 'dock' : 'pane'; composerHostRef.current = composerHost; + // Rows ticked beyond the open message take the reading pane (mail-p2-3): + // the selection bar and the keys act on the ticked rows, so a reader + // toolbar acting on the one open message would be a second, different set. + // Unticking brings the message back. Mountain View keeps the message while + // rows are ticked (#1231); the focus layout can't tick rows while reading. + const openRowKeys = (() => { + if (!selectedEmail) return null; + const key = emailKeyFor(selectedEmail); + // A conversation row ticks all its messages: the open one's whole row. + const row = useEmailStore.getState().listRows?.find((r) => r.key === key || r.members.includes(key)); + return new Set(row ? [key, ...row.members] : [key]); + })(); + const selectionTakesReader = !isMobile && !isFocusedMailLayout && !mvShell && !isScheduledView + && selectedEmailKeys.size > 0 + && !(openRowKeys && [...selectedEmailKeys].every((key) => openRowKeys.has(key))); const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread) - || Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null; + || Boolean(selectedEmail && !(mvShell && cursorOnly) && !selectionTakesReader) || deepLinkPane !== null; // A linked message on a phone opens over the list, as one picked there does. const mobileView = deepLinkPane !== null && isMobile ? 'viewer' : activeView; const shouldCollapseListPane = !isMobile && isFocusedMailLayout && hasViewerContent; @@ -6106,9 +6121,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { <> Date: Wed, 4 Nov 2026 11:22:58 +0100 Subject: [PATCH 09/50] fix: stop the app crashing when Compose is pressed while a reply is docked --- .../mail-app-composer-replace.test.tsx | 174 ++++++++++++++++++ components/mail/mail-app.tsx | 13 +- 2 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 components/mail/__tests__/mail-app-composer-replace.test.tsx diff --git a/components/mail/__tests__/mail-app-composer-replace.test.tsx b/components/mail/__tests__/mail-app-composer-replace.test.tsx new file mode 100644 index 000000000..f56066c13 --- /dev/null +++ b/components/mail/__tests__/mail-app-composer-replace.test.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useToastStore } from '@/stores/toast-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; + +// One composer at a time (compose-p2-1, compose-p2-2). Another one opening +// while a composer is on screen closes that one the way its X does (save, +// "Draft saved"), then opens. It never crashes and never drops the text. + +const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } })); +// What the stub composer's close does: 'saves' closes and lets the next one +// open, 'fails' keeps the composer (a save the server refused). +const stub = vi.hoisted(() => ({ + close: 'none' as 'none' | 'saves' | 'fails', + closes: [] as string[], + mounts: [] as string[], +})); + +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ + Sidebar: ({ onCompose }: { onCompose: () => void }) => , +})); +// As the real composer: it hands its state (its own mode, untouched) to the +// host as it unmounts without an explicit close, and offers its close. +vi.mock('@/components/email/email-composer', async () => { + const { useEffect, useRef } = await import('react'); + return { + EmailComposer: ({ mode, onSaveState, onClose, requestCloseRef }: { + mode: string; + onSaveState: (data: Record) => void; + onClose: () => void; + requestCloseRef?: { current: unknown }; + }) => { + const closed = useRef(false); + useEffect(() => { + stub.mounts.push(mode); + // The old host moved the composer between dock and pane for ever + // (React error 185 in the browser); stop it here instead of hanging. + if (stub.mounts.length > 40) throw new Error('composer remount loop'); + if (requestCloseRef && stub.close !== 'none') { + requestCloseRef.current = (afterClose?: () => void) => { + stub.closes.push(mode); + if (stub.close === 'fails') return; + closed.current = true; + onClose(); + afterClose?.(); + }; + } + return () => { + if (requestCloseRef) requestCloseRef.current = null; + if (closed.current) return; + onSaveState({ to: '', cc: '', bcc: '', subject: '', body: '', mode, draftId: null, unedited: true }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return
stub composer
; + }, + }; +}); +vi.mock('@/components/search/mv-search-form', () => ({ + MvSearchForm: ({ onSearch }: { onSearch: () => void }) => , +})); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: ({ onReply, onReplyAll, onForward }: { onReply: () => void; onReplyAll: () => void; onForward: () => void }) => ( +
+ message viewer + + + +
+ ), +})); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => device.current, + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ + EmailList: () => null, +})); + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox; +const MESSAGE = { + id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Ann', email: 'ann@example.org' }], to: [{ email: 'a@example.org' }], + subject: 'Plans', receivedAt: '2026-11-01T10:00:00Z', preview: 'Hi', size: 10, + hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {}, +} as never; + +beforeEach(() => { + device.current = { isMobile: false, isTablet: false, isDesktop: true }; + stub.close = 'none'; + stub.closes = []; + stub.mounts = []; + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + const client = { + getAccountId: () => 'acc', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + getEmail: vi.fn().mockResolvedValue(null), + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [], + getClientForAccount: () => client as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never); + useEmailStore.setState({ + emails: [MESSAGE], selectedEmail: MESSAGE, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false, + selectedEmailKeys: new Set(), unreadSection: null, + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + } as never); + useToastStore.getState().clearToasts(); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + useToastStore.getState().clearToasts(); +}); + +const composerMode = () => screen.getByTestId('stub-composer').getAttribute('data-mode'); + +describe('Compose while a reply is docked (compose-p2-1)', () => { + for (const action of ['stub reply', 'stub forward']) { + it(`opens a new message in the pane after ${action}`, async () => { + render(); + fireEvent.click(await screen.findByText(action)); + const reply = await screen.findByTestId('stub-composer'); + expect(reply.closest('[data-reply-dock]')).not.toBeNull(); + + fireEvent.click(screen.getByText('stub compose')); + await waitFor(() => expect(composerMode()).toBe('compose')); + // The new message takes the pane, not the dock, and stays put. + expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).toBeNull(); + const mounted = stub.mounts.length; + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + expect(stub.mounts.length).toBe(mounted); + expect(composerMode()).toBe('compose'); + }); + } +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index bdc888cfc..1ea598b03 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -304,7 +304,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { composerSessionRef.current = composerSessionId; const composerHostRef = useRef('pane'); // The compose session docked under its message (dockedReply). - const dockedSessionRef = useRef(null); + const [dockedSession, setDockedSession] = useState(null); const showComposerRef = useRef(showComposer); showComposerRef.current = showComposer; // Fields a plugin filled in through onBeforeCompose for the composer about @@ -4923,8 +4923,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // anew when the message goes away (another folder opened meanwhile). const dockCandidate = !mvShell && !isMobile && showComposer && !isEmbedded && !floatingComposer && (pendingDraft?.mode ?? composerMode) !== 'compose'; - if (dockCandidate && selectedEmail && !selectedEmail.keywords?.$draft) dockedSessionRef.current = composerSessionId; - const dockedReply = dockCandidate && dockedSessionRef.current === composerSessionId; + const dockOnOpen = dockCandidate && Boolean(selectedEmail) && !selectedEmail?.keywords?.$draft; + if (dockOnOpen && dockedSession !== composerSessionId) setDockedSession(composerSessionId); + const dockedReply = dockCandidate && (dockOnOpen || dockedSession === composerSessionId); const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : dockedReply ? 'dock' : 'pane'; composerHostRef.current = composerHost; const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread) @@ -5275,6 +5276,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { sessionKey={`inline-${composerSessionId}`} sourceEmailId={answeredEmailId} onSaveState={(data) => { + // A composer whose session another one has replaced: its state + // belongs to no composer on screen. Taken as the new session's, + // it turned a new message into the old reply and moved it between + // the dock and the pane for ever (React error 185, compose-p2-1). + // Whatever opens a composer closes the old one first, which saves it. + if (composerSessionRef.current !== composerSessionId) return; setComposerPrefill(null); if (suppressComposerStateSaveSessionRef.current === composerSessionId) { suppressComposerStateSaveSessionRef.current = null; From 890c49169bc7d3aad4bc0a4d07db3113d68e0f49 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:26:02 +0100 Subject: [PATCH 10/50] fix: save the open message as a draft before Compose, Reply all or Forward opens another --- .../mail-app-composer-replace.test.tsx | 52 ++++++++++++++ components/mail/mail-app.tsx | 67 ++++++++++--------- 2 files changed, 88 insertions(+), 31 deletions(-) diff --git a/components/mail/__tests__/mail-app-composer-replace.test.tsx b/components/mail/__tests__/mail-app-composer-replace.test.tsx index f56066c13..1d0283969 100644 --- a/components/mail/__tests__/mail-app-composer-replace.test.tsx +++ b/components/mail/__tests__/mail-app-composer-replace.test.tsx @@ -172,3 +172,55 @@ describe('Compose while a reply is docked (compose-p2-1)', () => { }); } }); + +describe('another composer while one is open (compose-p2-2)', () => { + it('closes the reply through its own close (save, "Draft saved") before Compose opens', async () => { + stub.close = 'saves'; + render(); + fireEvent.click(await screen.findByText('stub reply')); + await screen.findByTestId('stub-composer'); + + fireEvent.click(screen.getByText('stub compose')); + await waitFor(() => expect(composerMode()).toBe('compose')); + expect(stub.closes).toEqual(['reply']); + }); + + for (const [action, mode] of [['stub reply all', 'replyAll'], ['stub forward', 'forward']] as const) { + it(`closes the reply through its own close before ${action} opens`, async () => { + stub.close = 'saves'; + render(); + fireEvent.click(await screen.findByText('stub reply')); + await screen.findByTestId('stub-composer'); + + fireEvent.click(screen.getByText(action)); + await waitFor(() => expect(composerMode()).toBe(mode)); + expect(stub.closes).toEqual(['reply']); + }); + } + + it('closes a new message before another new message opens', async () => { + stub.close = 'saves'; + render(); + fireEvent.click(screen.getByText('stub compose')); + await screen.findByTestId('stub-composer'); + const mounted = stub.mounts.length; + + fireEvent.click(screen.getByText('stub compose')); + await waitFor(() => expect(stub.mounts.length).toBe(mounted + 1)); + expect(stub.closes).toEqual(['compose']); + }); + + it('keeps the open composer when its save fails', async () => { + stub.close = 'fails'; + render(); + fireEvent.click(await screen.findByText('stub reply')); + await screen.findByTestId('stub-composer'); + const mounted = stub.mounts.length; + + fireEvent.click(screen.getByText('stub compose')); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + expect(stub.closes).toEqual(['reply']); + expect(stub.mounts.length).toBe(mounted); + expect(composerMode()).toBe('reply'); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 1ea598b03..fb2db8863 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -319,6 +319,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // already uses the same seam), so entry points that replace the session can // ask before discarding unsaved text. const composerRequestCloseRef = useRef<((afterClose?: () => void) => void) | null>(null); + // One composer at a time (compose-p2-2). Whatever opens a composer closes + // the one on screen first, the way its X does: it saves and says "Draft + // saved" with Discard, then the new one opens. When that save fails the old + // composer stays with its question, and this never settles: nothing opens. + const closeOpenComposer = useCallback(() => new Promise((resolve) => { + const requestClose = composerRequestCloseRef.current; + if (showComposerRef.current && requestClose) requestClose(resolve); + else resolve(); + }), []); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); @@ -1176,12 +1185,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }, onCompose: () => { - void withComposeGate('compose', {}, () => { - startFreshComposerSession(); - setComposerMode('compose'); - setShowComposer(true); - if (isMobile) setActiveView('viewer'); - }); + void handleCompose(); }, onFocusSearch: () => { if (isScheduledView) return; @@ -2824,11 +2828,24 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setPendingDraft(null); }, []); + // A new message, from every Compose and New message there is. + const handleCompose = async (onOpen?: () => void) => { + await closeOpenComposer(); + await withComposeGate('compose', {}, () => { + startFreshComposerSession(); + setComposerMode('compose'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + onOpen?.(); + }); + }; + // A minimised composer's edits live only in pendingDraft. Before an // automatic sign-out it is opened again and saves them itself. useMinimisedComposerFlush(!showComposer && pendingDraft?.unsaved === true, () => setShowComposer(true)); const handleReply = async (draftText?: string) => { + await closeOpenComposer(); if (selectedEmail) { const formerSelected = { ...selectedEmail }; const enrichedEmail = await emailHooks.onBeforeComposeOpenToReply.transform(selectedEmail); @@ -2867,6 +2884,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { : selectedEmail; let draft = draftCandidate; if (!draft) return; + await closeOpenComposer(); // The login and account the draft lives in (resolveDraftSource). The // composer removes the old version there when it saves, sends or @@ -3121,6 +3139,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t, tComposer, tViewer]); const handleReplyAll = async (draftText?: string) => { + await closeOpenComposer(); if (selectedEmail) { const formerSelected = { ...selectedEmail }; const enrichedEmail = await emailHooks.onBeforeComposeOpenToReplyAll.transform(selectedEmail); @@ -3210,6 +3229,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }, [isEmbedded]); const handleForward = async () => { + await closeOpenComposer(); if (selectedEmail) { const formerSelected = { ...selectedEmail }; const enrichedEmail = await emailHooks.onBeforeComposeOpenToForward.transform(selectedEmail); @@ -3256,6 +3276,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // unselected row. See the list context-menu wiring below. const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => { if (!email) return; + await closeOpenComposer(); email = await emailHooks.onBeforeComposeOpenToForwardAsAttachment.transform(email); // Same filename options "Export as .eml" uses (see emailFilenameOptions // in email-viewer.tsx), so the two actions produce consistent filenames @@ -3324,6 +3345,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const handleBatchForwardAsAttachment = async () => { const selected = resolveSelectedEmails(useEmailStore.getState()); if (selected.length < 2) return; // menu only offers this for >1; guards future wiring + await closeOpenComposer(); const { emailDownloadTemplate, @@ -4817,14 +4839,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onImportEmail: handleImportEmailFromContextMenu, onRefreshMailboxes: handleRefreshMailboxes, onCompose: () => { - void withComposeGate('compose', {}, () => { - startFreshComposerSession(); - setComposerMode('compose'); - setShowComposer(true); - if (isMobile) { - setSidebarOpen(false); - setActiveView('viewer'); - } + void handleCompose(() => { + if (isMobile) setSidebarOpen(false); }); }, onSidebarClose: () => setSidebarOpen(false), @@ -5155,6 +5171,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Handle reply from conversation view const handleConversationReply = async (email: Email) => { + await closeOpenComposer(); email = await emailHooks.onBeforeComposeOpenToReply.transform(email); selectEmail(email); await prepareComposerQuoteHeader(email, 'reply'); @@ -5167,6 +5184,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }; const handleConversationReplyAll = async (email: Email) => { + await closeOpenComposer(); email = await emailHooks.onBeforeComposeOpenToReplyAll.transform(email); selectEmail(email); await prepareComposerQuoteHeader(email, 'replyAll'); @@ -5179,6 +5197,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }; const handleConversationForward = async (email: Email) => { + await closeOpenComposer(); email = await emailHooks.onBeforeComposeOpenToForward.transform(email); selectEmail(email); await prepareComposerQuoteHeader(email, 'forward'); @@ -5888,12 +5907,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }} onClearSearch={() => void handleClearSearch()} onCompose={() => { - void withComposeGate('compose', {}, () => { - startFreshComposerSession(); - setComposerMode('compose'); - setShowComposer(true); - if (isMobile) setActiveView('viewer'); - }); + void handleCompose(); }} onMarkAsRead={async (email, read) => { await markReadWithUndo(email, read).catch(toastUpdateFailure); @@ -5936,12 +5950,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { button no longer sits on the list's rows. */} {!mvShell && (isMobile || isEmbedded) && +
+ )} {/* Advanced Search Filter Chips */} diff --git a/components/mail/__tests__/mail-app-list-ux.test.tsx b/components/mail/__tests__/mail-app-list-ux.test.tsx index b209cea91..54354744c 100644 --- a/components/mail/__tests__/mail-app-list-ux.test.tsx +++ b/components/mail/__tests__/mail-app-list-ux.test.tsx @@ -174,6 +174,18 @@ describe('the reading pane for a selection', () => { }); }); +describe('the list header checkbox (mail-p2-11)', () => { + it('ticks every loaded row on the first click and clears them on the second', () => { + const second = { ...message, id: 'm2', threadId: 't-m2', subject: 'm2' } as Email; + useEmailStore.setState({ emails: [message, second] } as never); + render(); + fireEvent.click(screen.getByRole('button', { name: /^email_list\.batch_actions\.select/ })); + expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['m1', 'm2']); + fireEvent.click(screen.getByRole('button', { name: 'email_list.batch_actions.clear_selection' })); + expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0); + }); +}); + describe('the new folder dialogs', () => { it.each([ ['stub new folder', 'mail_list_extra.new_folder_title'], diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 556e6e1d1..59c95a26f 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -5672,24 +5672,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { {/* Select / Select All toggle */} { - if (selectedEmailKeys.size > 0) { - if (selectedEmailKeys.size === activeEmails.length) { - clearSelection(); - } else { - selectAllEmails(); - } - } else if (activeEmails.length > 0) { - // The open message when it is in this list, else the - // first row - as an email, so the selection is keyed by - // its owning account and not by a colliding id. - const openKey = selectedEmail ? emailKeyFor(selectedEmail) : null; - const target = activeEmails.find((e) => emailKeyFor(e) === openKey) ?? activeEmails[0]; - toggleEmailSelection(target); - } + // One click ticks every loaded row, a second clears + // them, as Gmail's box does (mail-p2-11). The bar then + // offers the rest of the folder. + if (selectedEmailKeys.size > 0) clearSelection(); + else if (activeEmails.length > 0) selectAllEmails(); }} disabled={isScheduledView} active={selectedEmailKeys.size > 0} - label={selectedEmailKeys.size > 0 ? (selectedEmailKeys.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')} + label={selectedEmailKeys.size > 0 ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')} title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined} > {selectedEmailKeys.size > 0 ? : } From ee33f9ef29619c4d9ed7532d623c6388b2b8d8cc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:41:56 +0100 Subject: [PATCH 18/50] fix: put the preview after the subject at the regular density so about ten rows fit The split-layout column test looked for a preview paragraph to tell a stacked row; a regular row now carries its preview on the subject line, so the test accepts either. --- .../__tests__/list-row-preview-lines.test.tsx | 46 ++++++++++++------- .../thread-list-item-columns.test.tsx | 4 +- components/email/email-list.tsx | 10 ++-- components/email/thread-list-item.tsx | 22 +++++++-- hooks/use-list-row-shape.ts | 16 +++++++ 5 files changed, 73 insertions(+), 25 deletions(-) diff --git a/components/email/__tests__/list-row-preview-lines.test.tsx b/components/email/__tests__/list-row-preview-lines.test.tsx index a32f33131..366ab7709 100644 --- a/components/email/__tests__/list-row-preview-lines.test.tsx +++ b/components/email/__tests__/list-row-preview-lines.test.tsx @@ -54,24 +54,38 @@ beforeEach(() => { useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); }); -// mail-15: the default density shows one preview line, so more rows fit; -// comfortable keeps two. +// mail-15: the default density puts the preview after the subject, on the +// subject's line, so about ten rows fit at 1366×768 (they were about 96 px, +// seven on screen); comfortable keeps two preview lines, and a phone keeps +// the preview under the subject. describe('list row preview lines', () => { - it.each([ - ['regular', 'line-clamp-1'], - ['comfortable', 'line-clamp-2'], - ] as const)('a %s row clamps its preview with %s', (density, clamp) => { - useSettingsStore.setState({ density }); - renderRow([makeEmail()]); - expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain(clamp); - }); + const onSubjectLine = () => { + const preview = screen.getByText(PREVIEW, { exact: false }); + return preview.closest('[data-row-preview]') !== null; + }; it.each([ - ['regular', 'line-clamp-1'], - ['comfortable', 'line-clamp-2'], - ] as const)('a %s conversation row clamps its preview with %s', (density, clamp) => { - useSettingsStore.setState({ density }); - renderRow([makeEmail({ id: 'a' }), makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' })]); - expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain(clamp); + ['message', [makeEmail()]], + ['conversation', [makeEmail({ id: 'a' }), makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' })]], + ] as const)('a regular %s row shows its preview on the subject line', (_kind, emails) => { + useSettingsStore.setState({ density: 'regular' }); + renderRow([...emails]); + expect(onSubjectLine()).toBe(true); + expect(document.querySelector('p.line-clamp-1')).toBeNull(); + }); + + it('a comfortable row keeps two preview lines of its own', () => { + useSettingsStore.setState({ density: 'comfortable' }); + renderRow([makeEmail()]); + expect(onSubjectLine()).toBe(false); + expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain('line-clamp-2'); + }); + + it('a regular row on a phone keeps the preview under the subject', () => { + useUIStore.setState({ isMobile: true }); + useSettingsStore.setState({ density: 'regular' }); + renderRow([makeEmail()]); + expect(onSubjectLine()).toBe(false); + expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain('line-clamp-1'); }); }); diff --git a/components/email/__tests__/thread-list-item-columns.test.tsx b/components/email/__tests__/thread-list-item-columns.test.tsx index 98c275ee0..ed27738ce 100644 --- a/components/email/__tests__/thread-list-item-columns.test.tsx +++ b/components/email/__tests__/thread-list-item-columns.test.tsx @@ -93,7 +93,9 @@ describe('message list rows under the bottom reading pane (#262)', () => { it('keeps stacked rows in the split layout', () => { useSettingsStore.setState({ mailLayout: 'split' }); const { container } = renderEmails([makeEmail()]); - expect(container.querySelector('p')).not.toBeNull(); + // Stacked: a preview line of its own, or the preview after the subject + // on the subject's line at the regular density (mail-15). + expect(container.querySelector('p, [data-row-preview]')).not.toBeNull(); }); describe('as columns', () => { diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index c8d3fa426..307d3159e 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -38,7 +38,7 @@ import { useOpenEmailInWindow } from "@/hooks/use-popouts-available"; import { useIsDesktop } from "@/hooks/use-media-query"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useSettingsStore } from "@/stores/settings-store"; -import { useListRowShape } from "@/hooks/use-list-row-shape"; +import { useListRowShape, showsPreviewOnSubjectLine } from "@/hooks/use-list-row-shape"; import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton"; import type { Density } from "@/lib/density"; import { useUIStore } from "@/stores/ui-store"; @@ -491,7 +491,11 @@ export function EmailList({ // up after a jump down (scrollbar drag, End). So guess per row. const latest = item?.kind === 'row' ? item.thread.latestEmail : undefined; let size = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density]; - if (showPreview && density !== 'extra-compact') { + if (showsPreviewOnSubjectLine({ showPreview, density, isMobile, mountainView: mountainViewList })) { + // The preview follows the subject on its line, and the subject line + // drops its margin (mail-15). + size -= 4; + } else if (showPreview && density !== 'extra-compact') { // A mail without a preview draws a one-line "No preview available", // a line (23px) shorter than a real one. Only comfortable rows show a // second preview line (mail-15). @@ -502,7 +506,7 @@ export function EmailList({ const hasCode = !!latest && showVerificationCodes && !!listVerificationCode(latest); if (latest && (hasCode || (onOpenAttachment && listRowShowsChips(latest, loadAttachments)))) size += 28; return size; - }, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments]); + }, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments, isMobile, mountainViewList]); // Stable per list, so the virtualizer does not rebuild every row's // measurement on each scroll render. diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index a65986286..a54107ee4 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -16,7 +16,7 @@ import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, Messa import type { AppIcon } from "@/components/icons"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useShallow } from "zustand/react/shallow"; -import { useListRowShape, LIST_COLUMN_CLASS } from "@/hooks/use-list-row-shape"; +import { useListRowShape, LIST_COLUMN_CLASS, showsPreviewOnSubjectLine } from "@/hooks/use-list-row-shape"; import { SizeCell } from "./list-columns"; import { accountTintKey, generateAvatarColor } from "@/lib/account-utils"; import { useUIStore } from "@/stores/ui-store"; @@ -498,6 +498,7 @@ const SingleEmailItem = React.forwardRef( // together. const mvRow = interfaceLayout === 'mountain-view' && oneLineRow; const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile; + const previewOnSubjectLine = showsPreviewOnSubjectLine({ showPreview, density, isMobile, mountainView: interfaceLayout === 'mountain-view' }); const trimmedPreview = cleanPreview(email.preview); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; // Search hits carry server snippets with the matched terms marked; they @@ -971,7 +972,7 @@ const SingleEmailItem = React.forwardRef( -
+
{tagPlacement === 'subject' && tagIds.length > 0 && ( {tagIds.map((id) => ( @@ -986,11 +987,16 @@ const SingleEmailItem = React.forwardRef( : "font-normal text-foreground/90" )}> {subjectSnippet ? : (email.subject || tNoSubject)} + {previewOnSubjectLine && ( + + {' – '}{previewSnippet ? : (trimmedPreview || t('no_preview_available'))} + + )}
- {showPreview && density !== 'extra-compact' && density !== 'compact' && ( + {showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && (

-
+
{tagPlacement === 'subject' && tagIds.length > 0 && ( {tagIds.map((id) => ( @@ -1774,11 +1781,16 @@ export const ThreadListItem = React.forwardRef {subjectSnippet ? : (latestEmail.subject || tNoSubject)} + {previewOnSubjectLine && ( + + {' – '}{previewSnippet ? : (trimmedPreview || tEmailViewer('no_preview_available'))} + + )}
- {showPreview && density !== 'extra-compact' && density !== 'compact' && ( + {showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && (

Date: Wed, 4 Nov 2026 11:42:22 +0100 Subject: [PATCH 19/50] fix: count every account's unread mail in the rail's Mail badge and the tab title, as the favicon does --- components/favicon-badge.tsx | 29 ++------------ .../navigation-rail-badge-name.test.tsx | 26 +++++++++++++ components/layout/navigation-rail.tsx | 7 ++-- components/mail/mail-app.tsx | 15 ++++++-- hooks/use-all-inboxes-unread.ts | 38 +++++++++++++++++++ lib/__tests__/tab-title.test.ts | 24 +++++++++++- lib/tab-title.ts | 22 +++++++++++ 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/vi/common.json | 4 +- locales/zh-TW/common.json | 4 +- locales/zh/common.json | 4 +- 37 files changed, 217 insertions(+), 64 deletions(-) create mode 100644 hooks/use-all-inboxes-unread.ts diff --git a/components/favicon-badge.tsx b/components/favicon-badge.tsx index 1bc199925..a961d0f29 100644 --- a/components/favicon-badge.tsx +++ b/components/favicon-badge.tsx @@ -1,13 +1,9 @@ "use client"; -import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useFaviconBadge } from "@/hooks/use-favicon-badge"; import { useAppBadge } from "@/hooks/use-app-badge"; -import { useShallow } from "zustand/react/shallow"; -import { useAccountStore } from "@/stores/account-store"; -import { useAuthStore } from "@/stores/auth-store"; -import { totalInboxUnread } from "@/lib/account-inbox-unread"; +import { useAllInboxesUnread } from "@/hooks/use-all-inboxes-unread"; /** * Badges the browser-tab favicon, and — where supported — the installed PWA's @@ -31,33 +27,14 @@ import { totalInboxUnread } from "@/lib/account-inbox-unread"; * Renders nothing. */ export function FaviconBadge() { - // The store's canonical inbox selector. `role === 'inbox'` alone is not - // enough: shared and group inboxes ship in the same `mailboxes` array, so on - // a delegated setup the first match can be somebody else's inbox. - const inboxUnread = useEmailStore( - (s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0, - ); const enabled = useSettingsStore((s) => s.faviconUnreadBadge); - const allAccounts = useSettingsStore((s) => s.unreadBadgeAllAccounts); // With several accounts the badge counts the inbox of each (on by default): // new mail in an account that is not on screen still shows on the icon and - // in the desktop tray. The other accounts' folder lists are what the - // account switcher reads (useProMultiAccountMailboxes); one not loaded yet - // adds nothing. - const othersUnread = useOtherAccountsUnread(allAccounts); - const count = inboxUnread + othersUnread; + // in the desktop tray. + const count = useAllInboxesUnread(); useFaviconBadge(count, enabled); useAppBadge(count, enabled); return null; } - -function useOtherAccountsUnread(enabled: boolean): number { - const activeAccountId = useAuthStore((s) => s.activeAccountId); - const otherIds = useAccountStore( - useShallow((s) => (enabled ? s.accounts.filter((a) => a.isConnected && a.id !== activeAccountId).map((a) => a.id) : [])), - ); - const accountMailboxes = useEmailStore((s) => s.accountMailboxes); - return totalInboxUnread(otherIds, accountMailboxes); -} diff --git a/components/layout/__tests__/navigation-rail-badge-name.test.tsx b/components/layout/__tests__/navigation-rail-badge-name.test.tsx index c3baba3de..bf3d0aa5b 100644 --- a/components/layout/__tests__/navigation-rail-badge-name.test.tsx +++ b/components/layout/__tests__/navigation-rail-badge-name.test.tsx @@ -63,3 +63,29 @@ describe.each([ expect(link.textContent).not.toContain('2'); }); }); + +// Usability test 2026-11-04, mail-6: with two accounts the rail said 10 while +// 30 were unread in all; the favicon already counted every account. +describe('Mail entry with several accounts', () => { + const twoAccounts = async () => { + const { useAccountStore } = await import('@/stores/account-store'); + const { useAuthStore } = await import('@/stores/auth-store'); + useAccountStore.setState({ accounts: [{ id: 'acc-a', isConnected: true }, { id: 'acc-b', isConnected: true }] } as never); + useAuthStore.setState({ activeAccountId: 'acc-a' } as never); + useEmailStore.setState({ accountMailboxes: { 'acc-b': [{ id: 'b-in', role: 'inbox', unreadEmails: 5 }] } } as never); + }; + + it('counts the other accounts\' inboxes too', async () => { + await twoAccounts(); + useSettingsStore.setState({ unreadBadgeAllAccounts: true }); + render(); + expect(screen.getAllByRole('link', { name: 'mail, 7 unread' }).length).toBeGreaterThan(0); + }); + + it('counts the open account only when Notifications says so', async () => { + await twoAccounts(); + useSettingsStore.setState({ unreadBadgeAllAccounts: false }); + render(); + expect(screen.getAllByRole('link', { name: 'mail, 2 unread' }).length).toBeGreaterThan(0); + }); +}); diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index c123da7f6..e36d84345 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -12,7 +12,6 @@ import { useShallow } from "zustand/react/shallow"; import { usePathname, Link, useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { useCalendarStore } from "@/stores/calendar-store"; -import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps"; @@ -34,6 +33,7 @@ import { toUnicodeEmail } from "@/lib/idn"; import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages"; import { NotificationBell } from "@/components/notifications/notification-center"; import { useBottomControls } from "@/hooks/use-bottom-controls"; +import { useAllInboxesUnread } from "@/hooks/use-all-inboxes-unread"; import { writeRailHints, type RailHint } from "@/lib/boot-skeleton/hints"; interface NavItem { @@ -260,7 +260,6 @@ export function NavigationRail({ const { appLogoLightUrl, appLogoDarkUrl } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar }))); - const { mailboxes } = useEmailStore(useShallow((state) => ({ mailboxes: state.mailboxes }))); const client = useAuthStore((s) => s.client); const supportsFiles = client?.supportsFiles() ?? false; const supportsContacts = client?.supportsContacts() ?? false; @@ -277,7 +276,9 @@ export function NavigationRail({ const visibleSidebarApps = useResolvedSidebarApps(); // Pages plugins added (api.ui.setPages); listed after the built-in apps. const pluginPages = usePluginPages(); - const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; + // Every signed-in account's inbox, as the favicon and the tab title count + // it (mail-6), unless Notifications says to count the open one only. + const inboxUnread = useAllInboxesUnread(); const isStalwartAdmin = useStalwartAdmin(); const hasUpdate = useUpdateStore(selectHasUpdate); const updateSeverity = useUpdateStore((s) => s.status?.severity); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 560bf805f..6c18797aa 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -152,7 +152,8 @@ import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; import { ToolbarButton, ToolbarSearchAction } from "@/components/ui/toolbar"; import { useDocumentTitle } from "@/hooks/use-document-title"; -import { mailTitleContext } from "@/lib/tab-title"; +import { mailTitleContext, mailboxTitleLine } from "@/lib/tab-title"; +import { useOtherAccountsUnread } from "@/hooks/use-all-inboxes-unread"; import { usePluginStore } from "@/stores/plugin-store"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { canDownload } from "@/lib/device-policy"; @@ -1472,6 +1473,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // The subject is kept out until the policy has loaded: a gate that keeps // subjects out of browser history must not let one through while unknown. const subjectInTabTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled('tabTitleSubjectEnabled')); + const otherAccountsUnread = useOtherAccountsUnread(); const titleContext = useMemo(() => { // Composing email const composer = showComposer @@ -1491,12 +1493,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined; if (mailbox) { const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`)); - const unreadCount = mailbox.unreadEmails || 0; - mailboxLine = unreadCount > 0 ? `${mailboxName} (${unreadCount})` : mailboxName; + // The open account's own Inbox also counts the other accounts' (mail-6). + mailboxLine = mailboxTitleLine({ + name: mailboxName, + unread: mailbox.unreadEmails || 0, + otherUnread: otherAccountsUnread, + ownInbox: mailbox.role === 'inbox' && !mailbox.isShared && !isUnifiedView, + }, t); } return mailTitleContext({ composer, subject, mailbox: mailboxLine }, { showSubject: subjectInTabTitle }); - }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle]); + }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle, otherAccountsUnread, isUnifiedView]); useDocumentTitle(titleContext); // When this page is rendered inside the Pro shell as the Mail tab body, diff --git a/hooks/use-all-inboxes-unread.ts b/hooks/use-all-inboxes-unread.ts new file mode 100644 index 000000000..bf3e04553 --- /dev/null +++ b/hooks/use-all-inboxes-unread.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useShallow } from "zustand/react/shallow"; +import { useEmailStore } from "@/stores/email-store"; +import { useSettingsStore } from "@/stores/settings-store"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { totalInboxUnread } from "@/lib/account-inbox-unread"; + +/** + * Unread mail in the inboxes of the signed-in accounts that aren't on + * screen, while "count every account" is on in Notifications (the default). + * The other accounts' folder lists are what the account switcher reads + * (useProMultiAccountMailboxes); one not loaded yet adds nothing. + */ +export function useOtherAccountsUnread(): number { + const enabled = useSettingsStore((s) => s.unreadBadgeAllAccounts); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const otherIds = useAccountStore( + useShallow((s) => (enabled ? s.accounts.filter((a) => a.isConnected && a.id !== activeAccountId).map((a) => a.id) : [])), + ); + const accountMailboxes = useEmailStore((s) => s.accountMailboxes); + return totalInboxUnread(otherIds, accountMailboxes); +} + +/** + * The number the app shows for new mail: the open account's own inbox plus, + * by the same setting, every other account's. The favicon, the app badge, + * the rail's Mail entry and the tab title all show this one number + * (usability test 2026-11-04, mail-6). + */ +export function useAllInboxesUnread(): number { + // `role === 'inbox'` alone is not enough: shared and group inboxes ship in + // the same `mailboxes` array, so on a delegated setup the first match can + // be somebody else's inbox. + const own = useEmailStore((s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0); + return own + useOtherAccountsUnread(); +} diff --git a/lib/__tests__/tab-title.test.ts b/lib/__tests__/tab-title.test.ts index 1b0073fe8..f375b2890 100644 --- a/lib/__tests__/tab-title.test.ts +++ b/lib/__tests__/tab-title.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatTabTitle, mailTitleContext } from '../tab-title'; +import { formatTabTitle, mailTitleContext, mailboxTitleLine } from '../tab-title'; describe('formatTabTitle', () => { it('puts the context first, the account next and the app name last', () => { @@ -50,3 +50,25 @@ describe('mailTitleContext', () => { expect(mailTitleContext(view, { showSubject: false })).toBe('Reply'); }); }); + +describe('mailboxTitleLine', () => { + const t = (key: string, values?: Record) => `${key} ${JSON.stringify(values)}`; + + it('names the folder with its unread count', () => { + expect(mailboxTitleLine({ name: 'Family', unread: 2 }, t)).toBe('Family (2)'); + expect(mailboxTitleLine({ name: 'Family', unread: 0 }, t)).toBe('Family'); + }); + + // mail-6: the title said 10 while 30 were unread in all accounts. + it('adds the other accounts on the Inbox', () => { + expect(mailboxTitleLine({ name: 'Inbox', unread: 10, otherUnread: 20, ownInbox: true }, t)) + .toBe('mail_p2_folders.title_all_accounts {"name":"Inbox","count":10,"total":30}'); + expect(mailboxTitleLine({ name: 'Inbox', unread: 0, otherUnread: 20, ownInbox: true }, t)) + .toBe('mail_p2_folders.title_all_accounts_only {"name":"Inbox","total":20}'); + }); + + it('leaves other folders and an Inbox with no other unread mail alone', () => { + expect(mailboxTitleLine({ name: 'Family', unread: 1, otherUnread: 20 }, t)).toBe('Family (1)'); + expect(mailboxTitleLine({ name: 'Inbox', unread: 10, otherUnread: 0, ownInbox: true }, t)).toBe('Inbox (10)'); + }); +}); diff --git a/lib/tab-title.ts b/lib/tab-title.ts index 87fa1ce2f..503e28918 100644 --- a/lib/tab-title.ts +++ b/lib/tab-title.ts @@ -34,3 +34,25 @@ export function mailTitleContext( ): string | null { return view.composer || (showSubject ? view.subject : null) || view.mailbox || null; } + +type Translate = (key: string, values?: Record) => string; + +/** + * The mailbox line of the tab title: "Inbox (10)", or for the open account's + * own Inbox while other accounts have unread mail too, "Inbox (10, 30 in all + * accounts)", the number the favicon and the rail show (usability test + * 2026-11-04, mail-6). `otherUnread` is 0 when Notifications counts the open + * account only. + */ +export function mailboxTitleLine( + { name, unread, otherUnread = 0, ownInbox = false }: { name: string; unread: number; otherUnread?: number; ownInbox?: boolean }, + t: Translate, +): string { + if (ownInbox && otherUnread > 0) { + const total = unread + otherUnread; + return unread > 0 + ? t('mail_p2_folders.title_all_accounts', { name, count: unread, total }) + : t('mail_p2_folders.title_all_accounts_only', { name, total }); + } + return unread > 0 ? `${name} (${unread})` : name; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index b3bc36ab5..59634352f 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "إفراغ المهملات", "empty_spam": "إفراغ البريد العشوائي", "empty_trash_confirm": "حذف {count, plural, zero {الرسائل} one {الرسالة} two {الرسالتين} few {كل الرسائل الـ#} many {كل الرسائل الـ#} other {كل الرسائل الـ#}} في المهملات نهائيًا؟ لا يمكن التراجع عن ذلك.", - "empty_spam_confirm": "حذف {count, plural, zero {الرسائل} one {الرسالة} two {الرسالتين} few {كل الرسائل الـ#} many {كل الرسائل الـ#} other {كل الرسائل الـ#}} في البريد العشوائي نهائيًا؟ لا يمكن التراجع عن ذلك." + "empty_spam_confirm": "حذف {count, plural, zero {الرسائل} one {الرسالة} two {الرسالتين} few {كل الرسائل الـ#} many {كل الرسائل الـ#} other {كل الرسائل الـ#}} في البريد العشوائي نهائيًا؟ لا يمكن التراجع عن ذلك.", + "title_all_accounts": "{name} ({count}، {total} في كل الحسابات)", + "title_all_accounts_only": "{name} ({total} في كل الحسابات)" }, "uxs_folders": { "manage_accounts": "إدارة الحسابات…", diff --git a/locales/ca/common.json b/locales/ca/common.json index 370ca65ba..2336353f6 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Buida la paperera", "empty_spam": "Buida el correu brossa", "empty_trash_confirm": "Vols suprimir per sempre {count, plural, one {el missatge} other {els # missatges}} de la paperera? No es pot desfer.", - "empty_spam_confirm": "Vols suprimir per sempre {count, plural, one {el missatge} other {els # missatges}} del correu brossa? No es pot desfer." + "empty_spam_confirm": "Vols suprimir per sempre {count, plural, one {el missatge} other {els # missatges}} del correu brossa? No es pot desfer.", + "title_all_accounts": "{name} ({count}, {total} a tots els comptes)", + "title_all_accounts_only": "{name} ({total} a tots els comptes)" }, "uxs_folders": { "manage_accounts": "Gestiona els comptes…", diff --git a/locales/cs/common.json b/locales/cs/common.json index 143e0cdcf..a4f189c06 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Vysypat koš", "empty_spam": "Vyprázdnit spam", "empty_trash_confirm": "Trvale smazat {count, plural, one {zprávu} few {všechny # zprávy} many {všech # zprávy} other {všech # zpráv}} v koši? Nelze to vrátit zpět.", - "empty_spam_confirm": "Trvale smazat {count, plural, one {zprávu} few {všechny # zprávy} many {všech # zprávy} other {všech # zpráv}} ve spamu? Nelze to vrátit zpět." + "empty_spam_confirm": "Trvale smazat {count, plural, one {zprávu} few {všechny # zprávy} many {všech # zprávy} other {všech # zpráv}} ve spamu? Nelze to vrátit zpět.", + "title_all_accounts": "{name} ({count}, {total} ve všech účtech)", + "title_all_accounts_only": "{name} ({total} ve všech účtech)" }, "uxs_folders": { "manage_accounts": "Spravovat účty…", diff --git a/locales/da/common.json b/locales/da/common.json index d03c97b2d..5bfef5971 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Tøm papirkurven", "empty_spam": "Tøm spam", "empty_trash_confirm": "Slet {count, plural, one {beskeden} other {alle # beskeder}} i papirkurven for altid? Det kan ikke fortrydes.", - "empty_spam_confirm": "Slet {count, plural, one {beskeden} other {alle # beskeder}} i spam for altid? Det kan ikke fortrydes." + "empty_spam_confirm": "Slet {count, plural, one {beskeden} other {alle # beskeder}} i spam for altid? Det kan ikke fortrydes.", + "title_all_accounts": "{name} ({count}, {total} i alle konti)", + "title_all_accounts_only": "{name} ({total} i alle konti)" }, "uxs_folders": { "manage_accounts": "Administrer konti…", diff --git a/locales/de/common.json b/locales/de/common.json index c3535957e..a00c23fe5 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Papierkorb leeren", "empty_spam": "Spam leeren", "empty_trash_confirm": "{count, plural, one {Die Nachricht} other {Alle # Nachrichten}} im Papierkorb endgültig löschen? Das lässt sich nicht rückgängig machen.", - "empty_spam_confirm": "{count, plural, one {Die Nachricht} other {Alle # Nachrichten}} im Spam endgültig löschen? Das lässt sich nicht rückgängig machen." + "empty_spam_confirm": "{count, plural, one {Die Nachricht} other {Alle # Nachrichten}} im Spam endgültig löschen? Das lässt sich nicht rückgängig machen.", + "title_all_accounts": "{name} ({count}, {total} in allen Konten)", + "title_all_accounts_only": "{name} ({total} in allen Konten)" }, "uxs_folders": { "manage_accounts": "Konten verwalten…", diff --git a/locales/en/common.json b/locales/en/common.json index 045019132..972b3cb69 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Empty Trash", "empty_spam": "Empty Spam", "empty_trash_confirm": "Delete {count, plural, one {the message} other {all # messages}} in Trash forever? You can't undo this.", - "empty_spam_confirm": "Delete {count, plural, one {the message} other {all # messages}} in Spam forever? You can't undo this." + "empty_spam_confirm": "Delete {count, plural, one {the message} other {all # messages}} in Spam forever? You can't undo this.", + "title_all_accounts": "{name} ({count}, {total} in all accounts)", + "title_all_accounts_only": "{name} ({total} in all accounts)" }, "uxs_folders": { "manage_accounts": "Manage accounts…", diff --git a/locales/es/common.json b/locales/es/common.json index 813d5b46c..90d265dcb 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Vaciar papelera", "empty_spam": "Vaciar spam", "empty_trash_confirm": "¿Eliminar para siempre {count, plural, one {el mensaje} other {los # mensajes}} de la papelera? No se puede deshacer.", - "empty_spam_confirm": "¿Eliminar para siempre {count, plural, one {el mensaje} other {los # mensajes}} de spam? No se puede deshacer." + "empty_spam_confirm": "¿Eliminar para siempre {count, plural, one {el mensaje} other {los # mensajes}} de spam? No se puede deshacer.", + "title_all_accounts": "{name} ({count}, {total} en todas las cuentas)", + "title_all_accounts_only": "{name} ({total} en todas las cuentas)" }, "uxs_folders": { "manage_accounts": "Gestionar cuentas…", diff --git a/locales/et/common.json b/locales/et/common.json index bb07164c9..f21bc157e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Tühjenda prügikast", "empty_spam": "Tühjenda rämpspost", "empty_trash_confirm": "Kas kustutada prügikastist {count, plural, one {kiri} other {kõik # kirja}} jäädavalt? Seda ei saa tagasi võtta.", - "empty_spam_confirm": "Kas kustutada rämpspostist {count, plural, one {kiri} other {kõik # kirja}} jäädavalt? Seda ei saa tagasi võtta." + "empty_spam_confirm": "Kas kustutada rämpspostist {count, plural, one {kiri} other {kõik # kirja}} jäädavalt? Seda ei saa tagasi võtta.", + "title_all_accounts": "{name} ({count}, {total} kõigil kontodel)", + "title_all_accounts_only": "{name} ({total} kõigil kontodel)" }, "uxs_folders": { "manage_accounts": "Halda kontosid…", diff --git a/locales/fa/common.json b/locales/fa/common.json index 33a32e3dc..64ed75bba 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "خالی کردن زباله‌دان", "empty_spam": "خالی کردن هرزنامه", "empty_trash_confirm": "{count, plural, one {پیام} other {همه # پیام}} زباله‌دان برای همیشه حذف شود؟ این کار برگشت‌پذیر نیست.", - "empty_spam_confirm": "{count, plural, one {پیام} other {همه # پیام}} هرزنامه برای همیشه حذف شود؟ این کار برگشت‌پذیر نیست." + "empty_spam_confirm": "{count, plural, one {پیام} other {همه # پیام}} هرزنامه برای همیشه حذف شود؟ این کار برگشت‌پذیر نیست.", + "title_all_accounts": "{name} ({count}، {total} در همه حساب‌ها)", + "title_all_accounts_only": "{name} ({total} در همه حساب‌ها)" }, "uxs_folders": { "manage_accounts": "مدیریت حساب‌ها…", diff --git a/locales/fr/common.json b/locales/fr/common.json index 916bc5ca5..86bc4dbf7 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Vider la corbeille", "empty_spam": "Vider le spam", "empty_trash_confirm": "Supprimer définitivement {count, plural, one {le message} other {les # messages}} de la corbeille ? Impossible de revenir en arrière.", - "empty_spam_confirm": "Supprimer définitivement {count, plural, one {le message} other {les # messages}} du spam ? Impossible de revenir en arrière." + "empty_spam_confirm": "Supprimer définitivement {count, plural, one {le message} other {les # messages}} du spam ? Impossible de revenir en arrière.", + "title_all_accounts": "{name} ({count}, {total} dans tous les comptes)", + "title_all_accounts_only": "{name} ({total} dans tous les comptes)" }, "uxs_folders": { "manage_accounts": "Gérer les comptes…", diff --git a/locales/he/common.json b/locales/he/common.json index fa675fa12..5c518d00b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5544,7 +5544,9 @@ "empty_trash": "ריקון האשפה", "empty_spam": "ריקון הספאם", "empty_trash_confirm": "למחוק לצמיתות {count, plural, one {את ההודעה} two {את שתי ההודעות} other {את כל # ההודעות}} באשפה? אי אפשר לבטל את זה.", - "empty_spam_confirm": "למחוק לצמיתות {count, plural, one {את ההודעה} two {את שתי ההודעות} other {את כל # ההודעות}} בספאם? אי אפשר לבטל את זה." + "empty_spam_confirm": "למחוק לצמיתות {count, plural, one {את ההודעה} two {את שתי ההודעות} other {את כל # ההודעות}} בספאם? אי אפשר לבטל את זה.", + "title_all_accounts": "{name} ({count}, {total} בכל החשבונות)", + "title_all_accounts_only": "{name} ({total} בכל החשבונות)" }, "uxs_folders": { "manage_accounts": "ניהול חשבונות…", diff --git a/locales/hu/common.json b/locales/hu/common.json index f8e6273cd..ab9f51a29 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Kuka ürítése", "empty_spam": "Spam ürítése", "empty_trash_confirm": "Véglegesen törli {count, plural, one {az üzenetet} other {mind a # üzenetet}} a kukából? Ez nem vonható vissza.", - "empty_spam_confirm": "Véglegesen törli {count, plural, one {az üzenetet} other {mind a # üzenetet}} a spamből? Ez nem vonható vissza." + "empty_spam_confirm": "Véglegesen törli {count, plural, one {az üzenetet} other {mind a # üzenetet}} a spamből? Ez nem vonható vissza.", + "title_all_accounts": "{name} ({count}, {total} az összes fiókban)", + "title_all_accounts_only": "{name} ({total} az összes fiókban)" }, "uxs_folders": { "manage_accounts": "Fiókok kezelése…", diff --git a/locales/it/common.json b/locales/it/common.json index 148745ae2..e1e8b2a4f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Svuota cestino", "empty_spam": "Svuota spam", "empty_trash_confirm": "Eliminare per sempre {count, plural, one {il messaggio} other {tutti i # messaggi}} nel cestino? Non si può annullare.", - "empty_spam_confirm": "Eliminare per sempre {count, plural, one {il messaggio} other {tutti i # messaggi}} nello spam? Non si può annullare." + "empty_spam_confirm": "Eliminare per sempre {count, plural, one {il messaggio} other {tutti i # messaggi}} nello spam? Non si può annullare.", + "title_all_accounts": "{name} ({count}, {total} in tutti gli account)", + "title_all_accounts_only": "{name} ({total} in tutti gli account)" }, "uxs_folders": { "manage_accounts": "Gestisci account…", diff --git a/locales/ja/common.json b/locales/ja/common.json index db0613b81..6e14746ca 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "ゴミ箱を空にする", "empty_spam": "迷惑メールを空にする", "empty_trash_confirm": "ゴミ箱の{count, plural, other {# 件のメッセージ}}をすべて完全に削除しますか?元に戻せません。", - "empty_spam_confirm": "迷惑メールの{count, plural, other {# 件のメッセージ}}をすべて完全に削除しますか?元に戻せません。" + "empty_spam_confirm": "迷惑メールの{count, plural, other {# 件のメッセージ}}をすべて完全に削除しますか?元に戻せません。", + "title_all_accounts": "{name} ({count}、すべてのアカウントで {total})", + "title_all_accounts_only": "{name} (すべてのアカウントで {total})" }, "uxs_folders": { "manage_accounts": "アカウントを管理…", diff --git a/locales/ko/common.json b/locales/ko/common.json index ee9794db8..c2bc54f27 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "휴지통 비우기", "empty_spam": "스팸 비우기", "empty_trash_confirm": "휴지통의 {count, plural, other {메시지 #개}}를 모두 영구 삭제할까요? 되돌릴 수 없어요.", - "empty_spam_confirm": "스팸의 {count, plural, other {메시지 #개}}를 모두 영구 삭제할까요? 되돌릴 수 없어요." + "empty_spam_confirm": "스팸의 {count, plural, other {메시지 #개}}를 모두 영구 삭제할까요? 되돌릴 수 없어요.", + "title_all_accounts": "{name} ({count}, 모든 계정에 {total})", + "title_all_accounts_only": "{name} (모든 계정에 {total})" }, "uxs_folders": { "manage_accounts": "계정 관리…", diff --git a/locales/lv/common.json b/locales/lv/common.json index 5550d87a6..7b9e46c07 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Iztukšot miskasti", "empty_spam": "Iztukšot mēstules", "empty_trash_confirm": "Neatgriezeniski dzēst {count, plural, =1 {vēstuli} zero {visas # vēstules} one {visas # vēstuli} other {visas # vēstules}} miskastē? To nevar atsaukt.", - "empty_spam_confirm": "Neatgriezeniski dzēst {count, plural, =1 {vēstuli} zero {visas # vēstules} one {visas # vēstuli} other {visas # vēstules}} mēstulēs? To nevar atsaukt." + "empty_spam_confirm": "Neatgriezeniski dzēst {count, plural, =1 {vēstuli} zero {visas # vēstules} one {visas # vēstuli} other {visas # vēstules}} mēstulēs? To nevar atsaukt.", + "title_all_accounts": "{name} ({count}, {total} visos kontos)", + "title_all_accounts_only": "{name} ({total} visos kontos)" }, "uxs_folders": { "manage_accounts": "Pārvaldīt kontus…", diff --git a/locales/mn/common.json b/locales/mn/common.json index 9c3ff1cb0..10389dae5 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Хогийн савыг хоослох", "empty_spam": "Спамыг хоослох", "empty_trash_confirm": "Хогийн саван дахь {count, plural, one {захидлыг} other {бүх # захидлыг}} бүрмөсөн устгах уу? Буцаах боломжгүй.", - "empty_spam_confirm": "Спам дахь {count, plural, one {захидлыг} other {бүх # захидлыг}} бүрмөсөн устгах уу? Буцаах боломжгүй." + "empty_spam_confirm": "Спам дахь {count, plural, one {захидлыг} other {бүх # захидлыг}} бүрмөсөн устгах уу? Буцаах боломжгүй.", + "title_all_accounts": "{name} ({count}, бүх бүртгэлд {total})", + "title_all_accounts_only": "{name} (бүх бүртгэлд {total})" }, "uxs_folders": { "manage_accounts": "Данснуудыг удирдах…", diff --git a/locales/nb/common.json b/locales/nb/common.json index dea326255..6b64d5f83 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Tøm papirkurven", "empty_spam": "Tøm søppelpost", "empty_trash_confirm": "Slette {count, plural, one {meldingen} other {alle # meldingene}} i papirkurven for godt? Dette kan ikke angres.", - "empty_spam_confirm": "Slette {count, plural, one {meldingen} other {alle # meldingene}} i søppelpost for godt? Dette kan ikke angres." + "empty_spam_confirm": "Slette {count, plural, one {meldingen} other {alle # meldingene}} i søppelpost for godt? Dette kan ikke angres.", + "title_all_accounts": "{name} ({count}, {total} i alle kontoer)", + "title_all_accounts_only": "{name} ({total} i alle kontoer)" }, "uxs_folders": { "manage_accounts": "Administrer kontoer…", diff --git a/locales/nl/common.json b/locales/nl/common.json index 38065b464..97a29b7d0 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Prullenbak legen", "empty_spam": "Spam legen", "empty_trash_confirm": "{count, plural, one {Het bericht} other {Alle # berichten}} in de prullenbak definitief verwijderen? Dit kan niet ongedaan worden.", - "empty_spam_confirm": "{count, plural, one {Het bericht} other {Alle # berichten}} in spam definitief verwijderen? Dit kan niet ongedaan worden." + "empty_spam_confirm": "{count, plural, one {Het bericht} other {Alle # berichten}} in spam definitief verwijderen? Dit kan niet ongedaan worden.", + "title_all_accounts": "{name} ({count}, {total} in alle accounts)", + "title_all_accounts_only": "{name} ({total} in alle accounts)" }, "uxs_folders": { "manage_accounts": "Accounts beheren…", diff --git a/locales/pl/common.json b/locales/pl/common.json index 7c5252bcd..69d42e56b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Opróżnij kosz", "empty_spam": "Opróżnij spam", "empty_trash_confirm": "Trwale usunąć {count, plural, one {wiadomość} few {wszystkie # wiadomości} many {wszystkie # wiadomości} other {wszystkie # wiadomości}} z kosza? Nie można tego cofnąć.", - "empty_spam_confirm": "Trwale usunąć {count, plural, one {wiadomość} few {wszystkie # wiadomości} many {wszystkie # wiadomości} other {wszystkie # wiadomości}} ze spamu? Nie można tego cofnąć." + "empty_spam_confirm": "Trwale usunąć {count, plural, one {wiadomość} few {wszystkie # wiadomości} many {wszystkie # wiadomości} other {wszystkie # wiadomości}} ze spamu? Nie można tego cofnąć.", + "title_all_accounts": "{name} ({count}, {total} we wszystkich kontach)", + "title_all_accounts_only": "{name} ({total} we wszystkich kontach)" }, "uxs_folders": { "manage_accounts": "Zarządzaj kontami…", diff --git a/locales/pt/common.json b/locales/pt/common.json index 8703862ee..b2186f497 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Esvaziar lixeira", "empty_spam": "Esvaziar spam", "empty_trash_confirm": "Excluir para sempre {count, plural, one {a mensagem} other {as # mensagens}} da lixeira? Não é possível desfazer.", - "empty_spam_confirm": "Excluir para sempre {count, plural, one {a mensagem} other {as # mensagens}} do spam? Não é possível desfazer." + "empty_spam_confirm": "Excluir para sempre {count, plural, one {a mensagem} other {as # mensagens}} do spam? Não é possível desfazer.", + "title_all_accounts": "{name} ({count}, {total} em todas as contas)", + "title_all_accounts_only": "{name} ({total} em todas as contas)" }, "uxs_folders": { "manage_accounts": "Gerir contas…", diff --git a/locales/ro/common.json b/locales/ro/common.json index c7cd5c559..91929ed8c 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Golește coșul de gunoi", "empty_spam": "Golește spamul", "empty_trash_confirm": "Ștergeți definitiv {count, plural, one {mesajul} few {toate cele # mesaje} other {toate cele # de mesaje}} din coșul de gunoi? Nu se poate anula.", - "empty_spam_confirm": "Ștergeți definitiv {count, plural, one {mesajul} few {toate cele # mesaje} other {toate cele # de mesaje}} din spam? Nu se poate anula." + "empty_spam_confirm": "Ștergeți definitiv {count, plural, one {mesajul} few {toate cele # mesaje} other {toate cele # de mesaje}} din spam? Nu se poate anula.", + "title_all_accounts": "{name} ({count}, {total} în toate conturile)", + "title_all_accounts_only": "{name} ({total} în toate conturile)" }, "uxs_folders": { "manage_accounts": "Gestionați conturile…", diff --git a/locales/ru/common.json b/locales/ru/common.json index bf469044a..0abdc507b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Очистить корзину", "empty_spam": "Очистить спам", "empty_trash_confirm": "Удалить {count, plural, =1 {письмо} one {все # письмо} few {все # письма} many {все # писем} other {все # письма}} в корзине навсегда? Это нельзя отменить.", - "empty_spam_confirm": "Удалить {count, plural, =1 {письмо} one {все # письмо} few {все # письма} many {все # писем} other {все # письма}} в спаме навсегда? Это нельзя отменить." + "empty_spam_confirm": "Удалить {count, plural, =1 {письмо} one {все # письмо} few {все # письма} many {все # писем} other {все # письма}} в спаме навсегда? Это нельзя отменить.", + "title_all_accounts": "{name} ({count}, {total} во всех аккаунтах)", + "title_all_accounts_only": "{name} ({total} во всех аккаунтах)" }, "uxs_folders": { "manage_accounts": "Управление аккаунтами…", diff --git a/locales/sk/common.json b/locales/sk/common.json index 006f1981f..4e2e1885f 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Vyprázdniť kôš", "empty_spam": "Vyprázdniť spam", "empty_trash_confirm": "Natrvalo odstrániť {count, plural, one {správu} few {všetky # správy} many {všetkých # správy} other {všetkých # správ}} v koši? Nedá sa to vrátiť späť.", - "empty_spam_confirm": "Natrvalo odstrániť {count, plural, one {správu} few {všetky # správy} many {všetkých # správy} other {všetkých # správ}} v spame? Nedá sa to vrátiť späť." + "empty_spam_confirm": "Natrvalo odstrániť {count, plural, one {správu} few {všetky # správy} many {všetkých # správy} other {všetkých # správ}} v spame? Nedá sa to vrátiť späť.", + "title_all_accounts": "{name} ({count}, {total} vo všetkých účtoch)", + "title_all_accounts_only": "{name} ({total} vo všetkých účtoch)" }, "uxs_folders": { "manage_accounts": "Spravovať účty…", diff --git a/locales/th/common.json b/locales/th/common.json index ff2221ad5..ddfcaab12 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1034,7 +1034,9 @@ "empty_trash": "ล้างถังขยะ", "empty_spam": "ล้างสแปม", "empty_trash_confirm": "ลบข้อความ {count, plural, other {ทั้ง # ฉบับ}} ในถังขยะอย่างถาวรไหม ย้อนกลับไม่ได้", - "empty_spam_confirm": "ลบข้อความ {count, plural, other {ทั้ง # ฉบับ}} ในสแปมอย่างถาวรไหม ย้อนกลับไม่ได้" + "empty_spam_confirm": "ลบข้อความ {count, plural, other {ทั้ง # ฉบับ}} ในสแปมอย่างถาวรไหม ย้อนกลับไม่ได้", + "title_all_accounts": "{name} ({count}, {total} ในทุกบัญชี)", + "title_all_accounts_only": "{name} ({total} ในทุกบัญชี)" }, "uxs_folders": { "manage_accounts": "จัดการบัญชี…", diff --git a/locales/tr/common.json b/locales/tr/common.json index 473a1eb40..6e66085fc 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Çöp Kutusunu boşalt", "empty_spam": "Spam klasörünü boşalt", "empty_trash_confirm": "Çöp Kutusundaki {count, plural, one {ileti} other {# iletinin tümü}} kalıcı olarak silinsin mi? Bu geri alınamaz.", - "empty_spam_confirm": "Spam klasöründeki {count, plural, one {ileti} other {# iletinin tümü}} kalıcı olarak silinsin mi? Bu geri alınamaz." + "empty_spam_confirm": "Spam klasöründeki {count, plural, one {ileti} other {# iletinin tümü}} kalıcı olarak silinsin mi? Bu geri alınamaz.", + "title_all_accounts": "{name} ({count}, tüm hesaplarda {total})", + "title_all_accounts_only": "{name} (tüm hesaplarda {total})" }, "uxs_folders": { "manage_accounts": "Hesapları yönet…", diff --git a/locales/uk/common.json b/locales/uk/common.json index 3922dca1a..f50025150 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "Очистити кошик", "empty_spam": "Очистити спам", "empty_trash_confirm": "Видалити {count, plural, =1 {лист} one {усі # лист} few {усі # листи} many {усі # листів} other {усі # листа}} в кошику назавжди? Це не можна скасувати.", - "empty_spam_confirm": "Видалити {count, plural, =1 {лист} one {усі # лист} few {усі # листи} many {усі # листів} other {усі # листа}} у спамі назавжди? Це не можна скасувати." + "empty_spam_confirm": "Видалити {count, plural, =1 {лист} one {усі # лист} few {усі # листи} many {усі # листів} other {усі # листа}} у спамі назавжди? Це не можна скасувати.", + "title_all_accounts": "{name} ({count}, {total} в усіх акаунтах)", + "title_all_accounts_only": "{name} ({total} в усіх акаунтах)" }, "uxs_folders": { "manage_accounts": "Керувати обліковими записами…", diff --git a/locales/vi/common.json b/locales/vi/common.json index 26e76d071..9445268ed 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -3850,7 +3850,9 @@ "empty_trash": "Dọn sạch Thùng rác", "empty_spam": "Dọn sạch Thư rác", "empty_trash_confirm": "Xóa vĩnh viễn {count, plural, other {tất cả # thư}} trong Thùng rác? Không thể hoàn tác.", - "empty_spam_confirm": "Xóa vĩnh viễn {count, plural, other {tất cả # thư}} trong Thư rác? Không thể hoàn tác." + "empty_spam_confirm": "Xóa vĩnh viễn {count, plural, other {tất cả # thư}} trong Thư rác? Không thể hoàn tác.", + "title_all_accounts": "{name} ({count}, {total} trong mọi tài khoản)", + "title_all_accounts_only": "{name} ({total} trong mọi tài khoản)" }, "uxs_folders": { "manage_accounts": "Quản lý tài khoản…", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 5dcbc14dc..a1ed7f1b2 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "清空垃圾桶", "empty_spam": "清空垃圾郵件", "empty_trash_confirm": "要永久刪除垃圾桶中的{count, plural, other {全部 # 封郵件}}嗎?此動作無法復原。", - "empty_spam_confirm": "要永久刪除垃圾郵件中的{count, plural, other {全部 # 封郵件}}嗎?此動作無法復原。" + "empty_spam_confirm": "要永久刪除垃圾郵件中的{count, plural, other {全部 # 封郵件}}嗎?此動作無法復原。", + "title_all_accounts": "{name} ({count}、所有帳號共 {total})", + "title_all_accounts_only": "{name} (所有帳號共 {total})" }, "uxs_folders": { "manage_accounts": "管理帳號…", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0354ee479..8320f27ab 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3838,7 +3838,9 @@ "empty_trash": "清空已删除", "empty_spam": "清空垃圾邮件", "empty_trash_confirm": "要永久删除已删除中的{count, plural, other {全部 # 封邮件}}吗?此操作无法撤销。", - "empty_spam_confirm": "要永久删除垃圾邮件中的{count, plural, other {全部 # 封邮件}}吗?此操作无法撤销。" + "empty_spam_confirm": "要永久删除垃圾邮件中的{count, plural, other {全部 # 封邮件}}吗?此操作无法撤销。", + "title_all_accounts": "{name} ({count}、所有账户共 {total})", + "title_all_accounts_only": "{name} (所有账户共 {total})" }, "uxs_folders": { "manage_accounts": "管理账户…", From 5f0ad627f5550a49d449ebf537e0ae687029198a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:44:22 +0100 Subject: [PATCH 20/50] fix: drop a send from the Scheduled count once a pop-out's undo time is over --- .../__tests__/popout-compose-app.test.tsx | 2 ++ .../popout/__tests__/popout-host.test.tsx | 17 ++++++++++++++ components/popout/popout-compose-app.tsx | 1 + components/popout/popout-host.tsx | 23 +++++++++++++++++++ lib/popout/channel.ts | 2 ++ 5 files changed, 45 insertions(+) diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index d92e90439..363ba58c4 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -610,6 +610,8 @@ describe('a send held for undo', () => { expect(closeSelf).not.toHaveBeenCalled(); const sent = heard.find((message) => message.type === 'sent') as Extract | undefined; expect(sent?.notice.pendingUndoSend ?? null).toBeNull(); + // compose-p2-13: the main window asks for the Scheduled count again then. + expect(sent?.notice.releasesAt).toBe(sendAt); }); it('closes once the undo time is over', async () => { diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index afcae4fcf..619920ba9 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -309,6 +309,23 @@ describe('PopoutHost', () => { expect(useToastStore.getState().toasts).toHaveLength(1); }); + // compose-p2-13: the Scheduled count kept a pop-out's undo-held send + // until the page was reloaded, since nothing reports the release. + it('asks for the Scheduled count again once a send held in a pop-out has gone', async () => { + const refreshScheduledMetadata = vi.fn(async () => {}); + useEmailStore.setState({ refreshScheduledMetadata: refreshScheduledMetadata as never }); + render(); + await settle(); + popout.post({ type: 'sent', requestId: 'r1', notice: { accountId: 'alice', scheduled: true, releasesAt: new Date(Date.now() + 100).toISOString() } }); + popout.post({ type: 'sent', requestId: 'r2', notice: { accountId: 'alice', scheduled: true, releasesAt: '2030-01-01T00:00:00Z' } }); + await settle(); + expect(refreshScheduledMetadata).not.toHaveBeenCalled(); + await settle(700); + // Once, for the held one; the one scheduled for 2030 stays in the count. + expect(refreshScheduledMetadata).toHaveBeenCalledTimes(1); + expect(refreshScheduledMetadata).toHaveBeenCalledWith(client); + }); + it('says once that a closed pop-out kept its draft, with Discard', async () => { render(<>); await settle(); diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 5f8f5433b..b86aa9cca 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -377,6 +377,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: { filingError: !!result.filingError, rejectedRecipients, pendingUndoSend: keepHere ? null : withLogin, + releasesAt: keepHere?.sendAt, }); // No main window to say so, and this is the only notice these get (#1123). if (!taken && (result.filingError || rejectedRecipients)) { diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx index 1308ca0bf..5a0e5deae 100644 --- a/components/popout/popout-host.tsx +++ b/components/popout/popout-host.tsx @@ -24,6 +24,10 @@ const LIST_CHANGE_DEBOUNCE_MS = 400; const CLAIM_HOLD_MS = 15_000; /** How long a window remembers which draft id a rescue became, for a reload of the pop-out. */ const RESCUE_MEMORY_MS = 60_000; +/** The Scheduled count is asked again this long after the server let a held send go. */ +const RELEASE_REFRESH_MS = 3000; +/** Longer holds are messages scheduled for later, which stay in the count. */ +const UNDO_HOLD_MS = 120_000; /** The sessions this window can lend. Empty until it is signed in, and in the demo. */ function shareableSessions(): SharedAccountSession[] { @@ -112,8 +116,26 @@ export function PopoutHost() { }, REFRESH_THROTTLE_MS); }; + // The Scheduled count holds a send while the server holds it. Nothing + // reports the release (compose-p2-13): ask again once it is out. + const releaseTimers = new Set>(); + const refreshScheduled = () => { + const client = useAuthStore.getState().client; + if (client) void useEmailStore.getState().refreshScheduledMetadata(client); + }; + const refreshScheduledAfter = (releasesAt: string) => { + const wait = Date.parse(releasesAt) - Date.now(); + if (!Number.isFinite(wait) || wait > UNDO_HOLD_MS) return; + const timer = setTimeout(() => { + releaseTimers.delete(timer); + refreshScheduled(); + }, Math.max(0, wait) + RELEASE_REFRESH_MS); + releaseTimers.add(timer); + }; + const takeOverSent = (notice: PopoutSentNotice) => { const translate = tRef.current; + if (notice.releasesAt) refreshScheduledAfter(notice.releasesAt); if (notice.filingError) toast.warning(translate("email_composer.send_filing_warning")); if (notice.rejectedRecipients) { toast.warning(translate("email_composer.send_some_recipients_rejected"), { message: notice.rejectedRecipients, duration: 15000 }); @@ -272,6 +294,7 @@ export function PopoutHost() { stopAccounts(); if (refreshTimer) clearTimeout(refreshTimer); if (listTimer) clearTimeout(listTimer); + for (const timer of releaseTimers) clearTimeout(timer); channel?.close(); }; }, [available]); diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts index b249c5bb4..28e39156c 100644 --- a/lib/popout/channel.ts +++ b/lib/popout/channel.ts @@ -53,6 +53,8 @@ export interface PopoutSentNotice { localAccountId?: string; submissionAccountId?: string; } | null; + /** A send held for undo in the pop-out itself: when the server lets it go. */ + releasesAt?: string; } /** From e7cef5ba1c568e3b45d45293967a35886411d56f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:44:53 +0100 Subject: [PATCH 21/50] feat: open a draft in the composer on click, and Reply resumes the reply already drafted --- .../thread-conversation-drafts.test.tsx | 54 ++++++ .../__tests__/viewer-reply-draft.test.tsx | 76 +++++++++ components/email/email-viewer.tsx | 33 +++- components/email/thread-conversation-view.tsx | 68 +++++++- .../mail/__tests__/mail-app-drafts.test.tsx | 158 ++++++++++++++++++ components/mail/mail-app.tsx | 131 ++++++++++++++- lib/__tests__/reply-drafts.test.ts | 54 ++++++ lib/reply-drafts.ts | 58 +++++++ 8 files changed, 622 insertions(+), 10 deletions(-) create mode 100644 components/email/__tests__/thread-conversation-drafts.test.tsx create mode 100644 components/email/__tests__/viewer-reply-draft.test.tsx create mode 100644 components/mail/__tests__/mail-app-drafts.test.tsx create mode 100644 lib/__tests__/reply-drafts.test.ts create mode 100644 lib/reply-drafts.ts diff --git a/components/email/__tests__/thread-conversation-drafts.test.tsx b/components/email/__tests__/thread-conversation-drafts.test.tsx new file mode 100644 index 000000000..80bc7f7ed --- /dev/null +++ b/components/email/__tests__/thread-conversation-drafts.test.tsx @@ -0,0 +1,54 @@ +// compose-p2-6: on a phone the conversation listed a reply draft as an +// ordinary message, expanded at the top with Reply, Reply all and Forward. +// It is marked Draft and offers Edit and Discard instead. +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false })); +vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { ThreadConversationView } = await import('@/components/email/thread-conversation-view'); + +const base = { + threadId: 't1', mailboxIds: { inbox: true }, to: [{ email: 'me@example.org' }], + subject: 's', size: 1, hasAttachment: false, htmlBody: [], textBody: [], attachments: [], bodyValues: {}, +}; +const received = { ...base, id: 'e1', keywords: { $seen: true }, from: [{ name: 'Ann', email: 'ann@t.example' }], receivedAt: '2026-11-01T10:00:00Z', preview: 'question' }; +const draft = { ...base, id: 'd1', keywords: { $seen: true, $draft: true }, from: [{ name: 'Me', email: 'me@example.org' }], receivedAt: '2026-11-02T10:00:00Z', preview: 'my answer' }; + +function renderView(handlers: Record) { + render( + {}} + onReply={() => {}} + onReplyAll={() => {}} + onForward={() => {}} + {...handlers} + />, + ); +} + +describe('a draft in a phone conversation', () => { + it('is marked Draft, and the newest received message is the one open', () => { + renderView({}); + expect(screen.getAllByTestId('conversation-draft-label')).toHaveLength(1); + // Only the received message is open, with its Reply. + expect(screen.getAllByRole('button', { name: /email_viewer.reply$/ })).toHaveLength(1); + expect(screen.queryByRole('button', { name: /email_viewer.edit_draft/ })).toBeNull(); + }); + + it('offers Edit and Discard instead of Reply once opened', () => { + const onEditDraft = vi.fn(); + const onDiscardDraft = vi.fn(); + renderView({ onEditDraft, onDiscardDraft }); + fireEvent.click(screen.getByText('my answer')); + expect(screen.getAllByRole('button', { name: /email_viewer.reply$/ })).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: /email_viewer.edit_draft/ })); + fireEvent.click(screen.getByRole('button', { name: /email_composer.discard/ })); + expect(onEditDraft).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' })); + expect(onDiscardDraft).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' })); + }); +}); diff --git a/components/email/__tests__/viewer-reply-draft.test.tsx b/components/email/__tests__/viewer-reply-draft.test.tsx new file mode 100644 index 000000000..52fba0c2e --- /dev/null +++ b/components/email/__tests__/viewer-reply-draft.test.tsx @@ -0,0 +1,76 @@ +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { clearAllHooks } from '@/lib/plugin-hooks'; +import { useAuthStore } from '@/stores/auth-store'; + +// compose-p2-6: a reply saved as a draft was nowhere to be seen on the +// message it answers. The reader shows it under the message, marked Draft, +// with Edit and Discard, where the quick reply box would be. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => + values ? `${key}:${JSON.stringify(values)}` : key, + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (d: Date | string) => String(d), + relativeTime: (d: Date | string) => String(d), + number: (n: number) => String(n), + }), +})); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const message = { + id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Ben Carter', email: 'ben.carter@example.com' }], to: [{ name: 'Me', email: 'me@example.org' }], + subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [], + bodyValues: { '1': { value: 'Plain body' } }, +} as unknown as Email; + +beforeEach(() => { + clearAllHooks(); + useAuthStore.setState({ identities: [{ id: 'me', email: 'me@example.org', name: 'Me' }] } as never); +}); + +async function renderViewer(replyDraft: { preview?: string; onEdit: () => void; onDiscard: () => void } | null) { + render(); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); +} + +describe('a reply draft under its message', () => { + it('shows Draft with its text, Edit and Discard, instead of the quick reply box', async () => { + const onEdit = vi.fn(); + const onDiscard = vi.fn(); + await renderViewer({ preview: 'my answer', onEdit, onDiscard }); + const card = screen.getByTestId('reply-draft'); + expect(card.textContent).toContain('draft'); + expect(card.textContent).toContain('my answer'); + expect(screen.queryByPlaceholderText(/^quick_reply_to/)).toBeNull(); + fireEvent.click(screen.getByTestId('reply-draft-edit')); + fireEvent.click(screen.getByTestId('reply-draft-discard')); + expect(onEdit).toHaveBeenCalledTimes(1); + expect(onDiscard).toHaveBeenCalledTimes(1); + }); + + it('keeps the quick reply box when there is none', async () => { + await renderViewer(null); + expect(screen.queryByTestId('reply-draft')).toBeNull(); + expect(screen.getByPlaceholderText(/^quick_reply_to/)).toBeTruthy(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 80ffc1c4c..2ae1ae06e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -304,6 +304,11 @@ interface EmailViewerProps { mvInlineComposer?: React.ReactNode; /** A reply or forward to this message is open under it: no quick reply box as well. */ replyComposerOpen?: boolean; + /** + * A reply to this message saved as a draft (compose-p2-6): shown under it, + * marked Draft, with Edit and Discard, in place of the quick reply box. + */ + replyDraft?: { preview?: string; onEdit: () => void; onDiscard: () => void } | null; onEditDraft?: () => void; onCancelScheduledForEdit?: () => void; /** Stop a scheduled message from going out; it goes back to Drafts. */ @@ -1376,6 +1381,7 @@ export function EmailViewer({ onReplyAllConversationEmail, mvInlineComposer, replyComposerOpen, + replyDraft, onEditDraft, onCancelScheduledForEdit, onCancelScheduledSend, @@ -1400,6 +1406,7 @@ export function EmailViewer({ const tWindows = useTranslations('pwaWindows'); const tComposer = useTranslations('email_composer'); const tWords = useTranslations('compose_words'); + const tList = useTranslations('email_list'); const tNotifications = useTranslations('notifications'); const tCommon = useTranslations('common'); const tFiles = useTranslations('files'); @@ -3514,7 +3521,8 @@ export function EmailViewer({ const latestSrcDocRef = useRef(null); const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc; // Not on spam: a reply confirms the address to the sender. - const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder + const showReplyDraft = !!email && !!replyDraft && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled; + const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !showReplyDraft && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder && (!effectiveEmailContent.isHtml || iframeReady); // The plain-text body, where reader-above-quote mounts between text and quote. const [plainBodyEl, setPlainBodyEl] = useState(null); @@ -6190,6 +6198,29 @@ ${printWatermark ? printWatermark.html : ''}

)} + {/* The reply already started on this message, as Gmail shows it. */} + {showReplyDraft && replyDraft && ( +
+
+ + + {tList('draft')} + + {replyDraft.preview} + + + + +
+
+ )} + {/* Quick Reply Section - hidden for drafts and while loading a new email */} {showQuickReply && (
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index eb6c91992..d949f8b25 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -49,6 +49,8 @@ import { Download, Eye, CalendarClock, + Pencil, + Trash2, } from "@/components/icons"; import { formatScheduledSendAt } from "@/lib/scheduled-send-label"; import { useTranslations } from "next-intl"; @@ -91,6 +93,9 @@ interface ThreadConversationViewProps { onReply?: (email: Email) => void; onReplyAll?: (email: Email) => void; onForward?: (email: Email) => void; + /** A draft in the conversation (compose-p2-6): Edit and Discard take Reply's place. */ + onEditDraft?: (email: Email) => void; + onDiscardDraft?: (email: Email) => void; onDownloadAttachment?: ConversationAttachmentHandler; /** Routing captured when this conversation was opened. */ blobSource?: EmailBlobContext; @@ -196,6 +201,8 @@ export function ThreadConversationView({ onReply, onReplyAll, onForward, + onEditDraft, + onDiscardDraft, onDownloadAttachment, blobSource, onMarkAsRead, @@ -219,12 +226,12 @@ export function ThreadConversationView({ if (emails.length > 0) { const idsToExpand = new Set(); - // Always expand most recent - idsToExpand.add(emails[0].id); + // Always expand the most recent message; a draft isn't one. + idsToExpand.add((emails.find((e) => !e.keywords?.$draft) ?? emails[0]).id); // Also expand all unread emails emails.forEach(email => { - if (!email.keywords?.$seen) { + if (!email.keywords?.$seen && !email.keywords?.$draft) { idsToExpand.add(email.id); } }); @@ -280,7 +287,9 @@ export function ThreadConversationView({
- {emails.map((email, index) => ( + {emails.map((email, index) => { + const draft = !!email.keywords?.$draft; + return ( toggleExpanded(email.id)} onAllowExternal={() => externalContent.allow(email.id)} onTrustSender={externalContent.trustSender(email)} - onReply={onReply ? () => onReply(email) : undefined} - onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined} - onForward={onForward ? () => onForward(email) : undefined} + isDraft={draft} + onEditDraft={draft && onEditDraft ? () => onEditDraft(email) : undefined} + onDiscardDraft={draft && onDiscardDraft ? () => onDiscardDraft(email) : undefined} + onReply={!draft && onReply ? () => onReply(email) : undefined} + onReplyAll={!draft && onReplyAll ? () => onReplyAll(email) : undefined} + onForward={!draft && onForward ? () => onForward(email) : undefined} onDownloadAttachment={onDownloadAttachment} blobSource={blobSource} onMarkAsRead={onMarkAsRead} mailboxRole={mailboxRole} /> - ))} + ); + })}
@@ -317,6 +330,9 @@ interface EmailCardProps { onReply?: () => void; onReplyAll?: () => void; onForward?: () => void; + isDraft?: boolean; + onEditDraft?: () => void; + onDiscardDraft?: () => void; onDownloadAttachment?: ConversationAttachmentHandler; /** Routing captured when this conversation was opened. */ blobSource?: EmailBlobContext; @@ -355,6 +371,9 @@ function EmailCard({ onReply, onReplyAll, onForward, + isDraft = false, + onEditDraft, + onDiscardDraft, onDownloadAttachment, blobSource, onMarkAsRead, @@ -410,6 +429,11 @@ function EmailCard({ + {isDraft && ( + + {t('email_list.draft')} + + )} {email.isScheduled && ( @@ -475,6 +499,34 @@ function EmailCard({ in the single reader's More panel on phones. Long names (Arabic "Reply all") wrap to a second line instead of being cut. */}
+ {onEditDraft && ( + + )} + {onDiscardDraft && ( + + )} {onReply && ( , +})); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: ({ onReply, replyDraft }: { onReply: () => void; replyDraft?: { preview?: string; onEdit: () => void } | null }) => ( +
+ message viewer + + {replyDraft && } +
+ ), +})); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => device.current, + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ + EmailList: ({ onEmailSelect }: { onEmailSelect: (email: unknown) => void }) => ( + + ), +})); + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox; +const drafts = { id: 'drafts', name: 'Drafts', role: 'drafts', totalEmails: 1, unreadEmails: 0 } as Mailbox; +const MESSAGE = { + id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Ann', email: 'ann@example.org' }], to: [{ email: 'a@example.org' }], + subject: 'Plans', receivedAt: '2026-11-01T10:00:00Z', preview: 'Hi', size: 10, + hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {}, messageId: ['plans@example.org'], +} as never; +const FULL_DRAFT = { + ...DRAFT_ROW, textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [], bodyValues: { 1: { value: 'my answer' } }, + inReplyTo: ['plans@example.org'], +}; + +type Client = Record & { getEmail: ReturnType }; +let client: Client; + +beforeEach(() => { + device.current = { isMobile: false, isTablet: false, isDesktop: true }; + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + client = { + getAccountId: () => 'acc', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + getEmail: vi.fn(async (id: string) => (id === 'd1' ? { ...FULL_DRAFT } : null)), + getThread: vi.fn(async () => ({ id: 't1', emailIds: ['m1', 'd1'] })), + getSomeEmails: vi.fn(async () => [{ ...(MESSAGE as object) }, { ...DRAFT_ROW }]), + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [], + getClientForAccount: () => client as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never); + useEmailStore.setState({ + emails: [MESSAGE], selectedEmail: MESSAGE, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox, drafts], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false, + selectedEmailKeys: new Set(), unreadSection: null, + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + } as never); + useToastStore.getState().clearToasts(); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + useToastStore.getState().clearToasts(); +}); + +describe('a draft in the list (compose-p2-8)', () => { + it('opens in the composer on a click', async () => { + render(); + fireEvent.click(await screen.findByText('stub draft row')); + const composer = await screen.findByTestId('stub-composer'); + expect(composer).toHaveAttribute('data-draft', 'd1'); + expect(composer).toHaveAttribute('data-mode', 'compose'); + }); +}); + +describe('a message with a reply draft (compose-p2-6)', () => { + it('shows the draft under the message', async () => { + render(); + expect(await screen.findByText('stub reply draft: my answer')).toBeInTheDocument(); + }); + + it('opens that draft on Reply instead of a new reply', async () => { + render(); + await screen.findByText('stub reply draft: my answer'); + fireEvent.click(screen.getByText('stub reply')); + await waitFor(() => expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-draft', 'd1')); + }); + + it('starts a new reply when the conversation has no reply to this message', async () => { + client.getEmail = vi.fn(async (id: string) => (id === 'd1' ? { ...FULL_DRAFT, inReplyTo: ['other@example.org'] } : null)); + render(); + await waitFor(() => expect(client.getEmail).toHaveBeenCalled()); + expect(screen.queryByText(/stub reply draft/)).toBeNull(); + fireEvent.click(screen.getByText('stub reply')); + await waitFor(() => expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-mode', 'reply')); + expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-draft', ''); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index fb2db8863..8e66c9e77 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -43,6 +43,7 @@ import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder"; import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; import { toast } from "@/stores/toast-store"; import { discardWithUndo } from "@/lib/compose/draft-close-toast"; +import { findReplyDraft, threadDrafts } from "@/lib/reply-drafts"; import { cancelFailureToast, partlyRefusedWarning, rescheduleFailureToast, rescheduledToast } from "@/lib/send-failure-message"; import { formatScheduledSendAt } from "@/lib/scheduled-send-label"; import { startSendProgress } from "@/lib/outgoing/send-progress-toast"; @@ -366,6 +367,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { threadId: string; emailId: string; startedAt: number; promise: Promise; } | null>(null); const mvConversationOpenSeqRef = useRef(0); + // compose-p2-6: a reply to the open message saved as a draft. The reader + // shows it under the message, and Reply opens it again. The conversation + // fetch notes the thread's drafts; a scan reads them fresh after a composer + // closed or a draft went. + const [replyDraft, setReplyDraft] = useState<{ emailId: string; draft: Email; source: DraftSource } | null>(null); + const [replyDraftScan, setReplyDraftScan] = useState<{ emailId: string; n: number } | null>(null); + const conversationDraftsRef = useRef<{ threadId: string; emailId: string; drafts: Email[] } | null>(null); const [conversationEmails, setConversationEmails] = useState([]); const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(null); @@ -2844,8 +2852,35 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // automatic sign-out it is opened again and saves them itself. useMinimisedComposerFlush(!showComposer && pendingDraft?.unsaved === true, () => setShowComposer(true)); + // Reply on a message with a reply draft opens that draft (compose-p2-6). + // After a composer closed (it may have saved that very draft under a new + // id) the drafts are read fresh. + const resumeReplyDraft = async (open: Email | null, composerWasOpen: boolean): Promise => { + if (!client || !open || open.keywords?.$draft || mvShell || isScheduledView) return false; + let found = replyDraft?.emailId === open.id ? replyDraft : null; + // A phone's conversation holds its drafts in full. + const phoneConversation = isMobile && conversationEmails.some((e) => e.id === open.id); + if (composerWasOpen || phoneConversation) { + const { client: threadClient, accountId, clientAccountId } = resolveEmailBlobContext(open, client); + if (!threadClient) return false; + const drafts = phoneConversation && !composerWasOpen + ? conversationEmails + : await threadDrafts(threadClient, open.threadId, accountId).catch(() => []); + const draft = await findReplyDraft(drafts, open, (id) => threadClient.getEmail(id, accountId)); + found = draft ? { + emailId: open.id, draft, + source: { accountId, localAccountId: clientAccountId ?? activeAccountId ?? undefined }, + } : null; + } + if (!found) return false; + await handleEditDraft(found.draft, found.source); + return true; + }; + const handleReply = async (draftText?: string) => { + const composerWasOpen = showComposer; await closeOpenComposer(); + if (!draftText && await resumeReplyDraft(selectedEmail, composerWasOpen)) return; if (selectedEmail) { const formerSelected = { ...selectedEmail }; const enrichedEmail = await emailHooks.onBeforeComposeOpenToReply.transform(selectedEmail); @@ -3139,7 +3174,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t, tComposer, tViewer]); const handleReplyAll = async (draftText?: string) => { + const composerWasOpen = showComposer; await closeOpenComposer(); + if (!draftText && await resumeReplyDraft(selectedEmail, composerWasOpen)) return; if (selectedEmail) { const formerSelected = { ...selectedEmail }; const enrichedEmail = await emailHooks.onBeforeComposeOpenToReplyAll.transform(selectedEmail); @@ -4737,6 +4774,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } const scheduledSubmissionByEmailId = useEmailStore.getState().scheduledSubmissionByEmailId; const annotated = annotateScheduledEmails(emails, scheduledSubmissionByEmailId); + conversationDraftsRef.current = { + threadId: ref.threadId, emailId: ref.id, + drafts: annotated.filter((e) => e.id !== ref.id && e.keywords?.$draft), + }; return annotated .filter((e) => e.id === ref.id || !e.keywords?.$draft) .sort((a, b) => a.receivedAt.localeCompare(b.receivedAt)); @@ -4766,6 +4807,62 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the open message, not on every list refresh }, [mvConversationThreadId, mvConversationRef?.id, mvBlobSource.client, mvBlobSource.accountId, client]); + // The open message's reply draft (compose-p2-6). Not in Mountain View, which + // has its own conversation, nor on a phone, whose conversation lists drafts. + const replyDraftMessageIds = String(selectedEmail?.messageId ?? ''); + useEffect(() => { + const open = selectedEmail; + if (!client || !open || open.keywords?.$draft || mvShell || isMobile || isScheduledView || !replyDraftMessageIds) { + setReplyDraft(null); + return; + } + const fresh = replyDraftScan?.emailId === open.id; + const noted = conversationDraftsRef.current; + const known = noted && noted.threadId === open.threadId && noted.emailId === open.id ? noted.drafts : null; + if (!fresh && !known) { + // The conversation isn't here yet: keep what this message had. + setReplyDraft((prev) => (prev?.emailId === open.id ? prev : null)); + return; + } + if (!fresh && known?.length === 0) { + setReplyDraft(null); + return; + } + const { client: threadClient, accountId, clientAccountId } = resolveEmailBlobContext(open, client); + if (!threadClient) return; + let cancelled = false; + void (async () => { + const drafts = fresh ? await threadDrafts(threadClient, open.threadId, accountId).catch(() => []) : known ?? []; + const found = await findReplyDraft(drafts, open, (id) => threadClient.getEmail(id, accountId)); + if (cancelled) return; + setReplyDraft(found ? { + emailId: open.id, + draft: { ...found, sourceClientAccountId: open.sourceClientAccountId, sourceAccountId: open.sourceAccountId }, + source: { accountId, localAccountId: clientAccountId ?? activeAccountId ?? undefined }, + } : null); + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the open message and what can change its drafts + }, [selectedEmail?.id, replyDraftMessageIds, mvConversationResult, replyDraftScan, client, mvShell, isMobile, isScheduledView]); + // A composer closing may have saved, sent or discarded a reply draft. + const scanReplyDrafts = () => { + const open = useEmailStore.getState().selectedEmail; + if (open) setReplyDraftScan((prev) => ({ emailId: open.id, n: (prev?.n ?? 0) + 1 })); + }; + const composerWasOpenRef = useRef(showComposer); + useEffect(() => { + if (composerWasOpenRef.current && !showComposer) scanReplyDrafts(); + composerWasOpenRef.current = showComposer; + }, [showComposer]); + const discardReplyDraft = () => { + const kept = replyDraft; + if (!kept) return; + setReplyDraft(null); + discardWithUndo(() => { + void handleDiscardDraft(kept.draft.id, kept.source).then(scanReplyDrafts); + }, { discarded: tComposeUx('draft_discarded'), undo: tComposeUx('undo') }, () => setReplyDraft(kept)); + }; + const patchMvConversation = (id: string, patch: (e: Email) => Email) => setMvConversationResult(prev => prev ? { ...prev, emails: prev.emails.map(e => e.id === id ? patch(e) : e), @@ -5171,7 +5268,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Handle reply from conversation view const handleConversationReply = async (email: Email) => { + const composerWasOpen = showComposer; await closeOpenComposer(); + if (await resumeReplyDraft(email, composerWasOpen)) return; email = await emailHooks.onBeforeComposeOpenToReply.transform(email); selectEmail(email); await prepareComposerQuoteHeader(email, 'reply'); @@ -5184,7 +5283,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }; const handleConversationReplyAll = async (email: Email) => { + const composerWasOpen = showComposer; await closeOpenComposer(); + if (await resumeReplyDraft(email, composerWasOpen)) return; email = await emailHooks.onBeforeComposeOpenToReplyAll.transform(email); selectEmail(email); await prepareComposerQuoteHeader(email, 'replyAll'); @@ -5332,6 +5433,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const outcome = await handleEmailSend(data); setPendingDraft(null); setComposerPrefill(null); + scanReplyDrafts(); return outcome; }} onSendFailed={(state) => { @@ -5844,7 +5946,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }} onRescheduleScheduled={(email) => setRescheduleTarget(email)} onCancelScheduledSend={handleCancelScheduledSend} - onEmailSelect={handleEmailSelect} + // A click (or Enter) on a draft opens it in the composer, as + // Gmail and Outlook do (compose-p2-8). j/k still read it. + // Mountain View and Scheduled keep their own ways. + onEmailSelect={!mvShell && !isScheduledView && !isEmbedded ? (email) => { + if (email.keywords?.$draft) void handleEditDraft(email).catch(reportDraftOpenFailure); + else void handleEmailSelect(email); + } : handleEmailSelect} onOpenSearchResult={handleEmailSelect} onEmailPrefetch={handleEmailPrefetch} onEmailDoubleClick={!isEmbedded ? (desktopDoubleClick ? (email) => { @@ -6078,6 +6186,22 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onReply={handleConversationReply} onReplyAll={handleConversationReplyAll} onForward={handleConversationForward} + onEditDraft={(draft) => { + void handleEditDraft(draft, { + accountId: conversationBlobSource?.accountId, + localAccountId: conversationBlobSource?.clientAccountId ?? activeAccountId ?? undefined, + }).catch(reportDraftOpenFailure); + }} + onDiscardDraft={(draft) => { + const before = conversationEmails; + setConversationEmails(before.filter((e) => e.id !== draft.id)); + discardWithUndo(() => { + void handleDiscardDraft(draft.id, { + accountId: conversationBlobSource?.accountId, + localAccountId: conversationBlobSource?.clientAccountId ?? activeAccountId ?? undefined, + }); + }, { discarded: tComposeUx('draft_discarded'), undo: tComposeUx('undo') }, () => setConversationEmails(before)); + }} blobSource={conversationBlobSource} onDownloadAttachment={handleDownloadConversationAttachment} onMarkAsRead={async (emailId, read) => { @@ -6200,6 +6324,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } : undefined} mvInlineComposer={mvInlineReply ? {composerNode} : undefined} replyComposerOpen={dockedReply} + replyDraft={replyDraft && selectedEmail && replyDraft.emailId === selectedEmail.id ? { + preview: replyDraft.draft.preview, + onEdit: () => void handleEditDraft(replyDraft.draft, replyDraft.source).catch(reportDraftOpenFailure), + onDiscard: discardReplyDraft, + } : null} onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined} onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined} isFullscreen={fullscreenReading} diff --git a/lib/__tests__/reply-drafts.test.ts b/lib/__tests__/reply-drafts.test.ts new file mode 100644 index 000000000..47454c9a3 --- /dev/null +++ b/lib/__tests__/reply-drafts.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { answersMessage, findReplyDraft, threadDrafts } from '@/lib/reply-drafts'; +import type { Email } from '@/lib/jmap/types'; + +const message = { id: 'm1', messageId: ['abc@example.org'] as unknown as string }; +const draft = (id: string, extra: Partial = {}) => ({ + id, threadId: 't1', keywords: { $draft: true }, receivedAt: '2026-11-02T10:00:00Z', ...extra, +}) as Email; + +describe('answersMessage', () => { + it('matches a draft whose In-Reply-To names the message, brackets or not', () => { + expect(answersMessage(draft('d1', { inReplyTo: [''] }), message)).toBe(true); + expect(answersMessage(draft('d1', { inReplyTo: ['abc@example.org'] }), { messageId: 'abc@example.org' })).toBe(true); + }); + + it('leaves out a reply to another message, and a sent message', () => { + expect(answersMessage(draft('d1', { inReplyTo: ['other@example.org'] }), message)).toBe(false); + expect(answersMessage({ inReplyTo: ['abc@example.org'], keywords: {} }, message)).toBe(false); + }); +}); + +describe('findReplyDraft', () => { + it('reads list rows without In-Reply-To in full and picks the newest that answers', async () => { + const full: Record = { + d1: draft('d1', { inReplyTo: ['abc@example.org'], receivedAt: '2026-11-01T10:00:00Z' }), + d2: draft('d2', { inReplyTo: ['abc@example.org'], receivedAt: '2026-11-03T10:00:00Z' }), + d3: draft('d3', { inReplyTo: ['other@example.org'], receivedAt: '2026-11-04T10:00:00Z' }), + }; + const getEmail = vi.fn(async (id: string) => full[id] ?? null); + const rows = Object.values(full).map(({ inReplyTo: _drop, ...row }) => row as Email); + const found = await findReplyDraft(rows, message, getEmail); + expect(found?.id).toBe('d2'); + expect(getEmail).toHaveBeenCalledWith('d3'); + expect(getEmail).not.toHaveBeenCalledWith('d1'); + }); + + it('finds nothing for a message without a Message-ID', async () => { + const getEmail = vi.fn(); + expect(await findReplyDraft([draft('d1')], { id: 'm1' }, getEmail)).toBeNull(); + expect(getEmail).not.toHaveBeenCalled(); + }); +}); + +describe('threadDrafts', () => { + it('returns the drafts of a conversation, and asks nothing more of a single message', async () => { + const client = { + getThread: vi.fn(async (id: string) => (id === 't1' ? { emailIds: ['m1', 'd1'] } : { emailIds: ['x'] })), + getSomeEmails: vi.fn(async () => [{ id: 'm1', keywords: {} }, draft('d1')] as Email[]), + }; + expect((await threadDrafts(client, 't1', 'acc')).map((e) => e.id)).toEqual(['d1']); + expect(await threadDrafts(client, 't2', 'acc')).toEqual([]); + expect(client.getSomeEmails).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/reply-drafts.ts b/lib/reply-drafts.ts new file mode 100644 index 000000000..08d2d9b33 --- /dev/null +++ b/lib/reply-drafts.ts @@ -0,0 +1,58 @@ +import type { Email } from '@/lib/jmap/types'; +import { stripMessageIdBrackets } from '@/lib/email-threading'; + +/** + * A reply saved as a draft, found from the message it answers (compose-p2-6). + * The reader marks that message with it, and Reply opens it again instead of + * starting a second draft, as Gmail and Outlook do. + */ + +/** The message's own Message-IDs, bare. JMAP sends a list; older code a string. */ +function messageIdsOf(email: Pick): string[] { + const raw = email.messageId as unknown; + const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : []; + return list.filter((id): id is string => typeof id === 'string').map(stripMessageIdBrackets).filter(Boolean); +} + +/** Whether `draft` is a reply to `email` (its In-Reply-To names it). */ +export function answersMessage(draft: Pick, email: Pick): boolean { + if (!draft.keywords?.$draft) return false; + const ids = messageIdsOf(email); + if (ids.length === 0) return false; + return (draft.inReplyTo ?? []).some((id) => ids.includes(stripMessageIdBrackets(id))); +} + +const newestFirst = (a: Pick, b: Pick) => + (b.receivedAt ?? '').localeCompare(a.receivedAt ?? ''); + +/** + * The newest of a conversation's drafts that answers `email`, in full. List + * rows carry no In-Reply-To, so a draft without it is read once more through + * `getEmail`; one that has it (a full fetch) is taken as it is. + */ +export async function findReplyDraft( + drafts: readonly Email[], + email: Pick, + getEmail: (id: string) => Promise, +): Promise { + if (messageIdsOf(email).length === 0) return null; + const candidates = drafts.filter((d) => d.id !== email.id && d.keywords?.$draft).sort(newestFirst); + for (const candidate of candidates) { + const full = candidate.inReplyTo !== undefined ? candidate : await getEmail(candidate.id).catch(() => null); + if (full && answersMessage(full, email)) return full; + } + return null; +} + +/** A conversation's drafts, read fresh (after a composer closed or a draft went). */ +export async function threadDrafts( + client: { getThread(threadId: string, accountId?: string): Promise<{ emailIds: string[] } | null>; getSomeEmails(ids: string[], accountId?: string): Promise }, + threadId: string, + accountId: string | undefined, +): Promise { + const thread = await client.getThread(threadId, accountId); + const ids = thread?.emailIds ?? []; + if (ids.length < 2) return []; + const members = await client.getSomeEmails(ids, accountId); + return members.filter((e) => e.keywords?.$draft); +} From de401b4bbf2ac0de81ec030f358a851979d3e5cc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:45:42 +0100 Subject: [PATCH 22/50] =?UTF-8?q?fix:=20show=20Sending=E2=80=A6=20and=20Se?= =?UTF-8?q?nt=20in=20pop-out=20and=20quick=20compose=20windows=20straight?= =?UTF-8?q?=20after=20Send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pop-out test for a message scheduled for later no longer expects no sending view at all: the window now says "Sending…" until it closes, so it checks that no Undo is offered there. --- .../__tests__/popout-compose-app.test.tsx | 32 +++++++++++++- components/popout/popout-compose-app.tsx | 43 ++++++++++++++++--- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 363ba58c4..737e95948 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -614,9 +614,38 @@ describe('a send held for undo', () => { expect(sent?.notice.releasesAt).toBe(sendAt); }); + // compose-24: the window sat with a greyed Send until the server answered. + it('says "Sending…" as soon as Send goes through, before the server answers', async () => { + let answer!: () => void; + useEmailStore.setState({ sendEmail: vi.fn(() => new Promise((resolve) => { answer = () => resolve({}); })) } as never); + sendAt = new Date(Date.now() + 5000).toISOString(); + window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice'); + render(); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending')); + let done: Promise = Promise.resolve(); + act(() => { done = (composer as unknown as Sending).onSend(DATA); }); + expect(screen.getByTestId('popout-sending').textContent).toMatch(/sending$/); + await act(async () => { answer(); await done; }); + }); + + it('shows the composer again with its text when the send fails', async () => { + useEmailStore.setState({ sendEmail: vi.fn(async () => { throw new Error('refused'); }) } as never); + sendAt = new Date(Date.now() + 5000).toISOString(); + window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice'); + render(); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending')); + await act(async () => { + await expect((composer as unknown as Sending).onSend(DATA)).rejects.toThrow('refused'); + }); + expect(screen.queryByTestId('popout-sending')).toBeNull(); + expect(screen.getByTestId('composer').closest('.hidden')).toBeNull(); + }); + it('closes once the undo time is over', async () => { await sendFromWindow(1.2); expect(closeSelf).not.toHaveBeenCalled(); + // As the main window's toast: "Sent" once Undo is over (compose-24). + await waitFor(() => expect(screen.getByTestId('popout-sending').textContent).toMatch(/(^|\.)sent$/), { timeout: 3000 }); await waitFor(() => expect(closeSelf).toHaveBeenCalled(), { timeout: 4000 }); }); @@ -631,7 +660,8 @@ describe('a send held for undo', () => { it('a message scheduled for later still goes to the main window, and the window closes', async () => { await sendFromWindow(3600); - expect(screen.queryByTestId('popout-sending')).toBeNull(); + // "Sending…" may still show as the window goes, but no Undo here. + expect(screen.queryByRole('button', { name: 'undo_send' })).toBeNull(); expect(closeSelf).toHaveBeenCalled(); const sent = heard.find((message) => message.type === 'sent') as Extract | undefined; expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1'); diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index b86aa9cca..52adf6bf1 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -67,6 +67,17 @@ interface ReopenTarget { ownerAccountId?: string; } +/** "Sending…" or "Sent", where the composer was, with the send icon. */ +function SendStatus({ text, children }: { text: string; children?: React.ReactNode }) { + return ( +
+
+ ); +} + /** * A send held for undo, shown in the window it was sent from: that's where the * user is looking, and quick compose is used with the main window out of sight. @@ -79,6 +90,7 @@ function SendingWithUndo({ held, onUndone, onGone }: { onGone: () => void; }) { const tViewer = useTranslations("email_viewer"); + const tSend = useTranslations("compose_send"); const [now, setNow] = useState(() => Date.now()); const [busy, setBusy] = useState(false); const sendAt = Date.parse(held.sendAt); @@ -122,17 +134,17 @@ function SendingWithUndo({ held, onUndone, onGone }: { onUndone(held.isSmime ? null : restored); }; - const seconds = Math.max(0, Math.ceil((sendAt - now) / 1000)); + const seconds = Math.max(1, Math.ceil((sendAt - now) / 1000)); + // Once Undo is over, "Sent" for the moment before the window goes, as the + // main window's toast says it. return ( -
-
+ ); } @@ -320,7 +332,11 @@ function PopoutComposer({ payload, handoffId, onReopen }: { : t("email_composer.new_message"), ); - const handleSend = useCallback(async (data: SendData) => { + // "Sending…" from the moment Send goes through, as the main window says it + // (compose-24); the composer waited greyed out for the server before. It + // stays mounted behind, and comes back with the text if the send fails. + const [sending, setSending] = useState(false); + const sendFromWindow = useCallback(async (data: SendData) => { if (!client) return false; const sendClient = data.localAccountId ? (useAuthStore.getState().getClientForAccount(data.localAccountId) ?? client) @@ -386,6 +402,16 @@ function PopoutComposer({ payload, handoffId, onReopen }: { await new Promise((resolve) => setTimeout(resolve, WARNING_READ_MS)); } }, [client, mode, payload.sourceEmailId, payload.sourceEmailSource, t]); + const handleSend = useCallback(async (data: SendData) => { + if (!client) return false; + if (!data.delayedUntil) setSending(true); + try { + return await sendFromWindow(data); + } catch (error) { + setSending(false); + throw error; + } + }, [client, sendFromWindow]); const handleDiscardDraft = useCallback(async (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => { if (!client) return; @@ -507,6 +533,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: { if (held) return ; return ( + <> + {sending && } +
+
+ ); } From 78c9d9fba92db0bcae2ff040d42bf14f70f109f5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:50:12 +0100 Subject: [PATCH 23/50] feat: tell folded folders apart, show their unread mail, and float out the whole tree on hover or from All folders --- .../__tests__/sidebar-folded-column.test.tsx | 124 +++++++++++++++++ components/layout/sidebar.tsx | 126 +++++++++++++++++- 2 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 components/layout/__tests__/sidebar-folded-column.test.tsx diff --git a/components/layout/__tests__/sidebar-folded-column.test.tsx b/components/layout/__tests__/sidebar-folded-column.test.tsx new file mode 100644 index 000000000..02c0f30b2 --- /dev/null +++ b/components/layout/__tests__/sidebar-folded-column.test.tsx @@ -0,0 +1,124 @@ +import { act, render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; + +// Usability test 2026-11-04, pass 2 p2-9: folded to icons (600 to 1024 px), +// the folder column showed eight identical folder icons, no unread marks, +// and no way to reach subfolders without unfolding it. + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/contexts/drag-drop-context', () => ({ + useDragDropContext: () => ({ isDragging: false }), +})); +vi.mock('@/hooks/use-mailbox-drop', () => ({ + useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }), +})); +vi.mock('@/hooks/use-tag-drop', () => ({ + useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }), +})); +vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); + +const { Sidebar } = await import('../sidebar'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { useUIStore } = await import('@/stores/ui-store'); + +const box = (id: string, name: string, role: string | null, extra: Partial = {}) => ({ + id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, ...extra, +}) as unknown as Mailbox; + +const mailboxes: Mailbox[] = [ + box('inbox', 'Inbox', 'inbox', { totalEmails: 120, unreadEmails: 4 }), + box('trash', 'Trash', 'trash'), + box('news', 'Newsletters', null, { totalEmails: 30, unreadEmails: 2 }), + box('receipts', 'Receipts', null, { totalEmails: 5 }), + box('y2026', '2026', null, { parentId: 'receipts', totalEmails: 3 }), +]; + +const peek = () => document.querySelector('[data-folder-peek]'); + +describe('folded folder column (default layout)', () => { + beforeEach(() => { + localStorage.clear(); + useAuthStore.setState({ activeAccountId: 'login-a', client: { getAccountId: () => 'me', getCapabilities: () => ({}) } } as never); + useSettingsStore.setState({ hideAccountSwitcher: true, interfaceLayout: 'default', openOnHover: true, unreadCountStyle: 'count' } as never); + useUIStore.setState({ sidebarCollapsed: true } as never); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + useUIStore.setState({ sidebarCollapsed: false } as never); + }); + + it('tells folders of one\'s own apart by their first letter', () => { + render(); + const initials = screen.getAllByTestId('folder-initial').map((el) => el.textContent); + expect(initials).toEqual(expect.arrayContaining(['N', 'R'])); + // System folders keep their own icons. + expect(initials).toHaveLength(2); + }); + + it('shows the unread count on the icon', () => { + render(); + const inbox = document.querySelector('[data-mailbox-id="inbox"]')!; + expect(inbox.querySelector('[data-testid="folded-unread"]')?.textContent).toBe('4'); + const trash = document.querySelector('[data-mailbox-id="trash"]')!; + expect(trash.querySelector('[data-testid="folded-unread"]')).toBeNull(); + }); + + it('shows a dot when counts are dots', () => { + useSettingsStore.setState({ unreadCountStyle: 'dot' } as never); + render(); + const mark = document.querySelector('[data-mailbox-id="news"] [data-testid="folded-unread"]'); + expect(mark).not.toBeNull(); + expect(mark!.textContent).toBe(''); + }); + + it('opens the whole tree from All folders, keyboard included, and Escape folds it back', () => { + render(); + expect(screen.queryByText('Receipts')).toBeNull(); + fireEvent.click(screen.getByTestId('folded-all-folders')); + expect(peek()).not.toBeNull(); + expect(screen.getByText('Receipts')).toBeTruthy(); + // The focus is in the tree, on the open folder. + expect(document.activeElement?.closest('[data-mailbox-id]')?.getAttribute('data-mailbox-id')).toBe('inbox'); + // Subfolders are reachable: Receipts > 2026. + expect(screen.getByText('2026')).toBeTruthy(); + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + expect(peek()).toBeNull(); + }); + + it('folds the tree away after a pick', () => { + const { rerender } = render(); + fireEvent.click(screen.getByTestId('folded-all-folders')); + expect(peek()).not.toBeNull(); + rerender(); + expect(peek()).toBeNull(); + }); + + it('folds it away on a press outside', () => { + render(<>); + fireEvent.click(screen.getByTestId('folded-all-folders')); + fireEvent.pointerDown(screen.getByText('elsewhere')); + expect(peek()).toBeNull(); + }); + + it('floats out after the pointer rests on it, not at once, and not with hover opening off', () => { + vi.useFakeTimers(); + const { container } = render(); + fireEvent.mouseEnter(container.firstElementChild!); + expect(peek()).toBeNull(); + act(() => { vi.advanceTimersByTime(400); }); + expect(peek()).not.toBeNull(); + fireEvent.mouseLeave(container.firstElementChild!); + expect(peek()).toBeNull(); + + useSettingsStore.setState({ openOnHover: false } as never); + fireEvent.mouseEnter(container.firstElementChild!); + act(() => { vi.advanceTimersByTime(400); }); + expect(peek()).toBeNull(); + }); +}); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index aaf3672e5..64ede6c8f 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -22,6 +22,7 @@ import { ChevronUp, Folder, FolderOpen, + FolderSync, User, Users, Palmtree, @@ -171,6 +172,8 @@ interface SidebarProps { const ROW_PX_BASE = FOLDER_ROW_PX_BASE; const CHEVRON_SLOT = FOLDER_CHEVRON_SLOT; const INDENT_STEP = 12; +/** How long the pointer rests on the folded column before the tree floats out. */ +const RAIL_HOVER_DELAY_MS = 350; // Icons and colours follow the JMAP role only. const getIconForMailbox = (role?: string, hasChildren?: boolean, isExpanded?: boolean, id?: string) => { @@ -496,8 +499,22 @@ function SidebarRow({ )} title={isCollapsed ? label : undefined} > - + {icon} + {/* Folded to icons, the count rides on the icon (p2-9); the name says it already. */} + {isCollapsed && unreadForName > 0 && ( + unreadCountStyle === 'dot' ? ( + {!isCollapsed && ( <> @@ -547,6 +564,20 @@ function SidebarRow({ ); } +/** A folder of one's own on the folded strip: its first letter in a small box. */ +function FolderInitial({ name, className }: { name: string; className?: string }) { + const initial = Array.from(name.trim())[0]?.toLocaleUpperCase() ?? '?'; + return ( + + ); +} + /** A folder the server gave a role (inbox, sent, trash...), as opposed to one the user made. */ const isSystemFolder = (node: MailboxNode) => !!node.role; /** Key of the single-account "Your folders" section in the collapsed set. */ @@ -708,6 +739,9 @@ function MailboxTreeItem({ const isVirtualNode = node.id.startsWith('shared-'); const isSelected = selectedMailbox === node.id; const label = sharedAccountName ?? localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); + // Folded to icons, eight folders of one's own were eight identical folder + // icons (p2-9): each shows its first letter instead. + const letterIcon = isCollapsed && !node.role && !sharedAccountName && !isVirtualNode; // Only for the active login's folders: its client tells a missing // accountId (own mailbox) apart. const muted = useMutedMailbox(isVirtualNode || dragAccountId || pickOnly ? null : node); @@ -751,7 +785,9 @@ function MailboxTreeItem({ return ( <> } + icon={letterIcon + ? + : } label={label} testRole={node.role} testName={node.name} @@ -1143,8 +1179,23 @@ function SidebarImpl({ // floats rather than widening the column, or every hover would reflow the // list behind it. const [railHovered, setRailHovered] = useState(false); - const hoverExpanded = mvShell && sidebarCollapsed && railHovered; + // The default layout's folded column floats the whole tree out too, so + // subfolders and folder names are a hover or a click away (p2-9). A hover + // waits a moment there, so passing over the column on the way to the list + // doesn't open it; "All folders" opens it by click, tap or keyboard. + const [railOpen, setRailOpen] = useState(false); + const railHoverTimer = useRef | null>(null); + const rootRef = useRef(null); + const allFoldersRef = useRef(null); + const hoverExpanded = sidebarCollapsed && !isDrawer && (railHovered || railOpen); const isCollapsed = sidebarCollapsed && !hoverExpanded && !isDrawer; + const closeRailPeek = useCallback(() => { + if (railHoverTimer.current) clearTimeout(railHoverTimer.current); + railHoverTimer.current = null; + setRailHovered(false); + setRailOpen(false); + }, []); + useEffect(() => () => { if (railHoverTimer.current) clearTimeout(railHoverTimer.current); }, []); const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId }))); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [expandedTags, setExpandedTags] = useState>(new Set()); @@ -1242,6 +1293,31 @@ function SidebarImpl({ crossAccountActive, restoring: restoringAccounts, scope: unifiedScope, accounts, }), [crossAccountActive, restoringAccounts, accounts, unifiedScope]); const t = useTranslations('sidebar'); + const tP2 = useTranslations('mail_p2_folders'); + + // A pick in the floated tree folds it away again (the hover peek of the + // default layout, and the one "All folders" opened). + const pickedRef = useRef({ mailbox: selectedMailbox, keyword: selectedKeyword }); + useEffect(() => { + const before = pickedRef.current; + pickedRef.current = { mailbox: selectedMailbox, keyword: selectedKeyword }; + if (before.mailbox === selectedMailbox && before.keyword === selectedKeyword) return; + if (railOpen || !mvShell) closeRailPeek(); + }, [selectedMailbox, selectedKeyword, railOpen, mvShell, closeRailPeek]); + // Opened from "All folders": the focus goes to the open folder in the tree, + // and a press anywhere outside folds it away. + useEffect(() => { + if (!railOpen) return; + const root = rootRef.current; + const target = root?.querySelector('nav [aria-current="page"]') + ?? root?.querySelector('nav button:not([disabled])'); + target?.focus(); + const onPointerDown = (e: PointerEvent) => { + if (root && !root.contains(e.target as Node)) closeRailPeek(); + }; + document.addEventListener('pointerdown', onPointerDown, true); + return () => document.removeEventListener('pointerdown', onPointerDown, true); + }, [railOpen, closeRailPeek]); useEffect(() => { const stored = localStorage.getItem('expandedMailboxes'); @@ -1868,10 +1944,32 @@ function SidebarImpl({ return (
{ if (hoverOpens()) setRailHovered(true); } : undefined} - onMouseLeave={mvShell && sidebarCollapsed ? () => setRailHovered(false) : undefined} - data-mv-rail-peek={hoverExpanded ? "" : undefined} + onMouseEnter={sidebarCollapsed && !isDrawer ? () => { + if (!hoverOpens()) return; + if (mvShell) { setRailHovered(true); return; } + if (railHoverTimer.current) clearTimeout(railHoverTimer.current); + railHoverTimer.current = setTimeout(() => setRailHovered(true), RAIL_HOVER_DELAY_MS); + } : undefined} + onMouseLeave={sidebarCollapsed && !isDrawer ? () => { + if (railHoverTimer.current) clearTimeout(railHoverTimer.current); + railHoverTimer.current = null; + setRailHovered(false); + } : undefined} + onKeyDown={railOpen || railHovered ? (e) => { + if (e.key !== 'Escape' || e.defaultPrevented) return; + e.preventDefault(); + const hadFocus = rootRef.current?.contains(document.activeElement); + closeRailPeek(); + if (hadFocus) requestAnimationFrame(() => allFoldersRef.current?.focus()); + } : undefined} + onBlur={railOpen ? (e) => { + const next = e.relatedTarget as Node | null; + if (next && !e.currentTarget.contains(next)) closeRailPeek(); + } : undefined} + data-mv-rail-peek={hoverExpanded && mvShell ? "" : undefined} + data-folder-peek={hoverExpanded && !mvShell ? "" : undefined} className={cn( "relative flex flex-col h-full border-e transition-all duration-300 overflow-hidden", // Mountain View's navigation sits on the canvas, with no rule between @@ -1880,7 +1978,7 @@ function SidebarImpl({ "max-desk:w-full", // Mountain View's collapsed column is 72px (--mv-rail-w), wide enough // for its round compose button; the default layout's rail is 48px. - isCollapsed && !mvShell ? "desk:w-12" : "desk:w-full", + isCollapsed && !mvShell ? "desk:w-12" : hoverExpanded && !mvShell ? "desk:w-64" : "desk:w-full", // Floats out of the rail's own column rather than widening it, so the // message list underneath never moves while the pointer passes over. // Width comes from --mv-rail-peek-w, keyed on data-mv-rail-peek. @@ -2254,6 +2352,20 @@ function SidebarImpl({ {!isCollapsed && } + {/* Folded: the whole tree, subfolders included, one click away (p2-9). */} + {isCollapsed && !mvShell && ( +
+ setRailOpen(true)} + > + + +
+ )} + Date: Wed, 4 Nov 2026 11:51:47 +0100 Subject: [PATCH 24/50] fix: say when a message needs a connection in a browser that keeps no mail offline --- components/email/reader-open-failure.tsx | 24 +++ .../__tests__/mail-app-open-offline.test.tsx | 179 ++++++++++++++++++ components/mail/mail-app.tsx | 50 ++++- .../offline-banner-no-store.test.tsx | 48 +++++ components/offline/offline-banner.tsx | 20 ++ lib/__tests__/offline-key-guard.test.ts | 26 +++ lib/offline-key-guard.ts | 28 +++ 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/vi/common.json | 5 + locales/zh-TW/common.json | 5 + locales/zh/common.json | 5 + 37 files changed, 520 insertions(+), 5 deletions(-) create mode 100644 components/email/reader-open-failure.tsx create mode 100644 components/mail/__tests__/mail-app-open-offline.test.tsx create mode 100644 components/offline/__tests__/offline-banner-no-store.test.tsx create mode 100644 lib/__tests__/offline-key-guard.test.ts create mode 100644 lib/offline-key-guard.ts diff --git a/components/email/reader-open-failure.tsx b/components/email/reader-open-failure.tsx new file mode 100644 index 000000000..598ab25fd --- /dev/null +++ b/components/email/reader-open-failure.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { CloudOff, RefreshCw } from "@/components/icons"; +import { Button } from "@/components/ui/button"; + +/** + * The reading pane when a message couldn't be fetched: offline (in a browser + * that keeps no mail on the device), or another failure. A short line and + * Retry, instead of an empty pane that says nothing. + */ +export function ReaderOpenFailure({ offline, onRetry }: { offline: boolean; onRetry: () => void }) { + const t = useTranslations("mail_p2_desk"); + return ( +
+
+ ); +} diff --git a/components/mail/__tests__/mail-app-open-offline.test.tsx b/components/mail/__tests__/mail-app-open-offline.test.tsx new file mode 100644 index 000000000..34699ce66 --- /dev/null +++ b/components/mail/__tests__/mail-app-open-offline.test.tsx @@ -0,0 +1,179 @@ +import React, { Suspense, lazy } from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useToastStore } from '@/stores/toast-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { clearAllHooks } from '@/lib/plugin-hooks'; +import { dispatchApiCall } from '@/lib/plugin-sandbox/host-api'; +import { resetOpenEmail } from '@/lib/plugin-sandbox/open-email'; +import type { InstalledPlugin } from '@/lib/plugin-types'; + +// pass-2 mail-p2-7: in a browser tab that keeps no mail on the device, a click +// on a message while offline left "No conversation selected" and the keys did +// nothing. The reader now says the message needs a connection, with Retry, and +// a key that changes mail says you're offline. The message is opened the way a +// plugin opens one, which goes through the same open as a click. + +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: (props: { email: unknown; emptyPaneContent?: React.ReactNode }) => (props.email ?
reader
:
{props.emptyPaneContent ?? 'empty pane'}
), +})); +vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +function mailbox(id: string, role: string | undefined, accountId: string, shared = false): Mailbox { + return { + id: shared ? `${accountId}:${id}` : id, originalId: id, name: role ?? id, role, accountId, isShared: shared, + sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, + } as Mailbox; +} + +// The active login's folders: its own account `cf` and the group `ce`. +const folders = [mailbox('a', 'inbox', 'cf'), mailbox('a', 'inbox', 'ce', true)]; + +// The group's Inbox message, as Email/get gives it. +const groupMessage = { + id: 'eaaaaab', threadId: 'c', subject: 'GRP one', receivedAt: '2026-10-26T10:00:00Z', + keywords: { $seen: true }, mailboxIds: { a: true }, bodyValues: {}, +} as unknown as Email; + +let client: Record; +const plugin = { + id: 'p1', name: 'Plugin One', version: '1.0.0', author: '', description: '', type: 'hook', + permissions: ['email:read'], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, managed: true, +} as InstalledPlugin; + +/** The group account has `eaaaaab`, the own account nothing. */ +let failing = true; +const getEmail = vi.fn(async (id: string, accountId?: string) => { + // Offline the real client logs the failure and answers with no message. + if (failing && navigator.onLine === false) return null; + if (failing) throw new TypeError('Failed to fetch'); + return id === 'eaaaaab' && accountId === 'ce' ? { ...groupMessage } : null; +}); + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + useToastStore.setState({ toasts: [] }); + clearAllHooks(); + resetOpenEmail(); + getEmail.mockClear(); + client = { + getAccountId: () => 'cf', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + getMailboxes: vi.fn().mockResolvedValue(folders), + getEmail, + getSharedAccounts: () => ['cf', 'ce'].map((id) => ({ id, name: id, isPrimary: id === 'cf', capabilities: { mail: true } })), + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [], + getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', archiveMode: 'single', deleteAction: 'trash' } as never); + useEmailStore.setState({ + emails: [], selectedEmail: null, selectedMailbox: 'a', selectedKeyword: null, mailboxes: folders, + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', + selectedEmailKeys: new Set(), + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const setOnline = (online: boolean) => { + Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online }); + window.dispatchEvent(new Event(online ? 'online' : 'offline')); +}; + +describe('a message that cannot be fetched', () => { + afterEach(() => setOnline(true)); + + it('says offline that it needs a connection, and Retry opens it once back online', async () => { + failing = true; + render(); + await screen.findByText('message list', {}, { timeout: 5000 }); + act(() => setOnline(false)); + await act(async () => { + await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {}); + }); + expect(await screen.findByText('reader_offline')).toBeTruthy(); + + failing = false; + act(() => setOnline(true)); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'retry' })); + await new Promise((r) => setTimeout(r, 20)); + }); + expect(await screen.findByText('reader')).toBeTruthy(); + expect(screen.queryByText('reader_offline')).toBeNull(); + }); + + it('says it could not be loaded when online', async () => { + failing = true; + render(); + await screen.findByText('message list', {}, { timeout: 5000 }); + await act(async () => { + await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {}); + }); + expect(await screen.findByText('reader_failed')).toBeTruthy(); + }); + + it('answers a key that changes mail with a note that it is offline', async () => { + render(); + await screen.findByText('message list', {}, { timeout: 5000 }); + act(() => setOnline(false)); + failing = true; + await act(async () => { + await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {}); + }); + await screen.findByText('reader_offline'); + // No Archive folder here: Delete stands in for every key that changes mail. + fireEvent.keyDown(document.body, { key: '#', code: 'Digit3', shiftKey: true }); + expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('action_offline'); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index bdc888cfc..7da957b92 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -57,7 +57,9 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin, canEnterLoginInP import { useRouter } from '@/i18n/navigation'; import { useSettingsStore } from "@/stores/settings-store"; import { useDrawnMailLayout } from "@/hooks/use-effective-mail-layout"; -import { useOfflineStore } from "@/lib/offline/state"; +import { isOfflineNow, useOfflineStore } from "@/lib/offline/state"; +import { useConnectionState } from "@/hooks/use-connection-state"; +import { ReaderOpenFailure } from "@/components/email/reader-open-failure"; import { useContactStore } from "@/stores/contact-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useUIStore, sidebarFolded, listPaneCssSize, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX, READING_PANE_MIN_WIDTH, READING_PANE_MIN_HEIGHT, COMPOSER_PANE_MIN_HEIGHT } from "@/stores/ui-store"; @@ -65,6 +67,7 @@ import { atLeast, PHONE_BELOW, useDeviceDetection } from "@/hooks/use-media-quer import { usePaneSize } from "@/hooks/use-pane-size"; import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context"; import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts"; +import { guardOfflineMailChanges } from "@/lib/offline-key-guard"; import { useDesktopCommands } from "@/lib/desktop/commands"; import { useDesktopPrefs } from "@/lib/desktop/preferences"; import { hasDesktopWindows, popoutsAvailable } from "@/lib/popout/support"; @@ -257,6 +260,11 @@ const UNIFIED_LIST_PUSH = { /** A draft from outside: a mailto:, or a share with files. ParsedMailto stays JSON, for sessionStorage. */ type DraftRequest = ParsedMailto & { initialFiles?: File[]; unedited?: boolean }; +/** No network, or this login's mail server can't be reached. */ +function offlineNow(client: { getServerUrl?: () => string }): boolean { + return (typeof navigator !== 'undefined' && navigator.onLine === false) || isOfflineNow(client.getServerUrl?.()); +} + export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Static Lite build: the route params are empty, read the link from the URL. const linkSegments = useLiteLinkSegments('mail', routeSegments); @@ -324,6 +332,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [showShortcutsModal, setShowShortcutsModal] = useState(false); + // A message that couldn't be fetched: the reader says so, with Retry (mail-p2-7). + const [openFailure, setOpenFailure] = useState<{ target: { id: string } & Partial>; offline: boolean } | null>(null); + const tP2Desk = useTranslations('mail_p2_desk'); // The message the palette's "Move to…" was picked for. const [moveDialogEmail, setMoveDialogEmail] = useState(null); // The empty reading pane's slot for a selection; the list draws into it (mail-10). @@ -1306,14 +1317,27 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return state.tabs.find((tab) => tab.id === id)?.kind === 'calendar'; }); const mailShortcutsApply = !isEmbedded || isFocusedProTab; + // Offline with no mail kept on this device, a key that changes mail can't + // run: say so rather than do nothing (mail-p2-7). + const connection = useConnectionState(); + const canChangeMailRef = useRef(connection.canChangeMail); + canChangeMailRef.current = connection.canChangeMail; + const shortcutHandlers = useMemo( + () => guardOfflineMailChanges(keyboardHandlers, () => canChangeMailRef.current, () => toast.info(tP2Desk('action_offline'))), + // eslint-disable-next-line react-hooks/exhaustive-deps -- tP2Desk is stable per locale + [keyboardHandlers], + ); + // Another folder: the line about the message that didn't open goes. + useEffect(() => setOpenFailure(null), [selectedMailbox]); useKeyboardShortcuts({ enabled: isAuthenticated && !showComposer && mailShortcutsApply, singleKeyShortcuts, emails: activeEmails, - selectedEmailId: selectedEmail?.id, + // The message that didn't open is still what the keys are about. + selectedEmailId: selectedEmail?.id ?? openFailure?.target.id, selectionCount: selectedEmailKeys.size, mountainViewKeys: mvShell, - handlers: keyboardHandlers, + handlers: shortcutHandlers, }); const anyTabHandlers = useMemo(() => ({ onShowHelp: keyboardHandlers.onShowHelp, @@ -4953,6 +4977,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { ): Promise => { if (!client || !email) return new Error('the mail page is not ready'); setCursorOnly(false); + setOpenFailure(null); // If composing, close the composer the way its X does: it keeps the // draft and says so. Mountain View's compose window stays open while @@ -5077,11 +5102,24 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Mark-as-read logic is now handled by useEffect return 'opened'; } + // The client answers a failed fetch with no message. Offline that + // says nothing about the message: the reader says it needs a connection. + if (offlineNow(client)) { + abandonOpenEmail(null); + setOpenFailure({ target: email, offline: true }); + return new Error('offline'); + } abandonOpenEmail(previousEmail); return 'missing'; } catch (error) { console.error('Failed to fetch email content:', error); - if (openSeq === emailOpenSeqRef.current) abandonOpenEmail(previousEmail); + if (openSeq === emailOpenSeqRef.current) { + // Offline the reader says why instead of going blank or back to the + // last message; another failure says so too when there's nothing to go back to. + const offline = offlineNow(client); + abandonOpenEmail(offline ? null : previousEmail); + if (offline || !previousEmail) setOpenFailure({ target: email, offline }); + } return error instanceof Error ? error : new Error(String(error)); } }; @@ -6217,7 +6255,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }} emptyPaneContent={emptyPaneBesideList && selectedEmailKeys.size > 0 && !isScheduledView && !mvShell ?
- : undefined} + : openFailure && !isLoadingEmail + ? void handleEmailSelect(openFailure.target)} /> + : undefined} emptyPaneFooter={emptyPaneBesideList ? : undefined} currentUserEmail={client?.getUsername()} currentUserName={client?.getUsername()?.split("@")[0]} diff --git a/components/offline/__tests__/offline-banner-no-store.test.tsx b/components/offline/__tests__/offline-banner-no-store.test.tsx new file mode 100644 index 000000000..ea6f40aa3 --- /dev/null +++ b/components/offline/__tests__/offline-banner-no-store.test.tsx @@ -0,0 +1,48 @@ +import { act, cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { OfflineBanner } from '../offline-banner'; +import { useAuthStore } from '@/stores/auth-store'; +import { useOfflineStore } from '@/lib/offline/state'; + +// pass-2 mail-p2-7: in a browser tab that keeps no mail on the device, going +// offline showed no bar anywhere; a click on a message then did nothing. + +const setOnline = (online: boolean) => { + Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online }); + window.dispatchEvent(new Event(online ? 'online' : 'offline')); +}; + +beforeEach(() => { + useAuthStore.setState({ activeAccountId: 'acc', serverUrl: 'https://mail.example.org' }); + useOfflineStore.setState({ enabled: [], outbox: [], conflicts: [], areaConflicts: [], localDrafts: [], browserOffline: false, downServers: [], startedOffline: false }); +}); + +afterEach(() => { + cleanup(); + setOnline(true); +}); + +describe('the offline bar for an account that keeps no mail here', () => { + it('says that reading and changing mail needs a connection, and goes once online', () => { + render(); + expect(screen.queryByTestId('offline-banner-no-store')).toBeNull(); + act(() => setOnline(false)); + expect(screen.getByTestId('offline-banner-no-store').textContent).toBe('offline_bar'); + act(() => setOnline(true)); + expect(screen.queryByTestId('offline-banner-no-store')).toBeNull(); + }); + + it('leaves an account with offline mode its own bar', () => { + useOfflineStore.setState({ enabled: ['acc'], browserOffline: true }); + render(); + expect(screen.queryByTestId('offline-banner-no-store')).toBeNull(); + expect(screen.getByTestId('offline-banner')).toBeTruthy(); + }); + + it('shows nothing when signed out', () => { + useAuthStore.setState({ activeAccountId: null }); + act(() => setOnline(false)); + render(); + expect(screen.queryByTestId('offline-banner-no-store')).toBeNull(); + }); +}); diff --git a/components/offline/offline-banner.tsx b/components/offline/offline-banner.tsx index 4efe20b44..d3a678980 100644 --- a/components/offline/offline-banner.tsx +++ b/components/offline/offline-banner.tsx @@ -6,6 +6,7 @@ import { CloudOff, X } from '@/components/icons'; import { Dialog } from '@/components/ui/dialog'; import { IconButton } from '@/components/ui/icon-button'; import { useCloseBesideControls } from '@/hooks/use-close-beside-controls'; +import { useConnectionState } from '@/hooks/use-connection-state'; import { IS_LITE } from '@/lib/lite'; import { serverKey, useOfflineStore } from '@/lib/offline/state'; import { useAuthStore } from '@/stores/auth-store'; @@ -20,6 +21,9 @@ import { OutboxList } from './outbox-list'; * * It can be hidden until what it says changes (going offline again, another * message waiting), for this session of the window. + * + * An account that keeps no mail here gets a one-line bar while offline too, + * so a message that doesn't open isn't a mystery (mail-p2-7). */ const HIDDEN_KEY = 'bulwark.offlineBanner.hidden'; @@ -49,7 +53,23 @@ export function OfflineBanner() { const [hidden, setHidden] = useState(hiddenFor); const [banner, setBanner] = useState(null); const closeFirst = useCloseBesideControls(banner); + const connection = useConnectionState(); + const tP2 = useTranslations('mail_p2_desk'); const offline = browserOffline || serverDown; + if (!active) { + if (!account || !connection.offline) return null; + return ( +
+
+ ); + } const says = `${browserOffline ? 'offline' : serverDown ? 'down' : 'online'}|${waiting}|${conflicts}|${waitingDrafts}|${refusedDrafts}`; if (IS_LITE || !active || (!offline && waiting === 0 && conflicts === 0 && refusedDrafts === 0 && waitingDrafts === 0 && !open)) return null; const hide = () => { diff --git a/lib/__tests__/offline-key-guard.test.ts b/lib/__tests__/offline-key-guard.test.ts new file mode 100644 index 000000000..46319a5a0 --- /dev/null +++ b/lib/__tests__/offline-key-guard.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { guardOfflineMailChanges } from '../offline-key-guard'; + +describe('keys that change mail, offline', () => { + it('answer with the refusal instead of running, and run again once they can', () => { + let can = false; + const archive = vi.fn(); + const next = vi.fn(); + const refused = vi.fn(); + const handlers = guardOfflineMailChanges({ onArchive: archive, onNextEmail: next }, () => can, refused); + handlers.onArchive(); + expect(archive).not.toHaveBeenCalled(); + expect(refused).toHaveBeenCalledTimes(1); + handlers.onNextEmail(); + expect(next).toHaveBeenCalledTimes(1); + can = true; + handlers.onArchive(); + expect(archive).toHaveBeenCalledTimes(1); + expect(refused).toHaveBeenCalledTimes(1); + }); + + it('leaves a key with no handler without one', () => { + const handlers = guardOfflineMailChanges({ onArchive: undefined as (() => void) | undefined }, () => false, vi.fn()); + expect(handlers.onArchive).toBeUndefined(); + }); +}); diff --git a/lib/offline-key-guard.ts b/lib/offline-key-guard.ts new file mode 100644 index 000000000..349318fad --- /dev/null +++ b/lib/offline-key-guard.ts @@ -0,0 +1,28 @@ +/** + * Keys that change mail on the server: archive, delete, read/unread, spam, + * star. Offline with no mail kept on the device they can't run, and before + * this they did nothing at all. + */ +export const MAIL_CHANGE_SHORTCUTS = ['onArchive', 'onDelete', 'onMarkAsRead', 'onMarkAsUnread', 'onToggleSpam', 'onToggleStar'] as const; + +type Handler = (...args: never[]) => unknown; + +/** + * The same handlers, with those keys answering `onRefused` instead while + * `canChangeMail()` is false. A key with no handler stays without one. + */ +export function guardOfflineMailChanges(handlers: T, canChangeMail: () => boolean, onRefused: () => void): T { + const guarded = { ...handlers } as Record; + for (const key of MAIL_CHANGE_SHORTCUTS) { + const handler = (handlers as Record)[key] as Handler | undefined; + if (typeof handler !== 'function') continue; + guarded[key] = (...args: never[]) => { + if (!canChangeMail()) { + onRefused(); + return undefined; + } + return handler(...args); + }; + } + return guarded as T; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 9df4371b6..1e8a162d3 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "البحث في البريد عن «{text}»" }, "mail_p2_desk": { + "reader_offline": "أنت غير متصل. تحتاج هذه الرسالة إلى اتصال لفتحها.", + "reader_failed": "تعذّر تحميل هذه الرسالة.", + "retry": "إعادة المحاولة", + "action_offline": "أنت غير متصل. يحتاج هذا إلى اتصال.", + "offline_bar": "أنت غير متصل. قراءة البريد وتغييره يحتاجان إلى اتصال.", "offline_default_on": "يُحفظ بريدك على هذا الجهاز، لذا يمكنك قراءته دون اتصال." }, "desktopMenuBar": { diff --git a/locales/ca/common.json b/locales/ca/common.json index eba62dfa2..4d6cb97fa 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Cerca «{text}» al correu" }, "mail_p2_desk": { + "reader_offline": "No tens connexió. Aquest missatge necessita connexió per obrir-se.", + "reader_failed": "No s'ha pogut carregar aquest missatge.", + "retry": "Torna-ho a provar", + "action_offline": "No tens connexió. Això necessita connexió.", + "offline_bar": "No tens connexió. Llegir i canviar el correu necessita connexió.", "offline_default_on": "El teu correu es guarda en aquest dispositiu, així el pots llegir sense connexió." }, "desktopMenuBar": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 7739d6271..e39106788 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Hledat v poště „{text}“" }, "mail_p2_desk": { + "reader_offline": "Jste offline. Tato zpráva se otevře jen s připojením.", + "reader_failed": "Tuto zprávu se nepodařilo načíst.", + "retry": "Zkusit znovu", + "action_offline": "Jste offline. K tomu je potřeba připojení.", + "offline_bar": "Jste offline. Ke čtení a úpravám pošty je potřeba připojení.", "offline_default_on": "Vaše pošta je uložena v tomto zařízení, takže ji můžete číst offline." }, "desktopMenuBar": { diff --git a/locales/da/common.json b/locales/da/common.json index abc9910fb..98e387fea 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Søg efter “{text}” i mail" }, "mail_p2_desk": { + "reader_offline": "Du er offline. Denne besked kræver forbindelse for at åbne.", + "reader_failed": "Beskeden kunne ikke indlæses.", + "retry": "Prøv igen", + "action_offline": "Du er offline. Det kræver forbindelse.", + "offline_bar": "Du er offline. Læsning og ændring af mail kræver forbindelse.", "offline_default_on": "Din mail gemmes på denne enhed, så du kan læse den offline." }, "desktopMenuBar": { diff --git a/locales/de/common.json b/locales/de/common.json index db3b71831..88be04c5c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "E-Mails nach „{text}“ durchsuchen" }, "mail_p2_desk": { + "reader_offline": "Sie sind offline. Diese Nachricht lässt sich nur mit Verbindung öffnen.", + "reader_failed": "Diese Nachricht konnte nicht geladen werden.", + "retry": "Erneut versuchen", + "action_offline": "Sie sind offline. Dafür braucht es eine Verbindung.", + "offline_bar": "Sie sind offline. Zum Lesen und Ändern von E-Mails braucht es eine Verbindung.", "offline_default_on": "Ihre E-Mails werden auf diesem Gerät gespeichert, damit Sie sie auch offline lesen können." }, "desktopMenuBar": { diff --git a/locales/en/common.json b/locales/en/common.json index 677699b97..e3cc8a519 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Search mail for \"{text}\"" }, "mail_p2_desk": { + "reader_offline": "You're offline. This message needs a connection to open.", + "reader_failed": "This message couldn't be loaded.", + "retry": "Try again", + "action_offline": "You're offline. This needs a connection.", + "offline_bar": "You're offline. Reading and changing mail needs a connection.", "offline_default_on": "Your mail is kept on this device, so you can read it offline." }, "desktopMenuBar": { diff --git a/locales/es/common.json b/locales/es/common.json index 4a2f12207..072f6cf61 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Buscar «{text}» en el correo" }, "mail_p2_desk": { + "reader_offline": "No tienes conexión. Este mensaje necesita conexión para abrirse.", + "reader_failed": "No se ha podido cargar este mensaje.", + "retry": "Reintentar", + "action_offline": "No tienes conexión. Esto necesita conexión.", + "offline_bar": "No tienes conexión. Leer y cambiar el correo necesita conexión.", "offline_default_on": "Tu correo se guarda en este dispositivo, así puedes leerlo sin conexión." }, "desktopMenuBar": { diff --git a/locales/et/common.json b/locales/et/common.json index ec50dc87b..f7353450e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Otsi kirjadest „{text}”" }, "mail_p2_desk": { + "reader_offline": "Oled võrguühenduseta. Selle kirja avamiseks on vaja ühendust.", + "reader_failed": "Seda kirja ei saanud laadida.", + "retry": "Proovi uuesti", + "action_offline": "Oled võrguühenduseta. Selleks on vaja ühendust.", + "offline_bar": "Oled võrguühenduseta. Kirjade lugemiseks ja muutmiseks on vaja ühendust.", "offline_default_on": "Sinu kirjad hoitakse selles seadmes, nii et saad neid ka ühenduseta lugeda." }, "desktopMenuBar": { diff --git a/locales/fa/common.json b/locales/fa/common.json index 7c74c1919..93f48d9bb 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "جستجوی «{text}» در نامه‌ها" }, "mail_p2_desk": { + "reader_offline": "آفلاین هستید. برای باز کردن این پیام اتصال لازم است.", + "reader_failed": "این پیام بارگیری نشد.", + "retry": "تلاش دوباره", + "action_offline": "آفلاین هستید. برای این کار اتصال لازم است.", + "offline_bar": "آفلاین هستید. برای خواندن و تغییر ایمیل اتصال لازم است.", "offline_default_on": "ایمیل‌های شما روی این دستگاه نگه داشته می‌شود تا آفلاین هم بخوانید." }, "desktopMenuBar": { diff --git a/locales/fr/common.json b/locales/fr/common.json index f5835e287..5f5825eef 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Rechercher « {text} » dans les messages" }, "mail_p2_desk": { + "reader_offline": "Vous êtes hors ligne. Ce message a besoin d'une connexion pour s'ouvrir.", + "reader_failed": "Ce message n'a pas pu être chargé.", + "retry": "Réessayer", + "action_offline": "Vous êtes hors ligne. Cette action a besoin d'une connexion.", + "offline_bar": "Vous êtes hors ligne. Lire et modifier les e-mails demande une connexion.", "offline_default_on": "Vos e-mails sont gardés sur cet appareil : vous pouvez les lire hors ligne." }, "desktopMenuBar": { diff --git a/locales/he/common.json b/locales/he/common.json index 60d487925..a664c7f52 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -7050,6 +7050,11 @@ "context_search_mail": "חיפוש “{text}” בדואר" }, "mail_p2_desk": { + "reader_offline": "אין חיבור. כדי לפתוח הודעה זו צריך חיבור.", + "reader_failed": "לא ניתן היה לטעון הודעה זו.", + "retry": "לנסות שוב", + "action_offline": "אין חיבור. לפעולה זו צריך חיבור.", + "offline_bar": "אין חיבור. כדי לקרוא ולשנות דואר צריך חיבור.", "offline_default_on": "הדואר שלך נשמר במכשיר הזה, כך שאפשר לקרוא אותו גם בלי חיבור." }, "desktopMenuBar": { diff --git a/locales/hu/common.json b/locales/hu/common.json index e0d9fb5e9..88d67e586 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "„{text}” keresése a levelek között" }, "mail_p2_desk": { + "reader_offline": "Nincs kapcsolat. Ennek az üzenetnek a megnyitásához kapcsolat kell.", + "reader_failed": "Az üzenetet nem sikerült betölteni.", + "retry": "Újra", + "action_offline": "Nincs kapcsolat. Ehhez kapcsolat kell.", + "offline_bar": "Nincs kapcsolat. A levelek olvasásához és módosításához kapcsolat kell.", "offline_default_on": "A leveleid ezen az eszközön is megmaradnak, így kapcsolat nélkül is olvashatod őket." }, "desktopMenuBar": { diff --git a/locales/it/common.json b/locales/it/common.json index 8aa048aca..ad77537bc 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Cerca «{text}» nella posta" }, "mail_p2_desk": { + "reader_offline": "Sei offline. Questo messaggio ha bisogno di una connessione per aprirsi.", + "reader_failed": "Impossibile caricare questo messaggio.", + "retry": "Riprova", + "action_offline": "Sei offline. Serve una connessione.", + "offline_bar": "Sei offline. Per leggere e modificare la posta serve una connessione.", "offline_default_on": "La tua posta è conservata su questo dispositivo, così puoi leggerla offline." }, "desktopMenuBar": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 464d35f8c..e8ecbb45a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "メールで「{text}」を検索" }, "mail_p2_desk": { + "reader_offline": "オフラインです。このメッセージを開くには接続が必要です。", + "reader_failed": "このメッセージを読み込めませんでした。", + "retry": "再試行", + "action_offline": "オフラインです。この操作には接続が必要です。", + "offline_bar": "オフラインです。メールの閲覧や変更には接続が必要です。", "offline_default_on": "メールはこのデバイスに保存されるので、オフラインでも読めます。" }, "desktopMenuBar": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 80256a370..db2801393 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "메일에서 “{text}” 검색" }, "mail_p2_desk": { + "reader_offline": "오프라인 상태입니다. 이 메시지를 열려면 연결이 필요합니다.", + "reader_failed": "이 메시지를 불러오지 못했습니다.", + "retry": "다시 시도", + "action_offline": "오프라인 상태입니다. 이 작업에는 연결이 필요합니다.", + "offline_bar": "오프라인 상태입니다. 메일을 읽고 변경하려면 연결이 필요합니다.", "offline_default_on": "메일이 이 기기에 보관되어 오프라인에서도 읽을 수 있습니다." }, "desktopMenuBar": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 1e420eb9b..1633baac8 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Meklēt pastā “{text}”" }, "mail_p2_desk": { + "reader_offline": "Jūs esat bezsaistē. Šīs vēstules atvēršanai vajag savienojumu.", + "reader_failed": "Šo vēstuli neizdevās ielādēt.", + "retry": "Mēģināt vēlreiz", + "action_offline": "Jūs esat bezsaistē. Tam vajag savienojumu.", + "offline_bar": "Jūs esat bezsaistē. Pasta lasīšanai un mainīšanai vajag savienojumu.", "offline_default_on": "Jūsu pasts tiek glabāts šajā ierīcē, tāpēc to var lasīt bezsaistē." }, "desktopMenuBar": { diff --git a/locales/mn/common.json b/locales/mn/common.json index 13f807146..974c0a29a 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Захидлаас “{text}”-г хайх" }, "mail_p2_desk": { + "reader_offline": "Та офлайн байна. Энэ захиаг нээхэд холболт хэрэгтэй.", + "reader_failed": "Энэ захиаг ачаалж чадсангүй.", + "retry": "Дахин оролдох", + "action_offline": "Та офлайн байна. Үүнд холболт хэрэгтэй.", + "offline_bar": "Та офлайн байна. Захиа унших, өөрчлөхөд холболт хэрэгтэй.", "offline_default_on": "Таны захиа энэ төхөөрөмж дээр хадгалагддаг тул офлайн үед ч уншиж болно." }, "desktopMenuBar": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 6d9fbb86f..f2c081647 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Søk etter «{text}» i e-post" }, "mail_p2_desk": { + "reader_offline": "Du er frakoblet. Denne meldingen trenger tilkobling for å åpnes.", + "reader_failed": "Meldingen kunne ikke lastes inn.", + "retry": "Prøv igjen", + "action_offline": "Du er frakoblet. Dette trenger tilkobling.", + "offline_bar": "Du er frakoblet. Å lese og endre e-post trenger tilkobling.", "offline_default_on": "E-posten din lagres på denne enheten, så du kan lese den uten nett." }, "desktopMenuBar": { diff --git a/locales/nl/common.json b/locales/nl/common.json index e54b2b5d8..2f2d8a963 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Zoek ‘{text}’ in e-mail" }, "mail_p2_desk": { + "reader_offline": "Je bent offline. Dit bericht heeft een verbinding nodig om te openen.", + "reader_failed": "Dit bericht kon niet worden geladen.", + "retry": "Opnieuw proberen", + "action_offline": "Je bent offline. Hiervoor is een verbinding nodig.", + "offline_bar": "Je bent offline. Voor het lezen en wijzigen van mail is een verbinding nodig.", "offline_default_on": "Je mail wordt op dit apparaat bewaard, zodat je hem offline kunt lezen." }, "desktopMenuBar": { diff --git a/locales/pl/common.json b/locales/pl/common.json index 2c94ef990..464e3ca04 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Szukaj „{text}” w poczcie" }, "mail_p2_desk": { + "reader_offline": "Jesteś offline. Ta wiadomość otworzy się tylko z połączeniem.", + "reader_failed": "Nie udało się wczytać tej wiadomości.", + "retry": "Spróbuj ponownie", + "action_offline": "Jesteś offline. To wymaga połączenia.", + "offline_bar": "Jesteś offline. Czytanie i zmienianie poczty wymaga połączenia.", "offline_default_on": "Twoja poczta jest przechowywana na tym urządzeniu, więc możesz ją czytać offline." }, "desktopMenuBar": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 47f68e1fe..a163230fb 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Pesquisar «{text}» no correio" }, "mail_p2_desk": { + "reader_offline": "Está offline. Esta mensagem precisa de ligação para abrir.", + "reader_failed": "Não foi possível carregar esta mensagem.", + "retry": "Tentar novamente", + "action_offline": "Está offline. Isto precisa de ligação.", + "offline_bar": "Está offline. Ler e alterar e-mails precisa de ligação.", "offline_default_on": "Os seus e-mails ficam guardados neste dispositivo, para os poder ler offline." }, "desktopMenuBar": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 072f91750..389b72cbe 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Căutare „{text}” în e-mail" }, "mail_p2_desk": { + "reader_offline": "Sunteți offline. Acest mesaj are nevoie de conexiune ca să se deschidă.", + "reader_failed": "Mesajul nu a putut fi încărcat.", + "retry": "Încercați din nou", + "action_offline": "Sunteți offline. Pentru asta e nevoie de conexiune.", + "offline_bar": "Sunteți offline. Citirea și modificarea e-mailurilor au nevoie de conexiune.", "offline_default_on": "E-mailurile dvs. sunt păstrate pe acest dispozitiv, ca să le puteți citi offline." }, "desktopMenuBar": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 1a63e300c..9ff16ffb5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Найти «{text}» в почте" }, "mail_p2_desk": { + "reader_offline": "Нет подключения. Чтобы открыть это письмо, нужно подключение.", + "reader_failed": "Не удалось загрузить это письмо.", + "retry": "Повторить", + "action_offline": "Нет подключения. Для этого нужно подключение.", + "offline_bar": "Нет подключения. Чтобы читать и изменять почту, нужно подключение.", "offline_default_on": "Ваша почта хранится на этом устройстве, поэтому её можно читать без сети." }, "desktopMenuBar": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 3b8ae2c34..5dde2cc6a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Hľadať „{text}“ v pošte" }, "mail_p2_desk": { + "reader_offline": "Ste offline. Táto správa sa otvorí len s pripojením.", + "reader_failed": "Túto správu sa nepodarilo načítať.", + "retry": "Skúsiť znova", + "action_offline": "Ste offline. Na to je potrebné pripojenie.", + "offline_bar": "Ste offline. Na čítanie a úpravy pošty je potrebné pripojenie.", "offline_default_on": "Vaša pošta je uložená v tomto zariadení, takže ju môžete čítať offline." }, "desktopMenuBar": { diff --git a/locales/th/common.json b/locales/th/common.json index 7ab12bd90..8eb81097f 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -7057,6 +7057,11 @@ "context_search_mail": "ค้นหา “{text}” ในอีเมล" }, "mail_p2_desk": { + "reader_offline": "คุณออฟไลน์อยู่ ข้อความนี้ต้องมีการเชื่อมต่อจึงจะเปิดได้", + "reader_failed": "โหลดข้อความนี้ไม่ได้", + "retry": "ลองอีกครั้ง", + "action_offline": "คุณออฟไลน์อยู่ การทำงานนี้ต้องมีการเชื่อมต่อ", + "offline_bar": "คุณออฟไลน์อยู่ การอ่านและแก้ไขอีเมลต้องมีการเชื่อมต่อ", "offline_default_on": "อีเมลของคุณถูกเก็บไว้ในอุปกรณ์นี้ คุณจึงอ่านได้แม้ออฟไลน์" }, "desktopMenuBar": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 18846c6c8..aed707fcf 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "E-postalarda “{text}” ara" }, "mail_p2_desk": { + "reader_offline": "Çevrimdışısınız. Bu iletiyi açmak için bağlantı gerekiyor.", + "reader_failed": "Bu ileti yüklenemedi.", + "retry": "Tekrar dene", + "action_offline": "Çevrimdışısınız. Bunun için bağlantı gerekiyor.", + "offline_bar": "Çevrimdışısınız. E-postaları okumak ve değiştirmek için bağlantı gerekiyor.", "offline_default_on": "E-postalarınız bu cihazda saklanır, böylece çevrimdışıyken de okuyabilirsiniz." }, "desktopMenuBar": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 7909002a5..4a9caf71c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "Шукати «{text}» у пошті" }, "mail_p2_desk": { + "reader_offline": "Немає з'єднання. Щоб відкрити цей лист, потрібне з'єднання.", + "reader_failed": "Не вдалося завантажити цей лист.", + "retry": "Спробувати ще", + "action_offline": "Немає з'єднання. Для цього потрібне з'єднання.", + "offline_bar": "Немає з'єднання. Щоб читати й змінювати пошту, потрібне з'єднання.", "offline_default_on": "Ваша пошта зберігається на цьому пристрої, тож її можна читати без мережі." }, "desktopMenuBar": { diff --git a/locales/vi/common.json b/locales/vi/common.json index 8bf7c1d0d..5af2773f8 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -7061,6 +7061,11 @@ "context_search_mail": "Tìm “{text}” trong thư" }, "mail_p2_desk": { + "reader_offline": "Bạn đang ngoại tuyến. Cần có kết nối để mở thư này.", + "reader_failed": "Không tải được thư này.", + "retry": "Thử lại", + "action_offline": "Bạn đang ngoại tuyến. Việc này cần có kết nối.", + "offline_bar": "Bạn đang ngoại tuyến. Cần có kết nối để đọc và thay đổi thư.", "offline_default_on": "Thư của bạn được lưu trên thiết bị này, nên bạn có thể đọc khi ngoại tuyến." }, "desktopMenuBar": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index de476cf02..4485af7f4 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "在郵件中搜尋「{text}」" }, "mail_p2_desk": { + "reader_offline": "您已離線。開啟這封郵件需要網路連線。", + "reader_failed": "無法載入這封郵件。", + "retry": "重試", + "action_offline": "您已離線。此操作需要網路連線。", + "offline_bar": "您已離線。閱讀和變更郵件需要網路連線。", "offline_default_on": "您的郵件保存在此裝置上,離線時也能閱讀。" }, "desktopMenuBar": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 3a5997857..c1405cb80 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -7049,6 +7049,11 @@ "context_search_mail": "在邮件中搜索“{text}”" }, "mail_p2_desk": { + "reader_offline": "您已离线。打开这封邮件需要网络连接。", + "reader_failed": "无法加载这封邮件。", + "retry": "重试", + "action_offline": "您已离线。此操作需要网络连接。", + "offline_bar": "您已离线。阅读和更改邮件需要网络连接。", "offline_default_on": "您的邮件保存在此设备上,离线时也能阅读。" }, "desktopMenuBar": { From f2fb56698d05fa0ae81f076cf8959e9374dc9eb6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:52:09 +0100 Subject: [PATCH 25/50] test: find the reader's Archive and Delete by their tooltips with the key The toolbar tooltips are now the label plus the menus' key ("archive (E)", "delete (Del)") instead of the old tooltip strings, so the tests look for those. --- components/email/__tests__/archive-gate-viewer.test.tsx | 6 +++--- .../email/__tests__/reader-spam-trash-actions.test.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/email/__tests__/archive-gate-viewer.test.tsx b/components/email/__tests__/archive-gate-viewer.test.tsx index 8fb33636e..9713c8519 100644 --- a/components/email/__tests__/archive-gate-viewer.test.tsx +++ b/components/email/__tests__/archive-gate-viewer.test.tsx @@ -82,13 +82,13 @@ function viewer() { describe('the viewer and the Archive action gate', () => { it('shows the Archive button while the gate is on', () => { viewer(); - expect(screen.getByTitle('tooltips.archive')).toBeInTheDocument(); + expect(screen.getByTitle('archive (E)')).toBeInTheDocument(); }); it('has no Archive button with the gate off, Delete still there', () => { setArchiveGate(false); viewer(); - expect(screen.queryByTitle('tooltips.archive')).toBeNull(); - expect(screen.getByTitle('tooltips.delete')).toBeInTheDocument(); + expect(screen.queryByTitle('archive (E)')).toBeNull(); + expect(screen.getByTitle('delete (Del)')).toBeInTheDocument(); }); }); diff --git a/components/email/__tests__/reader-spam-trash-actions.test.tsx b/components/email/__tests__/reader-spam-trash-actions.test.tsx index 815449deb..29a2670c6 100644 --- a/components/email/__tests__/reader-spam-trash-actions.test.tsx +++ b/components/email/__tests__/reader-spam-trash-actions.test.tsx @@ -56,7 +56,7 @@ async function viewer(folder: string, h = handlers()) { return h; } -const archiveButton = () => document.querySelector('button[title="tooltips.archive"]'); +const archiveButton = () => document.querySelector('button[title="archive (E)"]'); beforeEach(() => { useSettingsStore.setState({ deleteAction: 'trash', permanentlyDeleteJunk: false, quickReplyEnabled: true }); From 0a059e939213b8d7134e8d7545149df06fb507de Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:00:38 +0100 Subject: [PATCH 26/50] fix: run the send checks before the schedule dialog and say Schedule anyway there The composer test for the schedule arrow now waits for the dialog, since the send checks run before it opens. --- .../composer-plugin-surfaces.test.tsx | 23 +++++++++++++-- components/email/email-composer.tsx | 29 ++++++++++++++----- components/email/send-warnings-dialog.tsx | 9 ++++-- 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/vi/common.json | 4 ++- locales/zh-TW/common.json | 4 ++- locales/zh/common.json | 4 ++- 33 files changed, 139 insertions(+), 42 deletions(-) diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index 817a277ad..66357fe00 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -1,4 +1,4 @@ -import { act, createEvent, fireEvent, render, screen } from '@testing-library/react'; +import { act, createEvent, fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; @@ -332,7 +332,7 @@ describe('composer-send-menu', () => { }); // compose-23: a menu with "Schedule send" as its only item. - it('opens the schedule dialog straight from the arrow when it is the only entry', () => { + it('opens the schedule dialog straight from the arrow when it is the only entry', async () => { useAuthStore.setState({ client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never, }); @@ -343,7 +343,24 @@ describe('composer-send-menu', () => { fireEvent.click(arrow); expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull(); - expect(screen.getByRole('dialog')).toHaveTextContent('schedule_send_description'); + // After the send checks, which found nothing here (compose-p2-14). + expect(await screen.findByRole('dialog')).toHaveTextContent('schedule_send_description'); + }); + + // compose-p2-14: the checks opened on top of the picked time and said + // "Send anyway" though nothing was sent then. + it('runs the send checks before the schedule dialog, and says "Schedule anyway"', async () => { + useAuthStore.setState({ + client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never, + }); + render(); + + fireEvent.click(screen.getAllByRole('button', { name: 'schedule_send' })[0]); + const warnings = await screen.findByRole('alertdialog'); + expect(screen.queryByText('schedule_send_description')).toBeNull(); + fireEvent.click(within(warnings).getByRole('button', { name: 'schedule_anyway' })); + + expect(await screen.findByText('schedule_send_description')).toBeInTheDocument(); }); it('keeps a plain Send button without plugin entries or scheduled send', () => { diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index fc292094a..9ba0a6d50 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1259,7 +1259,9 @@ export function EmailComposer({ const [previewAttachment, setPreviewAttachment] = useState(null); // What the checks before sending found (no subject, no text, placeholders, // a missing attachment, open proofreading issues), asked about in one dialog. - const [sendWarnings, setSendWarnings] = useState<{ items: SendWarning[]; delayedUntil?: string } | null>(null); + // `schedule`: asked before the schedule dialog, which opens on "Schedule anyway". + const [sendWarnings, setSendWarnings] = useState<{ items: SendWarning[]; delayedUntil?: string; schedule?: boolean } | null>(null); + const checkThenScheduleRef = useRef<() => void>(() => {}); // Open proofreading issues, once a checker asked to warn about them. const proofreadingConfirmedRef = useRef(false); const [showScheduleDialog, setShowScheduleDialog] = useState(false); @@ -1545,9 +1547,11 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [signatureIdentity?.id, signatureContent, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision, autoSignatureEnabled]); + // The send checks run first, so they don't open on top of the picked time + // saying "Send anyway" when nothing is sent now (compose-p2-14). const openScheduleDialog = useCallback(() => { - setShowScheduleDialog(true); closeSendMenu(); + checkThenScheduleRef.current(); }, [closeSendMenu]); // `autoSelectReplyIdentity` fused two behaviours: matching one of the user's @@ -3836,10 +3840,12 @@ export function EmailComposer({ else editor.commands.focus(); }; - const handleSend = async ({ warningsConfirmed = false, delayedUntil }: { + const handleSend = async ({ warningsConfirmed = false, delayedUntil, checkOnly = false }: { /** "Send anyway" in the warnings dialog: don't check those again. */ warningsConfirmed?: boolean; delayedUntil?: string; + /** Only the checks, then the schedule dialog instead of a send. */ + checkOnly?: boolean; } = {}) => { if (isSendingRef.current) return; @@ -3986,10 +3992,14 @@ export function EmailComposer({ } if (warnings.length > 0) { - setSendWarnings({ items: warnings, delayedUntil }); + setSendWarnings({ items: warnings, delayedUntil, ...(checkOnly ? { schedule: true } : {}) }); return; } } + if (checkOnly) { + setShowScheduleDialog(true); + return; + } // Checks other features register (lib/outgoing/send-pipeline.ts), such // as the external-recipient confirm. They may ask the user, so a second @@ -4474,6 +4484,8 @@ export function EmailComposer({ }; + checkThenScheduleRef.current = () => void handleSend({ checkOnly: true }); + // A host can hand a follow-up to requestCloseRef - "close this session, then // do that". It runs only once the composer really closes, never when the // guard dialog is cancelled, and never before onClose has had its say. @@ -6143,7 +6155,8 @@ export function EmailComposer({ setShowScheduleDialog(false)} - onSchedule={(delayedUntil) => handleSend({ delayedUntil })} + // Checked before the dialog opened (openScheduleDialog). + onSchedule={(delayedUntil) => handleSend({ delayedUntil, warningsConfirmed: true })} supported={!!composerClient?.hasDelayedSend()} maxDelayedSend={composerClient?.getMaxDelayedSend() ?? 0} disabled={!canSend || isSending || isSavingAndClosing} @@ -6154,6 +6167,7 @@ export function EmailComposer({ { const first = sendWarnings?.items[0]; setSendWarnings(null); @@ -6171,7 +6185,7 @@ export function EmailComposer({ }} onSendAnyway={(dontAskAgain) => { if (!sendWarnings) return; - const { items, delayedUntil } = sendWarnings; + const { items, delayedUntil, schedule } = sendWarnings; for (const warning of items) { if (warning.kind === 'subject') { emptySubjectConfirmedRef.current = true; @@ -6186,7 +6200,8 @@ export function EmailComposer({ } } setSendWarnings(null); - handleSend({ warningsConfirmed: true, delayedUntil }); + if (schedule) setShowScheduleDialog(true); + else handleSend({ warningsConfirmed: true, delayedUntil }); }} /> diff --git a/components/email/send-warnings-dialog.tsx b/components/email/send-warnings-dialog.tsx index a29709903..0ea8a44eb 100644 --- a/components/email/send-warnings-dialog.tsx +++ b/components/email/send-warnings-dialog.tsx @@ -20,6 +20,8 @@ interface SendWarningsDialogProps { onBack: () => void; /** "Send anyway". `dontAskAgain`: the box under a single warning that has a setting. */ onSendAnyway: (dontAskAgain: boolean) => void; + /** Asked before Schedule send: the button says "Schedule anyway". */ + scheduling?: boolean; } /** The warnings that can be switched off in Settings (subject and placeholders). */ @@ -30,7 +32,7 @@ const HAS_SETTING = new Set(["subject", "placeholders"]); * own title and text, several are listed in one dialog with one "Send anyway" * (two dialogs in a row asked about each in turn). */ -export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarningsDialogProps) { +export function SendWarningsDialog({ warnings, onBack, onSendAnyway, scheduling = false }: SendWarningsDialogProps) { const t = useTranslations("email_composer"); const tSend = useTranslations("compose_send"); const tProofreading = useTranslations("proofreading"); @@ -89,7 +91,7 @@ export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarni backLabel = tProofreading("send_warning.review"); sendLabel = tProofreading("send_warning.send"); } else { - title = tSend("warnings_title"); + title = tSend(scheduling ? "schedule_warnings_title" : "warnings_title"); body = (
    {items.map((w) => ( @@ -113,6 +115,9 @@ export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarni ); } + // Nothing goes out now: the message is scheduled (compose-p2-14). + if (scheduling) sendLabel = tSend("schedule_anyway"); + return ( 0} diff --git a/locales/ar/common.json b/locales/ar/common.json index 999cafaf0..c4b29b121 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1112,7 +1112,7 @@ "unfinished": [] }, "schedule_send": "جدولة الإرسال", - "schedule_send_description": "اختر متى يجب على الخادم إرسال هذه الرسالة.", + "schedule_send_description": "اختر متى يتم إرسالها.", "schedule_preset": { "this_morning": "هذا الصباح", "this_afternoon": "بعد ظهر اليوم", @@ -4057,6 +4057,8 @@ "sending": "جارٍ الإرسال…", "sent": "تم الإرسال", "view": "عرض", + "schedule_anyway": "الجدولة على أي حال", + "schedule_warnings_title": "هل تريد جدولة هذه الرسالة على أي حال؟", "picker_button": "إضافة من جهات الاتصال", "picker_title": "إضافة مستلمين", "tab_contacts": "جهات الاتصال", diff --git a/locales/ca/common.json b/locales/ca/common.json index 091cd601c..6d3acbb35 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1112,7 +1112,7 @@ "unfinished": [] }, "schedule_send": "Programa l'enviament", - "schedule_send_description": "Trieu quan ha d'alliberar el servidor aquest missatge.", + "schedule_send_description": "Tria quan s'ha d'enviar.", "schedule_preset": { "this_morning": "Aquest matí", "this_afternoon": "Aquesta tarda", @@ -4057,6 +4057,8 @@ "sending": "S'està enviant…", "sent": "Enviat", "view": "Mostra", + "schedule_anyway": "Programa igualment", + "schedule_warnings_title": "Vols programar aquest missatge igualment?", "picker_button": "Afegeix des dels contactes", "picker_title": "Afegeix destinataris", "tab_contacts": "Contactes", diff --git a/locales/cs/common.json b/locales/cs/common.json index b2ae541cf..b1082a810 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "Zadejte URL", "sending": "Odesílání...", "schedule_send": "Naplánovat odeslání", - "schedule_send_description": "Vyberte, kdy má server tuto zprávu odeslat.", + "schedule_send_description": "Vyberte, kdy se má odeslat.", "schedule_preset": { "this_morning": "Dnes ráno", "this_afternoon": "Dnes odpoledne", @@ -4057,6 +4057,8 @@ "sending": "Odesílání…", "sent": "Odesláno", "view": "Zobrazit", + "schedule_anyway": "Přesto naplánovat", + "schedule_warnings_title": "Přesto tuto zprávu naplánovat?", "picker_button": "Přidat z kontaktů", "picker_title": "Přidat příjemce", "tab_contacts": "Kontakty", diff --git a/locales/da/common.json b/locales/da/common.json index 7d48f4b15..a382bf9f7 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1112,7 +1112,7 @@ }, "link_url_prompt": "Indtast URL'en", "schedule_send": "Planlæg afsendelse", - "schedule_send_description": "Vælg hvornår serveren skal frigive denne besked.", + "schedule_send_description": "Vælg hvornår den skal sendes.", "schedule_preset": { "this_morning": "I dag formiddag", "this_afternoon": "I eftermiddag", @@ -4057,6 +4057,8 @@ "sending": "Sender…", "sent": "Sendt", "view": "Vis", + "schedule_anyway": "Planlæg alligevel", + "schedule_warnings_title": "Planlæg denne besked alligevel?", "picker_button": "Tilføj fra kontakter", "picker_title": "Tilføj modtagere", "tab_contacts": "Kontakter", diff --git a/locales/de/common.json b/locales/de/common.json index e1e33c934..a09a2392a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "URL eingeben", "sending": "Wird gesendet...", "schedule_send": "Senden planen", - "schedule_send_description": "Wählen Sie, wann der Server diese Nachricht freigeben soll.", + "schedule_send_description": "Wählen Sie, wann gesendet werden soll.", "schedule_preset": { "this_morning": "Heute früh", "this_afternoon": "Heute Nachmittag", @@ -4057,6 +4057,8 @@ "sending": "Wird gesendet…", "sent": "Gesendet", "view": "Anzeigen", + "schedule_anyway": "Trotzdem planen", + "schedule_warnings_title": "Diese Nachricht trotzdem planen?", "picker_button": "Aus Kontakten hinzufügen", "picker_title": "Empfänger hinzufügen", "tab_contacts": "Kontakte", diff --git a/locales/en/common.json b/locales/en/common.json index ae08611b3..83bafebb0 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1114,7 +1114,7 @@ "unfinished": ["TODO", "TBD", "TBA", "TBC", "FIXME", "XXX"] }, "schedule_send": "Schedule send", - "schedule_send_description": "Choose when the server should release this message.", + "schedule_send_description": "Pick when to send it.", "schedule_preset": { "this_morning": "This morning", "this_afternoon": "This afternoon", @@ -4057,6 +4057,8 @@ "sending": "Sending…", "sent": "Sent", "view": "View", + "schedule_anyway": "Schedule anyway", + "schedule_warnings_title": "Schedule this message anyway?", "picker_button": "Add from contacts", "picker_title": "Add recipients", "tab_contacts": "Contacts", diff --git a/locales/es/common.json b/locales/es/common.json index e4c6f39e9..75303fc16 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "Introduce la URL", "sending": "Enviando...", "schedule_send": "Programar envío", - "schedule_send_description": "Elige cuándo debe liberar el servidor este mensaje.", + "schedule_send_description": "Elige cuándo enviarlo.", "schedule_preset": { "this_morning": "Esta mañana", "this_afternoon": "Esta tarde", @@ -4057,6 +4057,8 @@ "sending": "Enviando…", "sent": "Enviado", "view": "Ver", + "schedule_anyway": "Programar de todos modos", + "schedule_warnings_title": "¿Programar este mensaje de todos modos?", "picker_button": "Añadir desde contactos", "picker_title": "Añadir destinatarios", "tab_contacts": "Contactos", diff --git a/locales/et/common.json b/locales/et/common.json index 30ac1ee96..8e79e0fff 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -1112,7 +1112,7 @@ "unfinished": [] }, "schedule_send": "Ajasta saatmine", - "schedule_send_description": "Vali, millal server peaks selle sõnumi välja saatma.", + "schedule_send_description": "Vali, millal see saata.", "schedule_preset": { "this_morning": "Täna hommikul", "this_afternoon": "Täna pärastlõunal", @@ -4057,6 +4057,8 @@ "sending": "Saatmine…", "sent": "Saadetud", "view": "Vaata", + "schedule_anyway": "Ajasta ikkagi", + "schedule_warnings_title": "Kas ajastada see kiri ikkagi?", "picker_button": "Lisa kontaktidest", "picker_title": "Lisa saajaid", "tab_contacts": "Kontaktid", diff --git a/locales/fa/common.json b/locales/fa/common.json index 117abe12f..af6a59542 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1112,7 +1112,7 @@ "unfinished": [] }, "schedule_send": "زمان‌بندی ارسال", - "schedule_send_description": "زمان انتشار پیام توسط سرور را انتخاب کنید.", + "schedule_send_description": "زمان ارسال را انتخاب کنید.", "schedule_preset": { "this_morning": "امروز صبح", "this_afternoon": "امروز بعدازظهر", @@ -4057,6 +4057,8 @@ "sending": "در حال ارسال…", "sent": "ارسال شد", "view": "مشاهده", + "schedule_anyway": "زمان‌بندی به هر حال", + "schedule_warnings_title": "این پیام به هر حال زمان‌بندی شود؟", "picker_button": "افزودن از مخاطبین", "picker_title": "افزودن گیرندگان", "tab_contacts": "مخاطبین", diff --git a/locales/fr/common.json b/locales/fr/common.json index 920bc103e..381c94ddf 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "Saisissez l'URL", "sending": "Envoi...", "schedule_send": "Planifier l’envoi", - "schedule_send_description": "Choisissez quand le serveur doit libérer ce message.", + "schedule_send_description": "Choisissez quand l’envoyer.", "schedule_preset": { "this_morning": "Ce matin", "this_afternoon": "Cet après-midi", @@ -4057,6 +4057,8 @@ "sending": "Envoi…", "sent": "Envoyé", "view": "Afficher", + "schedule_anyway": "Planifier quand même", + "schedule_warnings_title": "Planifier ce message quand même ?", "picker_button": "Ajouter depuis les contacts", "picker_title": "Ajouter des destinataires", "tab_contacts": "Contacts", diff --git a/locales/he/common.json b/locales/he/common.json index 2ddbebc18..4b268c781 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1055,7 +1055,7 @@ "unfinished": [] }, "schedule_send": "תזמן שליחה", - "schedule_send_description": "בחר מתי השרת צריך לשחרר הודעה זו.", + "schedule_send_description": "בחר מתי לשלוח אותה.", "schedule_preset": { "this_morning": "הבוקר", "this_afternoon": "היום אחר הצהריים", @@ -3950,6 +3950,8 @@ "sending": "שולח…", "sent": "נשלח", "view": "הצג", + "schedule_anyway": "תזמן בכל זאת", + "schedule_warnings_title": "לתזמן את ההודעה בכל זאת?", "picker_button": "הוסף מאנשי הקשר", "picker_title": "הוספת נמענים", "tab_contacts": "אנשי קשר", diff --git a/locales/hu/common.json b/locales/hu/common.json index 40440385b..f1daa68e8 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1112,7 +1112,7 @@ "unfinished": [] }, "schedule_send": "Késleltetett küldés", - "schedule_send_description": "Válaszd ki, mikor küldje el a szerver az üzenetet.", + "schedule_send_description": "Válaszd ki, mikor menjen el.", "schedule_preset": { "this_morning": "Ma reggel", "this_afternoon": "Ma délután", @@ -4057,6 +4057,8 @@ "sending": "Küldés…", "sent": "Elküldve", "view": "Megtekintés", + "schedule_anyway": "Időzítés így is", + "schedule_warnings_title": "Időzíti így is ezt az üzenetet?", "picker_button": "Hozzáadás a névjegyekből", "picker_title": "Címzettek hozzáadása", "tab_contacts": "Névjegyek", diff --git a/locales/it/common.json b/locales/it/common.json index 4c928e274..0e77a5ee0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "Inserisci l'URL", "sending": "Invio in corso...", "schedule_send": "Programma invio", - "schedule_send_description": "Scegli quando il server deve rilasciare questo messaggio.", + "schedule_send_description": "Scegli quando inviarlo.", "schedule_preset": { "this_morning": "Stamattina", "this_afternoon": "Oggi pomeriggio", @@ -4057,6 +4057,8 @@ "sending": "Invio in corso…", "sent": "Inviato", "view": "Visualizza", + "schedule_anyway": "Programma comunque", + "schedule_warnings_title": "Programmare comunque questo messaggio?", "picker_button": "Aggiungi dai contatti", "picker_title": "Aggiungi destinatari", "tab_contacts": "Contatti", diff --git a/locales/ja/common.json b/locales/ja/common.json index a4788a73e..a9388d8bd 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "URLを入力してください", "sending": "送信中...", "schedule_send": "送信を予約", - "schedule_send_description": "サーバーがこのメッセージを送信する日時を選択します。", + "schedule_send_description": "送信する日時を選んでください。", "schedule_preset": { "this_morning": "今朝", "this_afternoon": "今日の午後", @@ -4057,6 +4057,8 @@ "sending": "送信中…", "sent": "送信しました", "view": "表示", + "schedule_anyway": "そのまま予約", + "schedule_warnings_title": "このメッセージをそのまま予約しますか?", "picker_button": "連絡先から追加", "picker_title": "宛先を追加", "tab_contacts": "連絡先", diff --git a/locales/ko/common.json b/locales/ko/common.json index 8692e53c6..e924ece48 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "URL을 입력하세요", "sending": "전송 중...", "schedule_send": "보내기 예약", - "schedule_send_description": "서버가 이 메시지를 보낼 시간을 선택하세요.", + "schedule_send_description": "보낼 시간을 고르세요.", "schedule_preset": { "this_morning": "오늘 아침", "this_afternoon": "오늘 오후", @@ -4057,6 +4057,8 @@ "sending": "보내는 중…", "sent": "보냈어요", "view": "보기", + "schedule_anyway": "그래도 예약하기", + "schedule_warnings_title": "이 메일을 그래도 예약할까요?", "picker_button": "연락처에서 추가", "picker_title": "받는 사람 추가", "tab_contacts": "연락처", diff --git a/locales/lv/common.json b/locales/lv/common.json index 30ae51e41..c602efb2b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "Ievadiet URL", "sending": "Sūta...", "schedule_send": "Ieplānot sūtīšanu", - "schedule_send_description": "Izvēlieties, kad serverim jānosūta šis ziņojums.", + "schedule_send_description": "Izvēlieties, kad to nosūtīt.", "schedule_preset": { "this_morning": "Šorīt", "this_afternoon": "Šodien pēcpusdienā", @@ -4057,6 +4057,8 @@ "sending": "Sūta…", "sent": "Nosūtīts", "view": "Skatīt", + "schedule_anyway": "Ieplānot tik un tā", + "schedule_warnings_title": "Vai tomēr ieplānot šo vēstuli?", "picker_button": "Pievienot no kontaktiem", "picker_title": "Pievienot saņēmējus", "tab_contacts": "Kontakti", diff --git a/locales/mn/common.json b/locales/mn/common.json index 677689faa..dd068df01 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1112,7 +1112,7 @@ "unfinished": [] }, "schedule_send": "Хуваарь илгээх", - "schedule_send_description": "Сервер энэ мессежийг хэзээ гаргахыг сонгоно уу.", + "schedule_send_description": "Хэзээ илгээхээ сонгоно уу.", "schedule_preset": { "this_morning": "Өнөө өглөө", "this_afternoon": "Өнөөдөр үдээс хойш", @@ -4057,6 +4057,8 @@ "sending": "Илгээж байна…", "sent": "Илгээгдлээ", "view": "Харах", + "schedule_anyway": "Ямар ч байсан хуваарьт оруулах", + "schedule_warnings_title": "Энэ захиаг ямар ч байсан хуваарьт оруулах уу?", "picker_button": "Харилцагчдаас нэмэх", "picker_title": "Хүлээн авагч нэмэх", "tab_contacts": "Харилцагчид", diff --git a/locales/nb/common.json b/locales/nb/common.json index bc849a939..9bc356866 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1112,7 +1112,7 @@ "unfinished": [] }, "schedule_send": "Planlegg sending", - "schedule_send_description": "Velg når serveren skal sende denne meldingen.", + "schedule_send_description": "Velg når den skal sendes.", "schedule_preset": { "this_morning": "I dag tidlig", "this_afternoon": "I ettermiddag", @@ -4057,6 +4057,8 @@ "sending": "Sender…", "sent": "Sendt", "view": "Vis", + "schedule_anyway": "Planlegg likevel", + "schedule_warnings_title": "Planlegge denne meldingen likevel?", "picker_button": "Legg til fra kontakter", "picker_title": "Legg til mottakere", "tab_contacts": "Kontakter", diff --git a/locales/nl/common.json b/locales/nl/common.json index 40c5b7aba..4a0ad4bfa 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "Voer de URL in", "sending": "Bezig met verzenden...", "schedule_send": "Verzenden plannen", - "schedule_send_description": "Kies wanneer de server dit bericht moet vrijgeven.", + "schedule_send_description": "Kies wanneer het verzonden wordt.", "schedule_preset": { "this_morning": "Vanochtend", "this_afternoon": "Vanmiddag", @@ -4057,6 +4057,8 @@ "sending": "Bezig met verzenden…", "sent": "Verzonden", "view": "Bekijken", + "schedule_anyway": "Toch plannen", + "schedule_warnings_title": "Dit bericht toch plannen?", "picker_button": "Toevoegen uit contacten", "picker_title": "Ontvangers toevoegen", "tab_contacts": "Contacten", diff --git a/locales/pl/common.json b/locales/pl/common.json index 977c2f348..d6d4d3c72 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "Wprowadź adres URL", "sending": "Wysyłanie...", "schedule_send": "Zaplanuj wysyłkę", - "schedule_send_description": "Wybierz, kiedy serwer ma wysłać tę wiadomość.", + "schedule_send_description": "Wybierz, kiedy ją wysłać.", "schedule_preset": { "this_morning": "Dziś rano", "this_afternoon": "Dziś po południu", @@ -4057,6 +4057,8 @@ "sending": "Wysyłanie…", "sent": "Wysłano", "view": "Pokaż", + "schedule_anyway": "Zaplanuj mimo to", + "schedule_warnings_title": "Zaplanować tę wiadomość mimo to?", "picker_button": "Dodaj z kontaktów", "picker_title": "Dodaj odbiorców", "tab_contacts": "Kontakty", diff --git a/locales/pt/common.json b/locales/pt/common.json index 4b3b27d99..d1c854248 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "Insira a URL", "sending": "Enviando...", "schedule_send": "Agendar envio", - "schedule_send_description": "Escolha quando o servidor deve liberar esta mensagem.", + "schedule_send_description": "Escolha quando enviar.", "schedule_preset": { "this_morning": "Esta manhã", "this_afternoon": "Esta tarde", @@ -4057,6 +4057,8 @@ "sending": "Enviando…", "sent": "Enviado", "view": "Ver", + "schedule_anyway": "Agendar mesmo assim", + "schedule_warnings_title": "Agendar esta mensagem mesmo assim?", "picker_button": "Adicionar dos contatos", "picker_title": "Adicionar destinatários", "tab_contacts": "Contatos", diff --git a/locales/ro/common.json b/locales/ro/common.json index 60c77c966..c51e3a359 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1112,7 +1112,7 @@ "unfinished": [] }, "schedule_send": "Programează trimiterea", - "schedule_send_description": "Alegeți momentul în care serverul ar trebui să transmită acest mesaj.", + "schedule_send_description": "Alegeți când să fie trimis.", "schedule_preset": { "this_morning": "Azi dimineață", "this_afternoon": "Azi după-amiază", @@ -4057,6 +4057,8 @@ "sending": "Se trimite…", "sent": "Trimis", "view": "Vezi", + "schedule_anyway": "Programează oricum", + "schedule_warnings_title": "Programați totuși acest mesaj?", "picker_button": "Adaugă din contacte", "picker_title": "Adaugă destinatari", "tab_contacts": "Contacte", diff --git a/locales/ru/common.json b/locales/ru/common.json index 3a0c9fcec..ef7a4111c 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "Введите URL", "sending": "Отправка...", "schedule_send": "Запланировать отправку", - "schedule_send_description": "Выберите, когда сервер должен отправить это сообщение.", + "schedule_send_description": "Выберите, когда отправить.", "schedule_preset": { "this_morning": "Сегодня утром", "this_afternoon": "Сегодня днём", @@ -4057,6 +4057,8 @@ "sending": "Отправка…", "sent": "Отправлено", "view": "Открыть", + "schedule_anyway": "Всё равно запланировать", + "schedule_warnings_title": "Всё равно запланировать это письмо?", "picker_button": "Добавить из контактов", "picker_title": "Добавить получателей", "tab_contacts": "Контакты", diff --git a/locales/sk/common.json b/locales/sk/common.json index 502164d6d..489a666d6 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1112,7 +1112,7 @@ "unfinished": [] }, "schedule_send": "Naplánovať odoslanie", - "schedule_send_description": "Vyberte, kedy má server túto správu odoslať.", + "schedule_send_description": "Vyberte, kedy sa má odoslať.", "schedule_preset": { "this_morning": "Dnes ráno", "this_afternoon": "Dnes popoludní", @@ -4057,6 +4057,8 @@ "sending": "Odosielanie…", "sent": "Odoslané", "view": "Zobraziť", + "schedule_anyway": "Naplánovať aj tak", + "schedule_warnings_title": "Naplánovať túto správu aj tak?", "picker_button": "Pridať z kontaktov", "picker_title": "Pridať príjemcov", "tab_contacts": "Kontakty", diff --git a/locales/th/common.json b/locales/th/common.json index 852be7f21..a4f832b21 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1233,6 +1233,8 @@ "sending": "กำลังส่ง…", "sent": "ส่งแล้ว", "view": "ดู", + "schedule_anyway": "กำหนดเวลาต่อไป", + "schedule_warnings_title": "กำหนดเวลาส่งข้อความนี้ต่อไปหรือไม่", "picker_button": "เพิ่มจากผู้ติดต่อ", "picker_title": "เพิ่มผู้รับ", "tab_contacts": "ผู้ติดต่อ", @@ -1501,7 +1503,7 @@ "unfinished": [] }, "schedule_send": "กำหนดเวลาส่ง", - "schedule_send_description": "เลือกเวลาที่ต้องการให้เซิร์ฟเวอร์ส่งข้อความนี้", + "schedule_send_description": "เลือกเวลาที่จะส่ง", "schedule_preset": { "this_morning": "เช้านี้", "this_afternoon": "บ่ายนี้", diff --git a/locales/tr/common.json b/locales/tr/common.json index 8e246b38f..1b5259bc7 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1112,7 +1112,7 @@ "unfinished": [] }, "schedule_send": "Göndermeyi zamanla", - "schedule_send_description": "Sunucunun bu iletiyi ne zaman göndereceğini seçin.", + "schedule_send_description": "Ne zaman gönderileceğini seçin.", "schedule_preset": { "this_morning": "Bu sabah", "this_afternoon": "Bu öğleden sonra", @@ -4057,6 +4057,8 @@ "sending": "Gönderiliyor…", "sent": "Gönderildi", "view": "Görüntüle", + "schedule_anyway": "Yine de zamanla", + "schedule_warnings_title": "Bu ileti yine de zamanlansın mı?", "picker_button": "Kişilerden ekle", "picker_title": "Alıcı ekle", "tab_contacts": "Kişiler", diff --git a/locales/uk/common.json b/locales/uk/common.json index 52bf3725a..32c0b46c4 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "Введіть URL", "sending": "Надсилання...", "schedule_send": "Запланувати надсилання", - "schedule_send_description": "Виберіть, коли сервер має надіслати це повідомлення.", + "schedule_send_description": "Виберіть, коли надіслати.", "schedule_preset": { "this_morning": "Сьогодні вранці", "this_afternoon": "Сьогодні вдень", @@ -4057,6 +4057,8 @@ "sending": "Надсилання…", "sent": "Надіслано", "view": "Переглянути", + "schedule_anyway": "Усе одно запланувати", + "schedule_warnings_title": "Усе одно запланувати цей лист?", "picker_button": "Додати з контактів", "picker_title": "Додати одержувачів", "tab_contacts": "Контакти", diff --git a/locales/vi/common.json b/locales/vi/common.json index a02cf42d1..57c5bf4e3 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -1126,7 +1126,7 @@ ] }, "schedule_send": "Lên lịch gửi", - "schedule_send_description": "Chọn thời điểm máy chủ sẽ gửi thư này.", + "schedule_send_description": "Chọn thời điểm gửi.", "schedule_preset": { "this_morning": "Sáng nay", "this_afternoon": "Chiều nay", @@ -4069,6 +4069,8 @@ "sending": "Đang gửi…", "sent": "Đã gửi", "view": "Xem", + "schedule_anyway": "Vẫn lên lịch", + "schedule_warnings_title": "Vẫn lên lịch gửi thư này?", "picker_button": "Thêm từ danh bạ", "picker_title": "Thêm người nhận", "tab_contacts": "Danh bạ", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index afe32e3ed..c80aa8724 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1112,7 +1112,7 @@ "unfinished": [] }, "schedule_send": "排程寄送", - "schedule_send_description": "選擇伺服器要在何時送出這封郵件。", + "schedule_send_description": "選擇寄送時間。", "schedule_preset": { "this_morning": "今天早上", "this_afternoon": "今天下午", @@ -4057,6 +4057,8 @@ "sending": "正在傳送…", "sent": "已傳送", "view": "檢視", + "schedule_anyway": "仍然排程", + "schedule_warnings_title": "仍然排程這封郵件?", "picker_button": "從聯絡人新增", "picker_title": "新增收件者", "tab_contacts": "聯絡人", diff --git a/locales/zh/common.json b/locales/zh/common.json index a5fd459b2..4cdb42e74 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1112,7 +1112,7 @@ "link_url_prompt": "输入 URL", "sending": "发送中...", "schedule_send": "计划发送", - "schedule_send_description": "选择服务器应何时发送此邮件。", + "schedule_send_description": "选择发送时间。", "schedule_preset": { "this_morning": "今天早上", "this_afternoon": "今天下午", @@ -4057,6 +4057,8 @@ "sending": "正在发送…", "sent": "已发送", "view": "查看", + "schedule_anyway": "仍然计划发送", + "schedule_warnings_title": "仍然计划发送这封邮件?", "picker_button": "从联系人添加", "picker_title": "添加收件人", "tab_contacts": "联系人", From 8cbc2efe9f020d3d4827c499d8f5d1d3761c5804 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:04:08 +0100 Subject: [PATCH 27/50] fix: say Template saved after Save as template --- .../__tests__/composer-template-reply.test.tsx | 14 +++++++++++++- components/email/email-composer.tsx | 8 ++++++-- 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/vi/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 32 files changed, 49 insertions(+), 3 deletions(-) diff --git a/components/email/__tests__/composer-template-reply.test.tsx b/components/email/__tests__/composer-template-reply.test.tsx index d2d5abc3e..bcb2f5b92 100644 --- a/components/email/__tests__/composer-template-reply.test.tsx +++ b/components/email/__tests__/composer-template-reply.test.tsx @@ -147,8 +147,9 @@ vi.mock('@/stores/contact-store', () => { // ─── Misc dependency mocks ──────────────────────────────────────────────────── +const toastSuccess = vi.hoisted(() => ({ calls: [] as unknown[][] })); vi.mock('@/stores/toast-store', () => ({ - toast: { info: () => {}, error: () => {}, success: () => {} }, + toast: { info: () => {}, error: () => {}, success: (...args: unknown[]) => { toastSuccess.calls.push(args); } }, })); vi.mock('@/lib/plugin-hooks', () => ({ @@ -341,6 +342,17 @@ describe('applying a template to a reply (#911)', () => { expect(screen.queryByTestId('quick-save-template')).not.toBeInTheDocument(); }); + // compose-p2-15: the dialog closed with no sign the template was kept. + it('says "Template saved"', async () => { + useTemplateStore.setState({ templates: [] }); + toastSuccess.calls = []; + await openReply(); + const dialog = await openNameDialog(); + fireEvent.change(within(dialog).getByLabelText('template_name'), { target: { value: 'Quick answer' } }); + fireEvent.click(within(dialog).getByText('save')); + await waitFor(() => expect(toastSuccess.calls).toContainEqual(['template_saved'])); + }); + it('carries the name over into the full editor', async () => { await openReply(); const dialog = await openNameDialog(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 9ba0a6d50..10eafabe6 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1846,9 +1846,13 @@ export function EmailComposer({ const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); // "Save as template" keeps it with the account the message is written from. + // It says so: the dialog closing was the only sign before (compose-p2-15). const addTemplate = useCallback( - (data: Parameters['add']>[0]) => personalTemplatesFor(composingAccountId).add(data), - [composingAccountId], + (data: Parameters['add']>[0]) => { + personalTemplatesFor(composingAccountId).add(data); + toast.success(tSend('template_saved')); + }, + [composingAccountId, tSend], ); // The admin may leave users only organisation and team templates. const composingTemplates = useTemplateSources(composingAccountId); diff --git a/locales/ar/common.json b/locales/ar/common.json index c4b29b121..03b02998b 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4057,6 +4057,7 @@ "sending": "جارٍ الإرسال…", "sent": "تم الإرسال", "view": "عرض", + "template_saved": "تم حفظ القالب", "schedule_anyway": "الجدولة على أي حال", "schedule_warnings_title": "هل تريد جدولة هذه الرسالة على أي حال؟", "picker_button": "إضافة من جهات الاتصال", diff --git a/locales/ca/common.json b/locales/ca/common.json index 6d3acbb35..b7b50e512 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -4057,6 +4057,7 @@ "sending": "S'està enviant…", "sent": "Enviat", "view": "Mostra", + "template_saved": "Plantilla desada", "schedule_anyway": "Programa igualment", "schedule_warnings_title": "Vols programar aquest missatge igualment?", "picker_button": "Afegeix des dels contactes", diff --git a/locales/cs/common.json b/locales/cs/common.json index b1082a810..318a91a09 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -4057,6 +4057,7 @@ "sending": "Odesílání…", "sent": "Odesláno", "view": "Zobrazit", + "template_saved": "Šablona uložena", "schedule_anyway": "Přesto naplánovat", "schedule_warnings_title": "Přesto tuto zprávu naplánovat?", "picker_button": "Přidat z kontaktů", diff --git a/locales/da/common.json b/locales/da/common.json index a382bf9f7..03c496a40 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -4057,6 +4057,7 @@ "sending": "Sender…", "sent": "Sendt", "view": "Vis", + "template_saved": "Skabelon gemt", "schedule_anyway": "Planlæg alligevel", "schedule_warnings_title": "Planlæg denne besked alligevel?", "picker_button": "Tilføj fra kontakter", diff --git a/locales/de/common.json b/locales/de/common.json index a09a2392a..01dbad087 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -4057,6 +4057,7 @@ "sending": "Wird gesendet…", "sent": "Gesendet", "view": "Anzeigen", + "template_saved": "Vorlage gespeichert", "schedule_anyway": "Trotzdem planen", "schedule_warnings_title": "Diese Nachricht trotzdem planen?", "picker_button": "Aus Kontakten hinzufügen", diff --git a/locales/en/common.json b/locales/en/common.json index 83bafebb0..c00f64c28 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -4057,6 +4057,7 @@ "sending": "Sending…", "sent": "Sent", "view": "View", + "template_saved": "Template saved", "schedule_anyway": "Schedule anyway", "schedule_warnings_title": "Schedule this message anyway?", "picker_button": "Add from contacts", diff --git a/locales/es/common.json b/locales/es/common.json index 75303fc16..5197a03a3 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -4057,6 +4057,7 @@ "sending": "Enviando…", "sent": "Enviado", "view": "Ver", + "template_saved": "Plantilla guardada", "schedule_anyway": "Programar de todos modos", "schedule_warnings_title": "¿Programar este mensaje de todos modos?", "picker_button": "Añadir desde contactos", diff --git a/locales/et/common.json b/locales/et/common.json index 8e79e0fff..2cfda5f4d 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -4057,6 +4057,7 @@ "sending": "Saatmine…", "sent": "Saadetud", "view": "Vaata", + "template_saved": "Mall salvestatud", "schedule_anyway": "Ajasta ikkagi", "schedule_warnings_title": "Kas ajastada see kiri ikkagi?", "picker_button": "Lisa kontaktidest", diff --git a/locales/fa/common.json b/locales/fa/common.json index af6a59542..6e6aae275 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4057,6 +4057,7 @@ "sending": "در حال ارسال…", "sent": "ارسال شد", "view": "مشاهده", + "template_saved": "الگو ذخیره شد", "schedule_anyway": "زمان‌بندی به هر حال", "schedule_warnings_title": "این پیام به هر حال زمان‌بندی شود؟", "picker_button": "افزودن از مخاطبین", diff --git a/locales/fr/common.json b/locales/fr/common.json index 381c94ddf..95de0e366 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -4057,6 +4057,7 @@ "sending": "Envoi…", "sent": "Envoyé", "view": "Afficher", + "template_saved": "Modèle enregistré", "schedule_anyway": "Planifier quand même", "schedule_warnings_title": "Planifier ce message quand même ?", "picker_button": "Ajouter depuis les contacts", diff --git a/locales/he/common.json b/locales/he/common.json index 4b268c781..ab4a20385 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3950,6 +3950,7 @@ "sending": "שולח…", "sent": "נשלח", "view": "הצג", + "template_saved": "התבנית נשמרה", "schedule_anyway": "תזמן בכל זאת", "schedule_warnings_title": "לתזמן את ההודעה בכל זאת?", "picker_button": "הוסף מאנשי הקשר", diff --git a/locales/hu/common.json b/locales/hu/common.json index f1daa68e8..78f82d0c8 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -4057,6 +4057,7 @@ "sending": "Küldés…", "sent": "Elküldve", "view": "Megtekintés", + "template_saved": "Sablon mentve", "schedule_anyway": "Időzítés így is", "schedule_warnings_title": "Időzíti így is ezt az üzenetet?", "picker_button": "Hozzáadás a névjegyekből", diff --git a/locales/it/common.json b/locales/it/common.json index 0e77a5ee0..5a77c2701 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -4057,6 +4057,7 @@ "sending": "Invio in corso…", "sent": "Inviato", "view": "Visualizza", + "template_saved": "Modello salvato", "schedule_anyway": "Programma comunque", "schedule_warnings_title": "Programmare comunque questo messaggio?", "picker_button": "Aggiungi dai contatti", diff --git a/locales/ja/common.json b/locales/ja/common.json index a9388d8bd..a62306d9b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -4057,6 +4057,7 @@ "sending": "送信中…", "sent": "送信しました", "view": "表示", + "template_saved": "テンプレートを保存しました", "schedule_anyway": "そのまま予約", "schedule_warnings_title": "このメッセージをそのまま予約しますか?", "picker_button": "連絡先から追加", diff --git a/locales/ko/common.json b/locales/ko/common.json index e924ece48..fee7751a9 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -4057,6 +4057,7 @@ "sending": "보내는 중…", "sent": "보냈어요", "view": "보기", + "template_saved": "템플릿을 저장했습니다", "schedule_anyway": "그래도 예약하기", "schedule_warnings_title": "이 메일을 그래도 예약할까요?", "picker_button": "연락처에서 추가", diff --git a/locales/lv/common.json b/locales/lv/common.json index c602efb2b..8289163b3 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -4057,6 +4057,7 @@ "sending": "Sūta…", "sent": "Nosūtīts", "view": "Skatīt", + "template_saved": "Veidne saglabāta", "schedule_anyway": "Ieplānot tik un tā", "schedule_warnings_title": "Vai tomēr ieplānot šo vēstuli?", "picker_button": "Pievienot no kontaktiem", diff --git a/locales/mn/common.json b/locales/mn/common.json index dd068df01..c282465a3 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4057,6 +4057,7 @@ "sending": "Илгээж байна…", "sent": "Илгээгдлээ", "view": "Харах", + "template_saved": "Загвар хадгалагдлаа", "schedule_anyway": "Ямар ч байсан хуваарьт оруулах", "schedule_warnings_title": "Энэ захиаг ямар ч байсан хуваарьт оруулах уу?", "picker_button": "Харилцагчдаас нэмэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index 9bc356866..ac721ecbf 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -4057,6 +4057,7 @@ "sending": "Sender…", "sent": "Sendt", "view": "Vis", + "template_saved": "Mal lagret", "schedule_anyway": "Planlegg likevel", "schedule_warnings_title": "Planlegge denne meldingen likevel?", "picker_button": "Legg til fra kontakter", diff --git a/locales/nl/common.json b/locales/nl/common.json index 4a0ad4bfa..b1c8f5153 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -4057,6 +4057,7 @@ "sending": "Bezig met verzenden…", "sent": "Verzonden", "view": "Bekijken", + "template_saved": "Sjabloon opgeslagen", "schedule_anyway": "Toch plannen", "schedule_warnings_title": "Dit bericht toch plannen?", "picker_button": "Toevoegen uit contacten", diff --git a/locales/pl/common.json b/locales/pl/common.json index d6d4d3c72..9b3d502ba 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -4057,6 +4057,7 @@ "sending": "Wysyłanie…", "sent": "Wysłano", "view": "Pokaż", + "template_saved": "Szablon zapisany", "schedule_anyway": "Zaplanuj mimo to", "schedule_warnings_title": "Zaplanować tę wiadomość mimo to?", "picker_button": "Dodaj z kontaktów", diff --git a/locales/pt/common.json b/locales/pt/common.json index d1c854248..2be6ca1e5 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -4057,6 +4057,7 @@ "sending": "Enviando…", "sent": "Enviado", "view": "Ver", + "template_saved": "Modelo guardado", "schedule_anyway": "Agendar mesmo assim", "schedule_warnings_title": "Agendar esta mensagem mesmo assim?", "picker_button": "Adicionar dos contatos", diff --git a/locales/ro/common.json b/locales/ro/common.json index c51e3a359..2a15d7ace 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -4057,6 +4057,7 @@ "sending": "Se trimite…", "sent": "Trimis", "view": "Vezi", + "template_saved": "Șablon salvat", "schedule_anyway": "Programează oricum", "schedule_warnings_title": "Programați totuși acest mesaj?", "picker_button": "Adaugă din contacte", diff --git a/locales/ru/common.json b/locales/ru/common.json index ef7a4111c..54130b2f0 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -4057,6 +4057,7 @@ "sending": "Отправка…", "sent": "Отправлено", "view": "Открыть", + "template_saved": "Шаблон сохранён", "schedule_anyway": "Всё равно запланировать", "schedule_warnings_title": "Всё равно запланировать это письмо?", "picker_button": "Добавить из контактов", diff --git a/locales/sk/common.json b/locales/sk/common.json index 489a666d6..f353bbf24 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -4057,6 +4057,7 @@ "sending": "Odosielanie…", "sent": "Odoslané", "view": "Zobraziť", + "template_saved": "Šablóna uložená", "schedule_anyway": "Naplánovať aj tak", "schedule_warnings_title": "Naplánovať túto správu aj tak?", "picker_button": "Pridať z kontaktov", diff --git a/locales/th/common.json b/locales/th/common.json index a4f832b21..de7bd0fdd 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1233,6 +1233,7 @@ "sending": "กำลังส่ง…", "sent": "ส่งแล้ว", "view": "ดู", + "template_saved": "บันทึกเทมเพลตแล้ว", "schedule_anyway": "กำหนดเวลาต่อไป", "schedule_warnings_title": "กำหนดเวลาส่งข้อความนี้ต่อไปหรือไม่", "picker_button": "เพิ่มจากผู้ติดต่อ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 1b5259bc7..454d42d0c 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -4057,6 +4057,7 @@ "sending": "Gönderiliyor…", "sent": "Gönderildi", "view": "Görüntüle", + "template_saved": "Şablon kaydedildi", "schedule_anyway": "Yine de zamanla", "schedule_warnings_title": "Bu ileti yine de zamanlansın mı?", "picker_button": "Kişilerden ekle", diff --git a/locales/uk/common.json b/locales/uk/common.json index 32c0b46c4..00a6adaa8 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -4057,6 +4057,7 @@ "sending": "Надсилання…", "sent": "Надіслано", "view": "Переглянути", + "template_saved": "Шаблон збережено", "schedule_anyway": "Усе одно запланувати", "schedule_warnings_title": "Усе одно запланувати цей лист?", "picker_button": "Додати з контактів", diff --git a/locales/vi/common.json b/locales/vi/common.json index 57c5bf4e3..47115845b 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -4069,6 +4069,7 @@ "sending": "Đang gửi…", "sent": "Đã gửi", "view": "Xem", + "template_saved": "Đã lưu mẫu", "schedule_anyway": "Vẫn lên lịch", "schedule_warnings_title": "Vẫn lên lịch gửi thư này?", "picker_button": "Thêm từ danh bạ", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index c80aa8724..0c1428b61 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4057,6 +4057,7 @@ "sending": "正在傳送…", "sent": "已傳送", "view": "檢視", + "template_saved": "範本已儲存", "schedule_anyway": "仍然排程", "schedule_warnings_title": "仍然排程這封郵件?", "picker_button": "從聯絡人新增", diff --git a/locales/zh/common.json b/locales/zh/common.json index 4cdb42e74..d13a22fc0 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -4057,6 +4057,7 @@ "sending": "正在发送…", "sent": "已发送", "view": "查看", + "template_saved": "模板已保存", "schedule_anyway": "仍然计划发送", "schedule_warnings_title": "仍然计划发送这封邮件?", "picker_button": "从联系人添加", From a2f5bad57211a3b972c84b25f99b9eebe9d9ef2f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:09:16 +0100 Subject: [PATCH 28/50] fix: close the details panel on the next message and show no delivery time for mail received before it was sent --- .../viewer-details-per-message.test.tsx | 66 +++++++++++++++++++ components/email/email-viewer.tsx | 12 +++- 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 components/email/__tests__/viewer-details-per-message.test.tsx diff --git a/components/email/__tests__/viewer-details-per-message.test.tsx b/components/email/__tests__/viewer-details-per-message.test.tsx new file mode 100644 index 000000000..953d760bc --- /dev/null +++ b/components/email/__tests__/viewer-details-per-message.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// mail-7: "Show details" stayed open for every message opened after it. +// mail-29: a mail received before it was sent showed a delivery time. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const make = (id: string, sentAt: string, receivedAt: string) => ({ + id, blobId: `b-${id}`, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Sam', email: 'sam@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: `Message ${id}`, sentAt, receivedAt, preview: 'Hello', + hasAttachment: false, size: 100, textBody: [], htmlBody: [], attachments: [], bodyValues: {}, +}) as unknown as Email; + +const viewer = (email: Email) => ( + +); + +describe('message details panel', () => { + it('closes when another message opens', () => { + const first = make('e1', '2026-11-03T10:00:00Z', '2026-11-03T10:00:05Z'); + const second = make('e2', '2026-11-03T11:00:00Z', '2026-11-03T11:00:05Z'); + const { rerender } = render(viewer(first)); + act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); }); + expect(screen.queryByText('details.recipients_routing')).not.toBeNull(); + rerender(viewer(second)); + expect(screen.queryByText('details.recipients_routing')).toBeNull(); + // And back on the first one it is closed too: it was opened for that view. + rerender(viewer(first)); + expect(screen.queryByText('details.recipients_routing')).toBeNull(); + }); + + it('shows no delivery time for a mail received before it was sent', () => { + render(viewer(make('e3', '2026-11-03T21:40:00Z', '2026-11-03T20:40:00Z'))); + act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); }); + expect(screen.queryByText(/details\.delivery_time/i)).toBeNull(); + expect(document.body.textContent).not.toMatch(/delivery_time/); + }); + + it('still shows a real delivery time', () => { + render(viewer(make('e4', '2026-11-03T20:40:00Z', '2026-11-03T21:40:00Z'))); + act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); }); + expect(document.body.textContent).toMatch(/delivery_time/); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index b18c7c983..5d6ece619 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1670,7 +1670,13 @@ export function EmailViewer({ }, [onRescheduleScheduled, t, tComposer, scheduledSendAt, timeFormat]); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const { startTour } = useTour(); - const [showFullHeaders, setShowFullHeaders] = useState(false); + // The details panel belongs to the message it was opened on: the next + // message opens without it (mail-7). + const shownEmailId = email?.id ?? null; + const [details, setDetails] = useState<{ emailId: string | null; open: boolean }>({ emailId: shownEmailId, open: false }); + if (details.emailId !== shownEmailId) setDetails({ emailId: shownEmailId, open: false }); + const showFullHeaders = details.open && details.emailId === shownEmailId; + const setShowFullHeaders = (open: boolean) => setDetails({ emailId: shownEmailId, open }); const [isDownloadingAll, setIsDownloadingAll] = useState(false); const [imageThumbUrls, setImageThumbUrls] = useState>({}); const [allowExternalContent, setAllowExternalContent] = useState(false); @@ -5516,8 +5522,10 @@ ${printWatermark ? printWatermark.html : ''} }; const replyToDifferent = !!email.replyTo?.length && (!email.from || email.replyTo[0].email !== email.from[0]?.email); + // Received before it was sent means a wrong clock somewhere, not a + // delivery time (mail-29). const deliveryDeltaMs = email.sentAt && email.receivedAt - ? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime()) + ? new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime() : 0; const formatDelta = (diff: number) => { const minutes = Math.floor(diff / 60000); From 8be420b41ea460baff5de391e3b900d6e2cd23c5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:11:54 +0100 Subject: [PATCH 29/50] fix: keep a click in a reply above the folded quote --- .../__tests__/reply-quote-collapse.test.ts | 48 ++++++++++++++- components/email/reply-quote-collapse.ts | 58 ++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/components/email/__tests__/reply-quote-collapse.test.ts b/components/email/__tests__/reply-quote-collapse.test.ts index 86c22986c..eeb00d1a8 100644 --- a/components/email/__tests__/reply-quote-collapse.test.ts +++ b/components/email/__tests__/reply-quote-collapse.test.ts @@ -5,7 +5,7 @@ import Paragraph from '@tiptap/extension-paragraph'; import { trailingParagraphBreakRule } from '../paragraph-breaks'; import { QuotedHtml, buildQuotedHtmlBlock, serializeEditorContent } from '../quoted-html'; import { SignatureBlock, buildSignatureBlock } from '../signature-block'; -import { ReplyQuoteCollapse, markReplyQuoteHeader } from '../reply-quote-collapse'; +import { ReplyQuoteCollapse, markReplyQuoteHeader, userTextEnd } from '../reply-quote-collapse'; const HEADER = markReplyQuoteHeader('
    On Monday, Bob wrote:
    '); const ORIGINAL = '

    Original message

    Earlier reply
    '; @@ -168,3 +168,49 @@ describe('reply quote visibility', () => { expect(toggle()).toHaveAttribute('aria-expanded', 'false'); }); }); + +// compose-p2-3: a click under the folded quote used to put the caret on the +// empty last line, below the hidden original, where the text went unseen. +describe('a click in a reply with a folded quote', () => { + const click = (e: Editor, pos: number) => { + let handled = false; + e.view.someProp('handleClick', (handler) => { handled = handler(e.view, pos, new MouseEvent('click')) || handled; return handled; }); + return handled; + }; + const lastPos = (e: Editor) => e.state.doc.content.size - 1; + + it('puts the caret at the end of the text above the quote', () => { + const e = mount(); + expect(e.state.doc.lastChild?.type.name).toBe('paragraph'); + expect(click(e, lastPos(e))).toBe(true); + expect(e.state.selection.from).toBe(1 + 'My response'.length); + expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + }); + + it('keeps the click where it was once the quote is shown', () => { + const e = mount(); + toggle().click(); + expect(click(e, lastPos(e))).toBe(false); + }); + + it('keeps a click on a line the user can see below the quote', () => { + const e = mount(`

    My response

    ${HEADER}${QUOTE}

    Postscript

    `); + expect(click(e, lastPos(e) - 2)).toBe(false); + }); + + it('keeps a click in the text above the quote', () => { + const e = mount(); + expect(click(e, 3)).toBe(false); + }); + + it.each(['above', 'below'])('ends the text before a signature %s the quote', (position) => { + const signature = buildSignatureBlock('My signature'); + const e = mount(`

    My response

    ${position === 'above' ? signature : ''}${HEADER}${QUOTE}${position === 'below' ? signature : ''}`); + expect(userTextEnd(e.state)).toBe(1 + 'My response'.length); + }); + + it('has no end of text in a body with neither quote nor signature', () => { + const e = mount('

    Just text

    '); + expect(userTextEnd(e.state)).toBeNull(); + }); +}); diff --git a/components/email/reply-quote-collapse.ts b/components/email/reply-quote-collapse.ts index 5dbd658f2..b97485888 100644 --- a/components/email/reply-quote-collapse.ts +++ b/components/email/reply-quote-collapse.ts @@ -2,7 +2,7 @@ import { Extension } from '@tiptap/core'; import type { Node as PMNode } from '@tiptap/pm/model'; -import { Plugin, PluginKey, Selection } from '@tiptap/pm/state'; +import { Plugin, PluginKey, Selection, TextSelection, type EditorState } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; export const REPLY_QUOTE_START_ATTRIBUTE = 'data-reply-quote-start'; @@ -59,6 +59,55 @@ function quoteRanges(doc: PMNode): QuoteRange[] { return ranges; } +const isSignature = (node: PMNode) => node.type.name === 'signatureBlock' || Boolean(node.attrs['data-signature-block']); + +function collapsedRanges(state: EditorState): QuoteRange[] { + const expanded = quoteKey.getState(state)?.expanded ?? new Set(); + return quoteRanges(state.doc).filter((range) => !expanded.has(range.from)); +} + +/** + * Where the user's own text ends: the end of the last paragraph above the + * folded quote or the signature, whichever comes first. Null when the body + * has neither, so the end of the body is the end of the text. + */ +export function userTextEnd(state: EditorState): number | null { + const ranges = quoteRanges(state.doc); + let boundary: number | null = collapsedRanges(state)[0]?.from ?? null; + state.doc.forEach((node, pos) => { + if (isSignature(node) && (boundary === null || pos < boundary)) boundary = pos; + }); + if (boundary === null) return null; + let end: number | null = null; + state.doc.forEach((node, pos) => { + if (pos + node.nodeSize > boundary! || !node.isTextblock || isSignature(node)) return; + if (ranges.some((range) => pos >= range.from && pos < range.to)) return; + end = pos + node.nodeSize - 1; + }); + return end; +} + +/** + * A click that lands under a folded quote (on the empty line the editor keeps + * at the end) would write below the hidden original, where the user can't see + * it. It goes to the end of their own text above the quote instead + * (compose-p2-3). Lines the user can see below the quote keep their click. + */ +function clickBelowCollapsedQuote(state: EditorState, pos: number): boolean { + const ranges = collapsedRanges(state); + const first = ranges[0]; + if (!first || pos < first.from) return false; + const $pos = state.doc.resolve(pos); + const topPos = $pos.depth > 0 ? $pos.before(1) : pos; + let hiddenOrEmpty = true; + state.doc.forEach((node, nodePos) => { + if (nodePos < first.from || nodePos > topPos) return; + if (ranges.some((range) => nodePos >= range.from && nodePos < range.to)) return; + if (!node.isTextblock || isSignature(node) || node.content.size > 0) hiddenOrEmpty = false; + }); + return hiddenOrEmpty; +} + /** Visibility is a view decoration, never a document edit or an undo step. */ export const ReplyQuoteCollapse = Extension.create<{ show: string; hide: string }>({ name: 'replyQuoteCollapse', @@ -107,6 +156,13 @@ export const ReplyQuoteCollapse = Extension.create<{ show: string; hide: string }, }, props: { + handleClick(view, pos) { + if (!clickBelowCollapsedQuote(view.state, pos)) return false; + const end = userTextEnd(view.state); + if (end === null) return false; + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, end))); + return true; + }, decorations(state) { const expanded = quoteKey.getState(state)?.expanded ?? new Set(); const decorations: Decoration[] = []; From b439fd5e6a874ea81ea247a635b4400fa305e004 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:11:54 +0100 Subject: [PATCH 30/50] fix: put the cursor at the end of the text when the space under it is clicked --- .../__tests__/composer-reply-focus.test.tsx | 52 ++++++++++++++++++- components/email/email-composer.tsx | 17 +++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/components/email/__tests__/composer-reply-focus.test.tsx b/components/email/__tests__/composer-reply-focus.test.tsx index 62bb0752e..8ac680ecf 100644 --- a/components/email/__tests__/composer-reply-focus.test.tsx +++ b/components/email/__tests__/composer-reply-focus.test.tsx @@ -1,4 +1,4 @@ -import { render, act } from '@testing-library/react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; @@ -9,17 +9,27 @@ import { useSettingsStore } from '@/stores/settings-store'; const toastError = vi.hoisted(() => vi.fn()); const editorFocus = vi.hoisted(() => vi.fn()); +const caret = vi.hoisted(() => ({ textEnd: null as number | null, selected: [] as number[], focused: 0 })); // Reports a stub editor as ready, the way the real one does once mounted. vi.mock('@/components/email/rich-text-editor', () => ({ RichTextEditor: ({ onEditorReady }: { onEditorReady?: (editor: unknown) => void }) => { React.useEffect(() => { - onEditorReady?.({ commands: { focus: editorFocus, setContent: () => {} }, isDestroyed: false }); + const chain = { + setTextSelection: (pos: number) => { caret.selected.push(pos); return chain; }, + focus: () => { caret.focused += 1; return chain; }, + run: () => true, + }; + onEditorReady?.({ commands: { focus: editorFocus, setContent: () => {} }, isDestroyed: false, isEditable: true, state: {}, chain: () => chain, on: () => {}, off: () => {} }); }, [onEditorReady]); return React.createElement('div', { 'data-testid': 'rich-text-editor' }); }, })); +vi.mock('@/components/email/reply-quote-collapse', async (importOriginal) => ({ + ...(await importOriginal()), + userTextEnd: () => caret.textEnd, +})); vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); @@ -234,3 +244,41 @@ describe('composer initial focus', () => { expect(document.activeElement?.tagName).toBe('TEXTAREA'); }); }); + +// compose-p2-9: the white space under the text is the message too. +describe('a click in the empty space under the text', () => { + beforeEach(() => { + editorFocus.mockClear(); + caret.textEnd = null; + caret.selected = []; + caret.focused = 0; + useSettingsStore.setState({ plainTextMode: false }); + }); + + const emptySpace = () => screen.getByTestId('rich-text-editor').parentElement!.parentElement!; + + it('puts the caret at the end of the text above the folded quote', () => { + caret.textEnd = 7; + render(); + editorFocus.mockClear(); + const event = fireEvent.mouseDown(emptySpace(), { button: 0 }); + expect(event).toBe(false); + expect(caret.selected).toEqual([7]); + expect(caret.focused).toBe(1); + }); + + it('puts the caret at the end of the body when nothing is folded', () => { + render(); + fireEvent.mouseDown(emptySpace(), { button: 0 }); + expect(editorFocus).toHaveBeenCalledWith('end'); + }); + + it('leaves clicks on buttons alone', () => { + caret.textEnd = 7; + render(); + const button = document.createElement('button'); + emptySpace().appendChild(button); + fireEvent.mouseDown(button, { button: 0 }); + expect(caret.selected).toEqual([]); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index fc292094a..6561bd670 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -69,7 +69,7 @@ import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { isFilePreviewable } from "@/lib/file-preview"; import { isEditableEventTarget } from "@/lib/keyboard"; import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; -import { markReplyQuoteHeader } from "@/components/email/reply-quote-collapse"; +import { markReplyQuoteHeader, userTextEnd } from "@/components/email/reply-quote-collapse"; import { attachmentIcon } from "@/components/email/attachment-chips"; import { attachmentsForDisplay } from "@/lib/compose/attachment-order"; import { showDraftSavedToast } from "@/lib/compose/draft-close-toast"; @@ -1455,6 +1455,19 @@ export function EmailComposer({ // when the user switches identity in "above quote" mode without rebuilding // the whole body (which would lose user edits to the surrounding draft). const editorRef = useRef(null); + // The white space under the text is the message too (compose-p2-9): a + // click there puts the caret at the end of the user's text, above a folded + // quote and the signature, instead of dropping the focus. + const focusBodyFromEmptySpace = (event: React.MouseEvent) => { + const editor = editorRef.current; + const target = event.target as HTMLElement; + if (event.button !== 0 || !editor || editor.isDestroyed || !editor.isEditable) return; + if (target.closest('.ProseMirror, button, input, textarea, select, a, label, [role="toolbar"], [role="dialog"], [data-plugin-slot]')) return; + event.preventDefault(); + const end = userTextEnd(editor.state); + if (end === null) editor.commands.focus('end'); + else editor.chain().setTextSelection(end).focus().run(); + }; // Set while the composer itself swaps the signature in the editor. const signatureSwapRef = useRef(false); // Set once a reply's body got its initial focus, so switching formats later @@ -5503,7 +5516,7 @@ export function EmailComposer({
-
+
{wantsPluginDraft && ( )} From 0da07335cc3c25c1a989a743942fb12bdcaac739 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:17:27 +0100 Subject: [PATCH 31/50] fix: give every message of a conversation the same header with Reply, Reply all and Forward, and open the newest one --- .../conversation-pane-headers.test.tsx | 130 +++++++++++++ components/email/email-viewer.tsx | 178 ++++++++++++++---- components/mail/mail-app.tsx | 4 + components/pro/pro-email-tab-body.tsx | 22 ++- 4 files changed, 295 insertions(+), 39 deletions(-) create mode 100644 components/email/__tests__/conversation-pane-headers.test.tsx diff --git a/components/email/__tests__/conversation-pane-headers.test.tsx b/components/email/__tests__/conversation-pane-headers.test.tsx new file mode 100644 index 000000000..a08d817c2 --- /dev/null +++ b/components/email/__tests__/conversation-pane-headers.test.tsx @@ -0,0 +1,130 @@ +import { render, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useSettingsStore } from '@/stores/settings-store'; + +// mail-p2-4: in the default layout's conversation every message has the same +// header (sender, short date with the full one on hover, Reply, Reply all and +// Forward), the subject line has no date of its own, and the newest message +// is open, also when it is your own reply. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { EmailViewer, conversationOpenPlan, conversationShortDate } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +function mail(n: number, overrides: Partial = {}): Email { + return { + id: `m${n}`, blobId: `b${n}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: `Sender ${n}`, email: `s${n}@example.com` }], to: [{ name: 'Me', email: 'me@example.com' }], + subject: 'Offsite agenda', receivedAt: `2026-01-0${n}T10:00:00Z`, sentAt: `2026-01-0${n}T10:00:00Z`, preview: `Preview ${n}`, + hasAttachment: false, size: 100, ...overrides, + } as unknown as Email; +} +function withBody(e: Email, text: string): Email { + return { + ...e, textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [], attachments: [], + bodyValues: { '1': { value: text, isTruncated: false } }, + } as unknown as Email; +} + +const expandedIds = (c: HTMLElement) => + [...c.querySelectorAll('[data-mv-conversation-expanded]')].map((e) => e.getAttribute('data-email-id')); + +describe('conversation headers in the default layout', () => { + beforeEach(() => { + useSettingsStore.setState({ interfaceLayout: 'default', markAsReadDelay: 0, timeFormat: '24h' }); + }); + + it('gives the open message the date and Reply, Reply all, Forward on its sender line', () => { + const open = withBody(mail(3), 'Newest body'); + const onReply = vi.fn(); + const onReplyAll = vi.fn(); + const onForward = vi.fn(); + const { container } = render( + , + ); + const header = container.querySelector('[data-mv-open-header]') as HTMLElement; + const actions = header.querySelector('[data-mv-sender-actions]') as HTMLElement; + expect(actions).not.toBeNull(); + fireEvent.click(within(actions).getByRole('button', { name: 'reply' })); + fireEvent.click(within(actions).getByRole('button', { name: 'reply_all' })); + fireEvent.click(within(actions).getByRole('button', { name: 'forward' })); + expect(onReply).toHaveBeenCalled(); + expect(onReplyAll).toHaveBeenCalled(); + expect(onForward).toHaveBeenCalled(); + const date = actions.querySelector('time[data-conversation-date]') as HTMLElement; + expect(date.getAttribute('title')!.length).toBeGreaterThan(date.textContent!.length); + // No second date on the subject line. + expect(container.querySelectorAll('time[data-conversation-date]').length).toBe(3); + expect(container.querySelector('.sm\\:block .whitespace-nowrap')).toBeNull(); + }); + + it('gives an unfolded older message Forward too, in the same short date style', () => { + const open = withBody(mail(3), 'Newest body'); + const onForward = vi.fn(); + const older = withBody(mail(2, { keywords: {} }), 'Older body'); + const { container } = render( + , + ); + const card = container.querySelector('[data-mv-conversation-expanded][data-email-id="m2"]') as HTMLElement; + fireEvent.click(within(card).getByRole('button', { name: 'forward' })); + expect(onForward).toHaveBeenCalledWith(expect.objectContaining({ id: 'm2' })); + // Not Gmail's "(n days ago)". + expect(card.querySelector('time')!.textContent).not.toMatch(/\(/); + }); + + it('opens your own newer reply and folds the read message you clicked', () => { + const open = withBody(mail(2), 'Their message'); + const mine = mail(3, { from: [{ name: 'Me', email: 'me@example.com' }], mailboxIds: { sent: true } }); + const { container } = render( + , + ); + expect(expandedIds(container)).toEqual(['m3']); + expect(container.querySelector('[data-mv-open-folded]')).not.toBeNull(); + expect(container.querySelector('[data-mv-open-header]')).toBeNull(); + }); +}); + +describe('conversationOpenPlan', () => { + it('keeps the open message when it is the newest', () => { + expect(conversationOpenPlan([mail(1), mail(2)], 'm2')).toEqual({ expanded: [], foldOpen: false, newestId: null }); + }); + it('keeps an unread open message open beside the newest', () => { + const plan = conversationOpenPlan([mail(1, { keywords: {} }), mail(2)], 'm1'); + expect(plan).toEqual({ expanded: ['m2'], foldOpen: false, newestId: 'm2' }); + }); + it('never picks a draft or a scheduled message as the newest', () => { + const plan = conversationOpenPlan([mail(1), mail(2, { keywords: { $draft: true, $seen: true } }), mail(3, { isScheduled: true })], 'm1'); + expect(plan.newestId).toBeNull(); + }); +}); + +describe('conversationShortDate', () => { + const now = new Date('2026-11-04T12:00:00Z'); + it('has no relative part and no year this year', () => { + const text = conversationShortDate('2026-10-21T13:40:00Z', '24h', now); + expect(text).not.toMatch(/2026|ago|\(/); + expect(text).toMatch(/21/); + }); + it('has the year for older mail', () => { + expect(conversationShortDate('2025-10-21T13:40:00Z', '24h', now)).toMatch(/2025/); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 5d6ece619..93be552e5 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -303,6 +303,8 @@ interface EmailViewerProps { /** Mountain View: reply to one message of the conversation. */ onReplyConversationEmail?: (email: Email) => void; onReplyAllConversationEmail?: (email: Email) => void; + /** Forward on a message of the conversation. */ + onForwardConversationEmail?: (email: Email) => void; /** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */ mvInlineComposer?: React.ReactNode; /** A reply or forward to this message is open under it: no quick reply box as well. */ @@ -784,11 +786,69 @@ function mvSenderDate(date: Date | string, timeFormat: '12h' | '24h'): string { return ago ? `${when} (${ago})` : when; } +/** + * The date on a conversation message in the default layout: the time today, + * "Oct 21, 13:40" this year, "Oct 21, 2025" before. The full date is on hover. + */ +export function conversationShortDate(date: Date | string, timeFormat: '12h' | '24h', now: Date = new Date()): string { + const d = typeof date === 'string' ? new Date(date) : date; + const zone = getEffectiveTimeZone(); + const day = Number.isNaN(d.getTime()) ? null : getWallClock(d, zone); + const today = getWallClock(now, zone); + if (day && day.year === today.year && day.month === today.month && day.day === today.day) return formatDateTime(d, timeFormat); + if (day && day.year === today.year) return formatDateTime(d, timeFormat, { month: 'short', day: 'numeric' }); + return formatDateTime(d, timeFormat, { year: 'numeric', month: 'short', day: 'numeric', dateOnly: true }); +} + +function conversationFullDate(date: Date | string, timeFormat: '12h' | '24h'): string { + return formatDateTime(date, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); +} + +/** + * The date on a conversation message's sender line: Mountain View keeps + * Gmail's "Oct 21, 2026, 13:40 (14 days ago)", the phone the list's date, the + * default layout the short date. Every style has the full date on hover. + */ +/** + * How a conversation opens around the open message (mail-p2-4): its unread + * messages unfolded, as Gmail does, and the newest one too, also when it is + * your own reply. The open message folds when a newer one is shown and it had + * been read already. Drafts and messages still waiting to be sent are never + * "the newest". + */ +export function conversationOpenPlan(conversation: Email[], openId: string): { expanded: string[]; foldOpen: boolean; newestId: string | null } { + const expanded = conversation.filter((e) => e.id !== openId && !e.keywords?.$seen).map((e) => e.id); + const open = conversation.find((e) => e.id === openId); + const candidates = conversation.filter((e) => !e.keywords?.$draft && !e.isScheduled); + const newest = candidates[candidates.length - 1]; + if (!open || !newest || newest.id === openId) return { expanded, foldOpen: false, newestId: null }; + if (!expanded.includes(newest.id)) expanded.push(newest.id); + return { expanded, foldOpen: !!open.keywords?.$seen, newestId: newest.id }; +} + +/** A button's tooltip with the key the menus and the shortcut help show (mail-p2-13). */ +function useKeyTip() { + const shortcut = useMessageActionShortcut(); + return (label: string, action: MessageActionKey) => { + const key = shortcut(action); + return key ? `${label} (${key})` : label; + }; +} + +function ConversationDate({ date, timeFormat, variant, className }: { date: string; timeFormat: '12h' | '24h'; variant: 'mv' | 'list' | 'short'; className?: string }) { + const shown = variant === 'mv' ? mvSenderDate(date, timeFormat) : variant === 'list' ? formatDate(date) : conversationShortDate(date, timeFormat); + return ( + + ); +} + /** * One message of a Mountain View conversation, folded to its sender, date and * snippet. Clicking it unfolds the message in place. */ -function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; compact?: boolean }) { +function MvConversationCard({ email, onOpen, timeFormat, compact, shortDates }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; compact?: boolean; shortDates?: boolean }) { const tSidebar = useTranslations('sidebar'); const sender = email.from?.[0]; const senderWarning = useSenderWarning(email); @@ -813,9 +873,7 @@ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Ema {email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')} )} - - {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} - + {cleanPreview(email.preview)} @@ -875,22 +933,28 @@ export function MvExpandedConversationCard({ onMarkRead, onReply, onReplyAll, + onForward, onDownloadAttachment, blobSource, compact, + shortDates, }: { email: Email; timeFormat: '12h' | '24h'; compact?: boolean; + /** The default layout's short date instead of Gmail's long one. */ + shortDates?: boolean; onCollapse: (email: Email) => void; onLoadBody?: (email: Email) => void; onMarkRead?: (email: Email) => void; onReply?: (email: Email) => void; onReplyAll?: (email: Email) => void; + onForward?: (email: Email) => void; onDownloadAttachment?: ConversationAttachmentHandler; blobSource?: EmailBlobContext; }) { const t = useTranslations('email_viewer'); + const keyTip = useKeyTip(); const tSidebar = useTranslations('sidebar'); const externalContent = useConversationExternalContent(); const sender = email.from?.[0]; @@ -943,9 +1007,7 @@ export function MvExpandedConversationCard({ - - {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} - + - {(onReply || onReplyAll) && !email.keywords?.$draft && !email.isScheduled && ( + {(onReply || onReplyAll || onForward) && !email.keywords?.$draft && !email.isScheduled && ( {onReply && ( )} + {onForward && ( + + )} )}
@@ -1025,9 +1098,10 @@ export function MvExpandedConversationCard({ * run of folded messages before it shows only its first and last message * around a count, as Gmail does, and the count unfolds the rest. */ -export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun, expandedIds, renderExpanded, compact }: { +export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun, expandedIds, renderExpanded, compact, shortDates }: { emails: Email[]; compact?: boolean; + shortDates?: boolean; /** A folded message was clicked. */ onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; @@ -1041,7 +1115,7 @@ export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun, const isExpanded = (e: Email) => !!renderExpanded && !!expandedIds?.has(e.id); const renderOne = (e: Email) => isExpanded(e) ? {renderExpanded!(e)} - : ; + : ; const middle = emails.slice(1, -1); if (!collapseLongRun || showAll || emails.length <= 3 || middle.some(isExpanded)) { return <>{emails.map(renderOne)}; @@ -1377,6 +1451,7 @@ export function EmailViewer({ onMarkConversationEmailRead, onReplyConversationEmail, onReplyAllConversationEmail, + onForwardConversationEmail, mvInlineComposer, replyComposerOpen, onEditDraft, @@ -1539,6 +1614,11 @@ export function EmailViewer({ const mvExpandedIdsRef = useRef(mvExpandedIds); mvExpandedIdsRef.current = mvExpandedIds; const mvExpandedThreadRef = useRef(null); + // The open message folds too, but only inside a conversation. + const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false); + useEffect(() => setMvOpenCollapsed(false), [email?.id]); + // The newest message to unfold and scroll to once the conversation is drawn. + const [mvNewestToShow, setMvNewestToShow] = useState(null); useEffect(() => { if (!mvConversation || mvConversation.length === 0 || !email) { mvExpandedThreadRef.current = null; @@ -1547,8 +1627,25 @@ export function EmailViewer({ const key = mvConversation[0].threadId; if (mvExpandedThreadRef.current === key) return; mvExpandedThreadRef.current = key; - setMvExpandedIds(new Set(mvConversation.filter((e) => e.id !== email.id && !e.keywords?.$seen).map((e) => e.id))); + const plan = conversationOpenPlan(mvConversation, email.id); + setMvExpandedIds(new Set(plan.expanded)); + if (plan.foldOpen) setMvOpenCollapsed(true); + setMvNewestToShow(plan.newestId); }, [mvConversation, email]); + useEffect(() => { + if (!mvNewestToShow) return; + const target = mvNewestToShow; + setMvNewestToShow(null); + const frame = requestAnimationFrame(() => { + const card = document.querySelector(`[data-mv-conversation-expanded][data-email-id="${CSS.escape(target)}"]`); + const pane = card?.closest('[data-reader-scroll]'); + if (!card || !pane) return; + // Bring the newest message's start into view when it's below the fold. + const cardTop = card.getBoundingClientRect().top - pane.getBoundingClientRect().top; + if (cardTop > pane.clientHeight - 96) pane.scrollTop += cardTop - 8; + }); + return () => cancelAnimationFrame(frame); + }, [mvNewestToShow]); const toggleMvExpanded = useCallback((target: Email) => { emitThreadExpand(target.threadId, [target], !mvExpandedIdsRef.current.has(target.id)); setMvExpandedIds((prev) => { @@ -1558,10 +1655,11 @@ export function EmailViewer({ return next; }); }, []); - // The open message folds too, but only inside a conversation. - const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false); - useEffect(() => setMvOpenCollapsed(false), [email?.id]); const mvOpenFolded = mvOpenIndex >= 0 && mvOpenCollapsed; + // The default layout's conversation (not Mountain View): the open message + // gets the same header as the others, with its date and its reply actions + // on the sender's line, and the subject line no date (mail-p2-4). + const inConversation = mvOpenIndex >= 0 && !mvViewer; // Folding or unfolding the open message of a conversation, told to plugins // like any other conversation message (onThreadExpand). const setMvOpenFolded = (folded: boolean) => { @@ -1581,6 +1679,8 @@ export function EmailViewer({ onMarkRead={onMarkConversationEmailRead} onReply={onReplyConversationEmail} onReplyAll={onReplyAllConversationEmail} + onForward={onForwardConversationEmail} + shortDates={!mvViewer} blobSource={target.sourceClientAccountId || target.sourceAccountId ? resolveEmailBlobContext(target, client) : blobSource ?? resolveEmailBlobContext(target, client)} @@ -5112,7 +5212,7 @@ ${printWatermark ? printWatermark.html : ''} {/* Date/time on the right of subject row - hidden on mobile, shown next to sender. Mountain View puts it on the sender's row instead. The size is in the details. */} -
+
{formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} @@ -5135,7 +5235,7 @@ ${printWatermark ? printWatermark.html : ''} {/* Email Content Area. Mountain View reads the conversation on one plain sheet, so the reply buttons under the message sit on the page colour rather than on a tinted strip. */} -
+
{/* Above the messages of the open conversation. */} @@ -5146,14 +5246,14 @@ ${printWatermark ? printWatermark.html : ''} {/* Mountain View: the conversation's earlier messages, folded. */} {mvBefore.length > 0 && (
- +
)} {/* Mountain View: the open message folded like the rest of the conversation. */} {mvOpenFolded && email ? (
- setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} /> + setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} shortDates={!mvViewer} />
) : mvAwaitingConversation ? (
@@ -5213,6 +5313,9 @@ ${printWatermark ? printWatermark.html : ''} {mvViewer && sender?.email && sender?.name && ( <{sender.email}> )} + {inConversation && sender?.email && sender?.name && ( + <> + )} @@ -5230,12 +5333,12 @@ ${printWatermark ? printWatermark.html : ''} }} /> )} - {mvViewer && ( + {(mvViewer || inConversation) && ( - - {mvSenderDate(emailDisplayDate(email), timeFormat)} + + - + )} {!isDraft && !isScheduled && ( )} + {inConversation && !isDraft && !isScheduled && onForward && ( + + )} )}
- {/* Email address under name (on the name's line under Mountain View) */} - {!mvViewer && sender?.email && sender?.name && ( + {/* Email address under name (on the name's line under Mountain View and in a conversation) */} + {!mvViewer && !inConversation && sender?.email && sender?.name && (
)}
@@ -5482,7 +5596,7 @@ ${printWatermark ? printWatermark.html : ''} type="button" onClick={() => onReply?.()} className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" - title={t('tooltips.reply')} + title={withShortcut(t('reply'), 'reply')} aria-label={t('reply')} > @@ -5492,7 +5606,7 @@ ${printWatermark ? printWatermark.html : ''} type="button" onClick={onReplyAll} className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" - title={t('tooltips.reply_all')} + title={withShortcut(t('reply_all'), 'reply_all')} aria-label={t('reply_all')} > @@ -6180,7 +6294,7 @@ ${printWatermark ? printWatermark.html : ''} {/* Mountain View: the conversation's later messages, folded. */} {mvAfter.length > 0 && (
- +
)} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 178e10bf7..0c5061e4c 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -6238,6 +6238,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const full = e.bodyValues ? e : await handleLoadConversationEmail(e); await handleConversationReplyAll(full ?? e); } : undefined} + onForwardConversationEmail={readerConversationView ? async (e) => { + const full = e.bodyValues ? e : await handleLoadConversationEmail(e); + await handleConversationForward(full ?? e); + } : undefined} mvInlineComposer={mvInlineReply ? {composerNode} : undefined} replyComposerOpen={dockedReply} onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined} diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 55bace5e8..9ff6ada84 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -347,16 +347,20 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, const conversationView = !!conversation && !disableThreading; const viewConversation = useViewConversation({ enabled: conversationView, email, source: blobSource, onChanged: onMailChanged }); - const handleForward = useCallback(() => { - if (!email) return; + const forwardMessage = useCallback((target: Email) => { openComposeTab({ mode: 'forward', - replyTo: buildReplyContext(email, writeSource), - sourceEmailId: email.id, + replyTo: buildReplyContext(target, writeSource), + sourceEmailId: target.id, sourceEmailSource: writeSource, - title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')), - }, { source: email }); - }, [email, openComposeTab, t, writeSource]); + title: buildForwardSubject(target.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')), + }, { source: target }); + }, [openComposeTab, t, writeSource]); + + const handleForward = useCallback(() => { + if (!email) return; + forwardMessage(email); + }, [email, forwardMessage]); // Mirrors handleForward, but attaches the original as a message/rfc822 // file instead of quoting it inline - see lib/forward-as-attachment.ts. @@ -673,6 +677,10 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, const full = e.bodyValues ? e : await viewConversation.loadBody(e); replyAllToMessage(full ?? e); } : undefined} + onForwardConversationEmail={conversationView ? async (e) => { + const full = e.bodyValues ? e : await viewConversation.loadBody(e); + forwardMessage(full ?? e); + } : undefined} blobSource={blobSource} isLoading={isLoading} onReply={handleReply} From 05555bc88aacb0a2b3de2ef9c60b38844902bc64 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:17:27 +0100 Subject: [PATCH 32/50] fix: read a conversation oldest first on the phone too, with the newest message open --- .../thread-conversation-order.test.tsx | 48 ++++++++++++++++ components/email/thread-conversation-view.tsx | 56 +++++++++++++++---- 2 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 components/email/__tests__/thread-conversation-order.test.tsx diff --git a/components/email/__tests__/thread-conversation-order.test.tsx b/components/email/__tests__/thread-conversation-order.test.tsx new file mode 100644 index 000000000..c01610280 --- /dev/null +++ b/components/email/__tests__/thread-conversation-order.test.tsx @@ -0,0 +1,48 @@ +// mail-p2-6: the phone listed a conversation newest first while every other +// layout reads it oldest first. It now reads oldest first too, with the +// newest message (your own reply as well) open at the bottom. +import { describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; + +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false })); +vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { ThreadConversationView } = await import('@/components/email/thread-conversation-view'); + +const mail = (n: number, extra: Record = {}) => ({ + id: `e${n}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: `Sender ${n}`, email: `s${n}@t.example` }], to: [{ email: 'me@example.org' }], + subject: 'Lunch', receivedAt: `2026-10-2${n}T10:00:00Z`, sentAt: `2026-10-2${n}T10:00:00Z`, size: 1, preview: `hi ${n}`, hasAttachment: false, + htmlBody: [], textBody: [], attachments: [], bodyValues: {}, ...extra, +}); + +function renderView(emails: ReturnType[]) { + return render( + {}} + mailboxRole="inbox" + />, + ); +} + +const order = (c: HTMLElement) => [...c.querySelectorAll('[data-thread-email-id]')].map((e) => e.getAttribute('data-thread-email-id')); + +describe('phone conversation order', () => { + it('lists the messages oldest first, as the thread arrives newest first', () => { + const { container } = renderView([mail(3), mail(2), mail(1)]); + expect(order(container)).toEqual(['e1', 'e2', 'e3']); + }); + + it('opens the newest message, your own reply too, and not a draft after it', () => { + const mine = mail(3, { from: [{ name: 'Me', email: 'me@example.org' }], mailboxIds: { sent: true } }); + const draft = mail(4, { keywords: { $draft: true, $seen: true } }); + const { container } = renderView([draft, mine, mail(2), mail(1)]); + const expanded = [...container.querySelectorAll('[data-thread-email-id]')] + .filter((el) => el.querySelector('[aria-expanded="true"]')) + .map((el) => el.getAttribute('data-thread-email-id')); + expect(expanded).toEqual(['e3']); + }); +}); diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index eb6c91992..cb3480049 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -204,7 +204,11 @@ export function ThreadConversationView({ }: ThreadConversationViewProps) { const t = useTranslations(); const externalContent = useConversationExternalContent(); - const emailIdList = emails.map((email) => email.id).join(','); + // Oldest first, the newest at the bottom, as on every other layout and in + // Gmail (mail-p2-6). The thread arrives newest first. + const ordered = useMemo(() => oldestFirst(emails), [emails]); + const newestId = newestReadable(ordered)?.id ?? null; + const emailIdList = ordered.map((email) => email.id).join(','); const { isMobile: summaryOnPhone, isCompact: summaryOnTablet } = useDeviceDetection(); const threadSummaryProps = useMemo( () => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [], deviceType: summaryOnPhone ? 'mobile' : summaryOnTablet ? 'tablet' : 'desktop' }), @@ -213,17 +217,19 @@ export function ThreadConversationView({ // Track which emails are expanded (most recent by default) const [expandedIds, setExpandedIds] = useState>(new Set()); + const cardsRef = useRef(null); + const scrolledThreadRef = useRef(null); - // Auto-expand most recent email AND all unread emails when thread opens + // Auto-expand the newest email (your own reply too) AND all unread emails + // when the thread opens. useEffect(() => { - if (emails.length > 0) { + if (ordered.length > 0) { const idsToExpand = new Set(); - // Always expand most recent - idsToExpand.add(emails[0].id); + if (newestId) idsToExpand.add(newestId); // Also expand all unread emails - emails.forEach(email => { + ordered.forEach(email => { if (!email.keywords?.$seen) { idsToExpand.add(email.id); } @@ -231,10 +237,21 @@ export function ThreadConversationView({ setExpandedIds(idsToExpand); } - }, [emails]); + }, [ordered, newestId]); + + // Once per conversation, show the newest message: it is at the bottom. + useEffect(() => { + const threadId = ordered[0]?.threadId ?? null; + if (!newestId || !threadId || scrolledThreadRef.current === threadId) return; + scrolledThreadRef.current = threadId; + const frame = requestAnimationFrame(() => { + cardsRef.current?.querySelector(`[data-thread-email-id="${CSS.escape(newestId)}"]`)?.scrollIntoView?.({ block: 'start' }); + }); + return () => cancelAnimationFrame(frame); + }, [ordered, newestId]); const toggleExpanded = (emailId: string) => { - const target = emails.find((e) => e.id === emailId); + const target = ordered.find((e) => e.id === emailId); if (target) emitThreadExpand(target.threadId, [target], !expandedIds.has(emailId)); setExpandedIds(prev => { const next = new Set(prev); @@ -279,13 +296,13 @@ export function ThreadConversationView({ {/* Email Cards */}
-
- {emails.map((email, index) => ( +
+ {ordered.map((email) => ( +
toggleExpanded(email.id)} onAllowExternal={() => externalContent.allow(email.id)} @@ -298,6 +315,7 @@ export function ThreadConversationView({ onMarkAsRead={onMarkAsRead} mailboxRole={mailboxRole} /> +
))}
@@ -305,6 +323,20 @@ export function ThreadConversationView({ ); } +/** A conversation's messages from the oldest to the newest. */ +export function oldestFirst(emails: Email[]): Email[] { + return [...emails].sort((a, b) => (Date.parse(a.receivedAt) || 0) - (Date.parse(b.receivedAt) || 0)); +} + +/** The newest message that isn't a draft or waiting to be sent. */ +export function newestReadable(ordered: Email[]): Email | undefined { + for (let i = ordered.length - 1; i >= 0; i--) { + const e = ordered[i]; + if (!e.keywords?.$draft && !e.isScheduled) return e; + } + return ordered[ordered.length - 1]; +} + // Individual email card component interface EmailCardProps { email: Email; From 801aa51b24ec883ff2faeca84aeeb48f9ff5982d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:20:42 +0100 Subject: [PATCH 33/50] fix: discard a draft with the bin at once and offer Undo The bin no longer asks "Discard draft?". The discard tests now expect no question: the first one checks the late delete and the Undo instead of the dialog, and the upload test that cancelled the question is gone, since there is no question to cancel. --- .../composer-discard-uploads.test.tsx | 22 +++------ .../email/__tests__/composer-discard.test.tsx | 47 +++++++++++++++---- components/email/email-composer.tsx | 40 ++++++++++++++-- .../mail-app-composer-replace.test.tsx | 21 ++++++++- components/mail/mail-app.tsx | 13 ++++- .../popout/__tests__/popout-host.test.tsx | 12 +++++ components/popout/popout-compose-app.tsx | 3 ++ components/popout/popout-host.tsx | 14 ++++-- lib/popout/channel.ts | 2 + 9 files changed, 138 insertions(+), 36 deletions(-) diff --git a/components/email/__tests__/composer-discard-uploads.test.tsx b/components/email/__tests__/composer-discard-uploads.test.tsx index 36cfc0199..cf1960591 100644 --- a/components/email/__tests__/composer-discard-uploads.test.tsx +++ b/components/email/__tests__/composer-discard-uploads.test.tsx @@ -1,5 +1,5 @@ import React, { StrictMode, useState } from 'react'; -import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextIntlClientProvider } from 'next-intl'; import { EmailComposer, type ComposerDraftData } from '@/components/email/email-composer'; @@ -134,9 +134,10 @@ async function openComposer(strict = false) { add: async (...files: File[]) => { fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files } }); await advance(); }, - dialog: (control: Control) => { + // The bin asks nothing (compose-p2-10). + discard: (control: Control) => { fireEvent.click(screen.getByTestId(control === 'toolbar' ? 'composer-discard' : 'composer-close')); - return within(screen.getByRole('alertdialog')); + expect(screen.queryByRole('alertdialog')).toBeNull(); }, reopen: async () => { act(() => reopen()); await advance(); }, }; @@ -167,7 +168,7 @@ describe('explicit Discard with pending attachment uploads', () => { void upload.mock.results[0].value.then(settled, settled); expect([...resources.files.values()].map(f => f.name)).toEqual(['A.txt']); expect(resources.progress.size).toBe(1); - fireEvent.click(c.dialog(control).getByRole('button', { name: /^Discard$/ })); await advance(); + c.discard(control); await advance(); await c.reopen(); await c.add(file('B.txt')); await complete(1); expectNewComposer(); @@ -197,17 +198,6 @@ describe('explicit Discard with pending attachment uploads', () => { expect(resources.files.size).toBe(0); expect(resources.progress.size).toBe(0); expectNewComposer(); expect(c.closed).toHaveBeenCalledOnce(); }); - - it('keeps active and queued uploads when the close/discard dialog is cancelled', async () => { - const uploaded = vi.spyOn(emailHooks.onAfterAttachmentUpload, 'emit'); - const c = await openComposer(strict); await c.add(file('A.txt'), file('C.txt')); - const dialog = c.dialog(control); - expect(UploadXHR.instances[0].abort).not.toHaveBeenCalled(); - fireEvent.click(dialog.getByRole('button', { name: /^Cancel$/ })); await advance(); - await complete(0); expect(UploadXHR.instances[1].body?.name).toBe('C.txt'); await complete(1); - expect(uploaded.mock.calls.map(([attachment]) => attachment.name)).toEqual(['A.txt', 'C.txt']); - expect(c.closed).not.toHaveBeenCalled(); expect(resources.files.size).toBe(0); expect(resources.progress.size).toBe(0); - }); }); it.each(['saveFile', 'getFile', 'deleteFile'] as const)('cancels while asynchronous %s is pending', async method => { @@ -234,7 +224,7 @@ describe('explicit Discard with pending attachment uploads', () => { await c.add(file('A.txt'), file('C.txt')); if (method === 'deleteFile') await complete(0); expect(entered).toHaveBeenCalledOnce(); - fireEvent.click(c.dialog('toolbar').getByRole('button', { name: /^Discard$/ })); await advance(); + c.discard('toolbar'); await advance(); await c.reopen(); await act(async () => { gate.resolve(); }); await advance(); await c.add(file('B.txt')); await complete(UploadXHR.instances.findIndex(xhr => xhr.body?.name === 'B.txt')); diff --git a/components/email/__tests__/composer-discard.test.tsx b/components/email/__tests__/composer-discard.test.tsx index 4df7b6d90..bc90773ee 100644 --- a/components/email/__tests__/composer-discard.test.tsx +++ b/components/email/__tests__/composer-discard.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, within } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; @@ -7,6 +7,8 @@ import { EmailComposer } from '../email-composer'; const editorKeyDown = vi.hoisted(() => vi.fn()); const toastError = vi.hoisted(() => vi.fn()); +const discardWithUndo = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/compose/draft-close-toast', () => ({ discardWithUndo, showDraftSavedToast: vi.fn() })); vi.mock('@/components/email/rich-text-editor', () => ({ RichTextEditor: () => React.createElement('div', { @@ -209,27 +211,56 @@ const REOPENED_DRAFT = { describe('composer Discard button', () => { beforeEach(() => { + discardWithUndo.mockClear(); toastError.mockClear(); updateSetting.mockClear(); editorKeyDown.mockClear(); }); - it('deletes a reopened draft without unsaved changes after confirming', async () => { + // compose-p2-10: no question; "Draft discarded" with Undo, as the toast's Discard. + it('closes at once and deletes a reopened draft once Undo is over', () => { const onDiscardDraft = vi.fn(); const onClose = vi.fn(); render(); fireEvent.click(screen.getByText('discard')); - const dialog = await screen.findByRole('alertdialog'); - expect(within(dialog).getByText('discard_draft_title')).toBeInTheDocument(); - // The user already chose to discard; there is nothing to save. - expect(within(dialog).queryByText('save')).toBeNull(); - - fireEvent.click(within(dialog).getByText('discard')); + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(onClose).toHaveBeenCalledTimes(1); + expect(discardWithUndo).toHaveBeenCalledTimes(1); + const [commit, labels] = discardWithUndo.mock.calls[0]; + expect(labels).toEqual({ discarded: 'draft_discarded', undo: 'undo' }); + // Not before the Undo time is over. + expect(onDiscardDraft).not.toHaveBeenCalled(); + commit(); expect(onDiscardDraft).toHaveBeenCalledWith('draft-1', { accountId: undefined, localAccountId: undefined }); }); + it('brings the text back on Undo, and deletes nothing', () => { + const onDiscardDraft = vi.fn(); + const onRestoreDiscarded = vi.fn(); + render( {}} />); + + fireEvent.click(screen.getByText('discard')); + const restore = discardWithUndo.mock.calls[0][2] as () => void; + restore(); + + expect(onRestoreDiscarded).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Lunch', draftId: 'draft-1', to: 'bob@example.com' })); + expect(onDiscardDraft).not.toHaveBeenCalled(); + }); + + it('has the main window say it for a composer in its own window', () => { + const onDraftDiscarded = vi.fn(); + const onDiscardDraft = vi.fn(); + render( {}} />); + + fireEvent.click(screen.getByText('discard')); + + expect(onDraftDiscarded).toHaveBeenCalledWith('draft-1', { accountId: undefined, localAccountId: undefined }); + expect(discardWithUndo).not.toHaveBeenCalled(); + expect(onDiscardDraft).not.toHaveBeenCalled(); + }); + it('closes an empty, never saved composer without asking', () => { const onDiscardDraft = vi.fn(); const onClose = vi.fn(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 6561bd670..baa7e79ad 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -72,7 +72,7 @@ import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; import { markReplyQuoteHeader, userTextEnd } from "@/components/email/reply-quote-collapse"; import { attachmentIcon } from "@/components/email/attachment-chips"; import { attachmentsForDisplay } from "@/lib/compose/attachment-order"; -import { showDraftSavedToast } from "@/lib/compose/draft-close-toast"; +import { discardWithUndo, showDraftSavedToast } from "@/lib/compose/draft-close-toast"; import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; import { containsEmbeddedSignature } from "@/components/email/signature-block"; import { @@ -411,6 +411,11 @@ interface EmailComposerProps { */ onDiscardDraft?: (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => void; onSaveState?: (data: ComposerDraftData) => void; + /** + * Undo after the bin: opens a composer again with what this one held. The + * draft on the server is still there then. + */ + onRestoreDiscarded?: (data: ComposerDraftData) => void; /** * Offers "Open in new window". Called in the click, since a browser only * opens a window for a user action: the host opens it and answers with what @@ -444,6 +449,8 @@ interface EmailComposerProps { * this one is gone before a toast here could be read. */ onDraftKept?: (draftId: string, where: { accountId?: string; localAccountId?: string }) => void; + /** The bin closed the window: another window says "Draft discarded" with Undo, and deletes it after. */ + onDraftDiscarded?: (draftId: string, where: { accountId?: string; localAccountId?: string }) => void; }; /** The subject as typed, for a host that shows it (a Pro compose tab's title). */ onSubjectChange?: (subject: string) => void; @@ -606,6 +613,7 @@ export function EmailComposer({ requestCloseRef, onDiscardDraft, onSaveState, + onRestoreDiscarded, popOut, popOutToMainLabel, ownWindow, @@ -4699,7 +4707,9 @@ export function EmailComposer({ ); - const handleDiscardAndClose = () => { + // `keepDraft`: the bin's discard, which deletes the draft once its Undo is + // over, not here. + const handleDiscardAndClose = ({ keepDraft = false }: { keepDraft?: boolean } = {}) => { cancelSaveAndClose(); sendCancelledRef.current = true; explicitCloseRef.current = true; @@ -4717,7 +4727,7 @@ export function EmailComposer({ // From belongs to: switching From doesn't move the draft until it is // saved again, and the same id names another message on another server. const discardedDraftId = draftIdRef.current; - if (discardedDraftId && onDiscardDraft) { + if (discardedDraftId && onDiscardDraft && !keepDraft) { onDiscardDraft(discardedDraftId, { accountId: draftAccountIdRef.current, localAccountId: draftLocalAccountIdRef.current, @@ -4753,6 +4763,11 @@ export function EmailComposer({ // Discard deletes the draft, also one that has no unsaved changes, such as // a scheduled message reopened with "Cancel and edit". It used to share the // X button's path, which closed such a composer and kept the draft. + // It asks nothing (compose-p2-10): the composer goes at once with "Draft + // discarded · Undo", as Discard on the "Draft saved" toast does, and the + // draft is deleted once Undo is no longer offered. Undo brings the composer + // back with its text where the host can; elsewhere the last saved draft + // stays in Drafts. A window of its own has the main window say it. const handleDiscardClick = () => { if (saveCloseAttemptRef.current?.saving) return; cancelSaveAndClose(); @@ -4761,7 +4776,22 @@ export function EmailComposer({ return; } afterCloseRef.current = null; - openCloseDialog('discard'); + const draftId = draftIdRef.current; + const where = { accountId: draftAccountIdRef.current, localAccountId: draftLocalAccountIdRef.current }; + const snapshot = snapshotStateRef.current(); + const discard = onDiscardDraft; + const restore = onRestoreDiscarded; + const windowDiscarded = ownWindowRef.current?.onDraftDiscarded; + handleDiscardAndClose({ keepDraft: true }); + if (windowDiscarded) { + if (draftId) windowDiscarded(draftId, where); + return; + } + discardWithUndo( + () => { if (draftId) discard?.(draftId, where); }, + { discarded: tUx('draft_discarded'), undo: tUx('undo') }, + restore ? () => restore(snapshot) : undefined, + ); }; // X, Escape, another message, a host's tab: one rule, as drafts save @@ -6215,7 +6245,7 @@ export function EmailComposer({ - {closeDialogKind === 'close' && ( diff --git a/components/mail/__tests__/mail-app-composer-replace.test.tsx b/components/mail/__tests__/mail-app-composer-replace.test.tsx index 1d0283969..d3ade0db1 100644 --- a/components/mail/__tests__/mail-app-composer-replace.test.tsx +++ b/components/mail/__tests__/mail-app-composer-replace.test.tsx @@ -21,6 +21,7 @@ const stub = vi.hoisted(() => ({ close: 'none' as 'none' | 'saves' | 'fails', closes: [] as string[], mounts: [] as string[], + restore: null as null | ((data: Record) => void), })); vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); @@ -32,8 +33,9 @@ vi.mock('@/components/layout/sidebar', () => ({ vi.mock('@/components/email/email-composer', async () => { const { useEffect, useRef } = await import('react'); return { - EmailComposer: ({ mode, onSaveState, onClose, requestCloseRef }: { + EmailComposer: ({ mode, onSaveState, onClose, requestCloseRef, onRestoreDiscarded }: { mode: string; + onRestoreDiscarded?: (data: Record) => void; onSaveState: (data: Record) => void; onClose: () => void; requestCloseRef?: { current: unknown }; @@ -41,6 +43,7 @@ vi.mock('@/components/email/email-composer', async () => { const closed = useRef(false); useEffect(() => { stub.mounts.push(mode); + stub.restore = onRestoreDiscarded ?? null; // The old host moved the composer between dock and pane for ever // (React error 185 in the browser); stop it here instead of hanging. if (stub.mounts.length > 40) throw new Error('composer remount loop'); @@ -118,6 +121,7 @@ beforeEach(() => { stub.close = 'none'; stub.closes = []; stub.mounts = []; + stub.restore = null; vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); @@ -224,3 +228,18 @@ describe('another composer while one is open (compose-p2-2)', () => { expect(composerMode()).toBe('reply'); }); }); + +// compose-p2-10: Undo after the bin brings the composer back. +describe('Undo after the bin', () => { + it('opens the discarded text again, closing whatever is open first', async () => { + stub.close = 'saves'; + render(); + fireEvent.click(screen.getByText('stub compose')); + await screen.findByTestId('stub-composer'); + const restore = stub.restore!; + + act(() => restore({ to: 'bob@example.org', cc: '', bcc: '', subject: 'Back', body: 'kept', mode: 'forward', draftId: 'd1' })); + await waitFor(() => expect(composerMode()).toBe('forward')); + expect(stub.closes).toEqual(['compose']); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index fb2db8863..e06b393e6 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -5362,7 +5362,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { requestCloseRef={composerRequestCloseRef} onDiscardDraft={(draftId, draft) => { handleDiscardDraft(draftId, draft); - setPendingDraft(null); + // The bin and the toasts' Discard delete once their Undo is over, + // when another composer may be open: its draft stays. + if (composerSessionRef.current === composerSessionId) setPendingDraft(null); + }} + onRestoreDiscarded={(data) => { + void closeOpenComposer().then(() => { + startFreshComposerSession(); + setPendingDraft(data); + setComposerMode(data.mode); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }); }} popOut={popoutsOn && !isEmbedded ? composerPopOut : undefined} /> diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index afcae4fcf..5850bc78f 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -320,6 +320,18 @@ describe('PopoutHost', () => { expect(toasts[0].action?.label).toBe('email_composer.discard'); }); + // compose-p2-10: the window's bin. + it('says a pop-out discarded its draft, with Undo', async () => { + render(); + await settle(); + popout.post({ type: 'draft-kept', requestId: 'k2', notice: { draftId: 'd1', localAccountId: 'alice', discarded: true } }); + await settle(); + const toasts = useToastStore.getState().toasts; + expect(toasts).toHaveLength(1); + expect(toasts[0].title).toBe('compose_ux.draft_discarded'); + expect(toasts[0].action?.label).toBe('compose_ux.undo'); + }); + it('refreshes the list and counts when a pop-out changed mail, once for a burst', async () => { render(); await settle(); diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 5f8f5433b..9e37034d6 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -487,6 +487,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: { onDraftKept: (draftId: string, where: { accountId?: string; localAccountId?: string }) => { announceDraftKept({ draftId, ...where, localAccountId: where.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined }); }, + onDraftDiscarded: (draftId: string, where: { accountId?: string; localAccountId?: string }) => { + announceDraftKept({ draftId, ...where, localAccountId: where.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined, discarded: true }); + }, onDraftSaved: (draftId: string, where: { accountId?: string; localAccountId?: string } = {}) => { const active = useAuthStore.getState().activeAccountId; announceMailChanged(active); diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx index 1308ca0bf..9969d4dda 100644 --- a/components/popout/popout-host.tsx +++ b/components/popout/popout-host.tsx @@ -8,7 +8,7 @@ import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; -import { showDraftSavedToast } from "@/lib/compose/draft-close-toast"; +import { discardWithUndo, showDraftSavedToast } from "@/lib/compose/draft-close-toast"; import { newPopoutId, openPopoutChannel, type PopoutChannel, type PopoutMessage, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel"; import { takeOverMoved } from "@/lib/popout/moved-undo"; import { keepRescuedDraft } from "@/lib/popout/rescued-drafts"; @@ -180,10 +180,10 @@ export function PopoutHost() { break; case "draft-kept": { // Said here, as the pop-out closes with its Escape or X. - const { draftId, accountId, localAccountId } = message.notice; + const { draftId, accountId, localAccountId, discarded } = message.notice; claimOnce(`bulwark-popout-kept:${message.requestId}`, () => { const tr = tRef.current; - showDraftSavedToast(() => { + const discard = () => { const auth = useAuthStore.getState(); const login = localAccountId ? auth.getClientForAccount(localAccountId) : auth.client; if (!login) { @@ -191,12 +191,16 @@ export function PopoutHost() { return; } void login.deleteEmail(draftId, accountId).catch((error: unknown) => console.error("Failed to discard draft:", error)); - }, { + }; + const labels = { saved: tr("email_composer.draft_saved"), discard: tr("email_composer.discard"), discarded: tr("compose_ux.draft_discarded"), undo: tr("compose_ux.undo"), - }); + }; + // The window's bin: Undo leaves the draft in Drafts. + if (discarded) discardWithUndo(discard, labels); + else showDraftSavedToast(discard, labels); }); break; } diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts index b249c5bb4..46eeb0de1 100644 --- a/lib/popout/channel.ts +++ b/lib/popout/channel.ts @@ -127,6 +127,8 @@ export interface DraftKeptNotice { accountId?: string; /** The login it was saved through. */ localAccountId?: string; + /** Thrown away with the bin: "Draft discarded" with Undo, deleted after. */ + discarded?: boolean; } export interface PopoutChannel { From b02ce23aceced26906f6293d0143c9e062591063 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:29:01 +0100 Subject: [PATCH 34/50] feat: ask for the link's text and address in Insert link, also with nothing selected The first link dialog test now finds the address by its "Link" label and checks the linked text, since the dialog has two fields. --- .../rich-text-editor-link-dialog.test.tsx | 45 ++++++- components/email/link-dialog.tsx | 120 ++++++++++++++++++ components/email/rich-text-editor.tsx | 60 +++++---- 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/vi/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 33 files changed, 229 insertions(+), 26 deletions(-) create mode 100644 components/email/link-dialog.tsx diff --git a/components/email/__tests__/rich-text-editor-link-dialog.test.tsx b/components/email/__tests__/rich-text-editor-link-dialog.test.tsx index c15a51ce4..23d7ac5a2 100644 --- a/components/email/__tests__/rich-text-editor-link-dialog.test.tsx +++ b/components/email/__tests__/rich-text-editor-link-dialog.test.tsx @@ -29,11 +29,50 @@ describe('Insert link', () => { fireEvent.click(screen.getByTitle('link')); expect(prompt).not.toHaveBeenCalled(); - const dialog = screen.getByRole('dialog', { name: 'insert_link' }); - fireEvent.change(dialog.querySelector('input')!, { target: { value: 'example.com/docs' } }); + screen.getByRole('dialog', { name: 'insert_link' }); + expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('docs'); + fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'example.com/docs' } }); fireEvent.click(screen.getByRole('button', { name: 'save' })); - expect(editor.getHTML()).toContain('href="https://example.com/docs"'); + expect(editor.getHTML()).toContain('docs here'); + }); + + // compose-p2-7: with nothing selected Save used to add nothing. + it('inserts the text as a link when nothing is selected', async () => { + const editor = await setup('

See it

'); + act(() => { editor.commands.setTextSelection(4); }); + + fireEvent.click(screen.getByTitle('link')); + expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue(''); + fireEvent.change(screen.getByRole('textbox', { name: 'link_text' }), { target: { value: 'our docs' } }); + fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'example.com/docs' } }); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + expect(editor.getHTML()).toMatch(/See]*href="https:\/\/example\.com\/docs"[^>]*>our docs<\/a> it/); + }); + + it('shows the address as the link when the text is left empty', async () => { + const editor = await setup('

See it

'); + act(() => { editor.commands.setTextSelection(4); }); + + fireEvent.click(screen.getByTitle('link')); + fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'https://example.com' } }); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + expect(editor.getHTML()).toMatch(/See
]*href="https:\/\/example\.com"[^>]*>https:\/\/example\.com<\/a> it/); + }); + + it('edits the text of a link the caret is in', async () => { + const editor = await setup('

docs here

'); + act(() => { editor.commands.setTextSelection(2); }); + + fireEvent.click(screen.getByTitle('link')); + expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('docs'); + expect(screen.getByRole('textbox', { name: 'link' })).toHaveValue('https://example.com'); + fireEvent.change(screen.getByRole('textbox', { name: 'link_text' }), { target: { value: 'the docs' } }); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + + expect(editor.getHTML()).toMatch(/]*href="https:\/\/example\.com"[^>]*>the docs<\/a> here/); }); it('opens on Ctrl+K in the text, as in Gmail, Outlook and Word', async () => { diff --git a/components/email/link-dialog.tsx b/components/email/link-dialog.tsx new file mode 100644 index 000000000..d9a0f0ed2 --- /dev/null +++ b/components/email/link-dialog.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; + +export interface LinkDialogProps { + isOpen: boolean; + onClose: () => void; + /** `text` is what the link shows; empty means the address itself. */ + onSubmit: (link: { text: string; href: string }) => void; + /** The selected text, or the link's own text when editing one. */ + defaultText: string; + defaultHref: string; + /** Offers "Remove link" when editing a link. */ + onRemove?: () => void; +} + +/** + * Insert link (compose-p2-7): "Text" and "Link", as Gmail and Outlook ask. The + * selection fills Text; with nothing selected the text (or the address) goes + * in as a new link. + */ +export function LinkDialog({ isOpen, onClose, onSubmit, defaultText, defaultHref, onRemove }: LinkDialogProps) { + const t = useTranslations("email_composer.toolbar"); + const tCommon = useTranslations("common"); + const id = useId(); + const [text, setText] = useState(defaultText); + const [href, setHref] = useState(defaultHref); + const textRef = useRef(null); + const hrefRef = useRef(null); + // With text already there, the address is what is left to give. + const firstField = defaultText ? hrefRef : textRef; + + useEffect(() => { + if (!isOpen) return; + setText(defaultText); + setHref(defaultHref); + }, [isOpen, defaultText, defaultHref]); + + const address = href.trim(); + const error = /\s/.test(address) ? t("link_invalid") : null; + const canSubmit = address.length > 0 && !error; + const errorId = `${id}-error`; + + const submit = (event?: React.FormEvent) => { + event?.preventDefault(); + if (!canSubmit) return; + try { + onSubmit({ text, href: address }); + } finally { + onClose(); + } + }; + + return ( + + {onRemove && ( + + )} + + + + } + > +
+
+ + setText(e.target.value)} /> +
+
+ + setHref(e.target.value)} + placeholder={t("link_address")} + aria-invalid={error ? true : undefined} + aria-describedby={error ? errorId : undefined} + className={error ? "border-destructive focus-visible:border-destructive" : undefined} + /> + {error && ( + + )} +
+
+
+ ); +} diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 952a9da30..e0a9720a3 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -32,7 +32,8 @@ import { RecipientMention, RecipientMentionList, type RecipientMentionListHandle import { MentionChipCard, setMentionRecipients } from "@/components/email/mention-chip"; import { mentionSuggestions, type MentionCandidate, type MentionContact } from "@/lib/recipient-mentions"; import { cn } from "@/lib/utils"; -import { PromptDialog } from "@/components/ui/prompt-dialog"; +import { getMarkRange } from "@tiptap/core"; +import { LinkDialog } from "@/components/email/link-dialog"; import { ImageAltDialog } from "@/components/email/image-alt-dialog"; /** A typed address without a scheme ("example.com") becomes a web link. */ @@ -741,26 +742,43 @@ export function RichTextEditor({ if (editor) setMentionRecipients(editor, mentionCandidates ? mentionCandidates.map((c) => c.email) : null); }, [editor, mentionCandidates]); - // Insert link asks for the address in the app's own dialog; on a link it - // starts from the current address and offers "Remove link". - // The selection is kept from when the dialog opened: the dialog takes the - // focus, and with it the editor's selection. - const [linkDialog, setLinkDialog] = useState<{ href: string; from: number; to: number } | null>(null); + // Insert link asks for the text and the address in the app's own dialog + // (compose-p2-7); on a link it starts from the link's text and address and + // offers "Remove link". The range is kept from when the dialog opened: the + // dialog takes the focus, and with it the editor's selection. + const [linkDialog, setLinkDialog] = useState<{ href: string; text: string; from: number; to: number } | null>(null); const addLink = useCallback(() => { if (!editor) return; const previousUrl = editor.getAttributes("link").href as string | undefined; - const { from, to } = editor.state.selection; - setLinkDialog({ href: previousUrl ?? "", from, to }); + let { from, to } = editor.state.selection; + // The caret in a link edits the whole link. + const linkType = editor.schema.marks.link; + if (previousUrl && from === to && linkType) { + const range = getMarkRange(editor.state.doc.resolve(from), linkType); + if (range) ({ from, to } = range); + } + const text = editor.state.doc.textBetween(from, to, " "); + setLinkDialog({ href: previousUrl ?? "", text, from, to }); }, [editor]); addLinkRef.current = addLink; - const applyLink = useCallback((url: string) => { + const applyLink = useCallback(({ text, href }: { text: string; href: string }) => { if (!editor || !linkDialog) return; - editor - .chain() - .focus() - .setTextSelection({ from: linkDialog.from, to: linkDialog.to }) + const label = text.trim() ? text : href; + const { from, to } = linkDialog; + const chain = editor.chain().focus(); + if (from === to || label !== linkDialog.text) { + // Nothing selected, or the text changed: the text goes in as the link. + chain + .insertContentAt({ from, to }, { type: "text", text: label, marks: [{ type: "link", attrs: { href } }] }) + .setTextSelection(from + label.length) + .unsetMark("link") + .run(); + return; + } + chain + .setTextSelection({ from, to }) .extendMarkRange("link") - .setLink({ href: url }) + .setLink({ href }) .run(); }, [editor, linkDialog]); const removeLink = useCallback(() => { @@ -776,7 +794,6 @@ export function RichTextEditor({ const tToolbar = useTranslations("email_composer.toolbar"); const tWindows = useTranslations("compose_windows"); - const tCommon = useTranslations("common"); const [tableMenuOpen, setTableMenuOpen] = useState(false); const tableWrapperRef = useRef(null); const [colorMenuOpen, setColorMenuOpen] = useState(false); @@ -1465,16 +1482,13 @@ export function RichTextEditor({ document.body )} - setLinkDialog(null)} - title={linkDialog?.href ? tToolbar("edit_link") : tToolbar("insert_link")} - placeholder={tToolbar("link_address")} - defaultValue={linkDialog?.href ?? ""} - confirmText={tCommon("save")} - validate={(value) => (/\s/.test(value) ? tToolbar("link_invalid") : null)} - onSubmit={(value) => applyLink(withLinkScheme(value))} - extraAction={linkDialog?.href ? { label: tToolbar("remove_link"), onClick: removeLink } : undefined} + defaultText={linkDialog?.text ?? ""} + defaultHref={linkDialog?.href ?? ""} + onSubmit={({ text, href }) => applyLink({ text, href: withLinkScheme(href) })} + onRemove={linkDialog?.href ? removeLink : undefined} /> Date: Wed, 4 Nov 2026 12:31:41 +0100 Subject: [PATCH 35/50] fix: draw the full-screen composer over the folder sidebar --- components/email/__tests__/composer-windows-layout.test.tsx | 2 ++ components/email/email-composer.tsx | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/components/email/__tests__/composer-windows-layout.test.tsx b/components/email/__tests__/composer-windows-layout.test.tsx index a5a5dd5a4..9c03feea7 100644 --- a/components/email/__tests__/composer-windows-layout.test.tsx +++ b/components/email/__tests__/composer-windows-layout.test.tsx @@ -232,6 +232,8 @@ describe('full screen', () => { expect(root).toHaveAttribute('data-maximized'); expect(root.className).toMatch(/(^|\s)fixed(\s|$)/); expect(root.className).toContain('inset-0'); + // The folder sidebar sits at z-50 (compose-p2-5): at 40 it covered the composer's left part. + expect(root.className).toMatch(/(^|\s)z-50(\s|$)/); fireEvent.click(screen.getByRole('button', { name: 'restore' })); expect(root).not.toHaveAttribute('data-maximized'); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index baa7e79ad..25849713f 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -4993,7 +4993,8 @@ export function EmailComposer({ "flex h-full bg-background relative", className, // Over everything but dialogs (z-60) and toasts; below the desktop app's window controls. - maximized && "fixed inset-0 z-40 h-auto pt-[var(--titlebar-row-height)]", + // At the folder sidebar's z-50 and after it in the page, so over it (compose-p2-5). + maximized && "fixed inset-0 z-50 h-auto pt-[var(--titlebar-row-height)]", )} data-maximized={maximized || undefined} onKeyDownCapture={handleComposerKeyDown} From d03d4a0b780c1eee35d22c341f42d050c3a7217e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:33:28 +0100 Subject: [PATCH 36/50] fix: show whole rows of attachments and keep uploading files in their place The attachment order test now expects uploading files where they were added: moved to the front, a big upload jumped out of sight once done. --- .../composer-attachment-list-scroll.test.tsx | 10 ++++++++++ components/email/email-composer.tsx | 13 +++++++------ lib/compose/__tests__/attachment-order.test.ts | 6 +++--- lib/compose/attachment-order.ts | 9 +++++---- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/components/email/__tests__/composer-attachment-list-scroll.test.tsx b/components/email/__tests__/composer-attachment-list-scroll.test.tsx index 04bc59023..c4fe570c2 100644 --- a/components/email/__tests__/composer-attachment-list-scroll.test.tsx +++ b/components/email/__tests__/composer-attachment-list-scroll.test.tsx @@ -259,6 +259,16 @@ describe('attachment list', () => { expect(row.className).toContain('max-h-['); }); + // compose-p2-11: 5.25rem showed two rows of 2rem chips and half a third. + it('is as high as whole rows of chips, so none is cut in half', () => { + render(); + + const heights = [...chipRow().className.matchAll(/max-h-\[([\d.]+)rem\]/g)].map((m) => Number(m[1])); + expect(heights.length).toBeGreaterThan(0); + // n rows of 2rem chips with 0.5rem gaps: 2.5n - 0.5. + for (const height of heights) expect(Number.isInteger((height + 0.5) / 2.5)).toBe(true); + }); + it('says how many files and how much they weigh', () => { render(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 25849713f..d0d922b13 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5679,12 +5679,13 @@ export function EmailComposer({

)}
{attachmentsForDisplay(attachments).map(({ att, index }) => { diff --git a/lib/compose/__tests__/attachment-order.test.ts b/lib/compose/__tests__/attachment-order.test.ts index d5337caea..de1376bed 100644 --- a/lib/compose/__tests__/attachment-order.test.ts +++ b/lib/compose/__tests__/attachment-order.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { attachmentsForDisplay } from '../attachment-order'; describe('the order of the composer attachment chips', () => { - it('puts failed, then uploading files first, and keeps the rest in the order added', () => { + it('puts failed files first, and keeps the rest, uploading or not, in the order added', () => { const files = [ { name: 'a.pdf' }, { name: 'b.pdf', uploading: true }, @@ -11,8 +11,8 @@ describe('the order of the composer attachment chips', () => { { name: 'broken.png', error: 'failed' }, ]; const shown = attachmentsForDisplay(files); - expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'b.pdf', 'big.zip', 'a.pdf', 'c.pdf']); + expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'a.pdf', 'b.pdf', 'c.pdf', 'big.zip']); // Each keeps its place in the composer's list, for Remove. - expect(shown.map(({ index }) => index)).toEqual([4, 1, 3, 0, 2]); + expect(shown.map(({ index }) => index)).toEqual([4, 0, 1, 2, 3]); }); }); diff --git a/lib/compose/attachment-order.ts b/lib/compose/attachment-order.ts index 158d340d6..594011a40 100644 --- a/lib/compose/attachment-order.ts +++ b/lib/compose/attachment-order.ts @@ -1,13 +1,14 @@ /** * The composer's attachment chips in the order they're shown: failed first, - * then still uploading, then the rest, each group in the order added. The - * list scrolls past about two rows, so the files that need an eye on them - * are the ones always in view. `index` is the place in the composer's list. + * then the rest in the order added. A file still uploading keeps its place: + * moved to the front, it jumped to the end once done and went out of sight + * past the rows on screen (compose-p2-11). `index` is the place in the + * composer's list. */ export function attachmentsForDisplay( attachments: readonly T[], ): Array<{ att: T; index: number }> { - const rank = (att: T) => (att.error ? 0 : att.uploading ? 1 : 2); + const rank = (att: T) => (att.error ? 0 : 1); return attachments .map((att, index) => ({ att, index })) .sort((a, b) => rank(a.att) - rank(b.att) || a.index - b.index); From 90e959a0925e072e3209ecd5e3578b8e9afe2453 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:35:50 +0100 Subject: [PATCH 37/50] fix: show a sent reply in the open conversation at once, keep it out of Sent and Scheduled during Undo, and bring an undone reply back where it was written --- .../viewer-quick-reply-back.test.tsx | 16 ++ components/email/email-composer.tsx | 7 +- components/email/email-viewer.tsx | 31 ++- .../__tests__/mail-app-held-reply.test.tsx | 217 ++++++++++++++++++ components/mail/mail-app.tsx | 166 +++++++++++++- lib/__tests__/held-send.test.ts | 64 ++++++ lib/held-send.ts | 102 ++++++++ lib/jmap/types.ts | 5 + .../email-store-held-send-counts.test.ts | 42 ++++ stores/email-store.ts | 10 + stores/quick-reply-restore-store.ts | 23 ++ 11 files changed, 670 insertions(+), 13 deletions(-) create mode 100644 components/mail/__tests__/mail-app-held-reply.test.tsx create mode 100644 lib/__tests__/held-send.test.ts create mode 100644 lib/held-send.ts create mode 100644 stores/__tests__/email-store-held-send-counts.test.ts create mode 100644 stores/quick-reply-restore-store.ts diff --git a/components/email/__tests__/viewer-quick-reply-back.test.tsx b/components/email/__tests__/viewer-quick-reply-back.test.tsx index b4717d125..df22850a8 100644 --- a/components/email/__tests__/viewer-quick-reply-back.test.tsx +++ b/components/email/__tests__/viewer-quick-reply-back.test.tsx @@ -63,6 +63,22 @@ describe('quick reply', () => { }); }); +// mail-p2-8: Undo send on a quick reply brings its text back into this box. +describe('quick reply after Undo send', () => { + it('takes the undone text back into the box of the message it answered', async () => { + const { useQuickReplyRestoreStore } = await import('@/stores/quick-reply-restore-store'); + useQuickReplyRestoreStore.setState({ pending: null }); + render(); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + const box = screen.getByPlaceholderText('quick_reply_to') as HTMLTextAreaElement; + act(() => { useQuickReplyRestoreStore.getState().restore('other', 'Not for this one'); }); + expect(box.value).toBe(''); + act(() => { useQuickReplyRestoreStore.getState().restore('m1', 'My answer'); }); + await waitFor(() => expect(box.value).toBe('My answer')); + expect(useQuickReplyRestoreStore.getState().pending).toBeNull(); + }); +}); + describe('automatic sign-out while the external recipient question is open', () => { it('answers Back and keeps the typed reply as a draft, without asking again', async () => { const { useExternalRecipientConfirmStore } = await import('@/stores/external-recipient-confirm-store'); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index fc292094a..7777a7bb3 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -385,7 +385,10 @@ interface EmailComposerProps { labelHeaders?: Record; /** The id plugins know this composer by, for onAfterEmailSend. */ composerId?: string; - }) => void | false | Promise; + }, + /** The composer as it was at Send, to bring it back as it was after Undo send (mail-p2-8). */ + state?: ComposerDraftData, + ) => void | false | Promise; /** * onSend failed. A host that closes the composer as soon as onSend starts * gets the message back here, to open it again: it must never vanish. @@ -4380,7 +4383,7 @@ export function EmailComposer({ labelHeaders: labelled.labelHeaders, delayedUntil: effectiveDelayedUntil, composerId: pluginComposerIdRef.current ?? undefined, - }); + }, handedToHost); // Not when nothing went: no host, or one that sent nothing. if (draftElsewhere && onSend && outcome !== false) await removeDraftVersion(finalDraftId!, draftLogin, draftAccountAtSend); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 93be552e5..432749293 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -40,6 +40,7 @@ import { EmailContextMenu } from "./email-context-menu"; import { rememberedTrashOrigin } from "@/lib/trash-origin"; import { useMessageActionList, type MessageActionEntry, type MessageActionKey, type MessageActionOptions, type MessageSubmenuKey } from "./message-actions"; import { useMessageActionShortcut } from "./message-action-shortcuts"; +import { useQuickReplyRestoreStore } from "@/stores/quick-reply-restore-store"; import { PhishingMarkBanner } from "./phishing-mark-banner"; import { turnOffLinks } from "@/lib/phishing-links"; import { usePhishingMarked } from "@/hooks/use-phishing-report"; @@ -819,7 +820,7 @@ function conversationFullDate(date: Date | string, timeFormat: '12h' | '24h'): s export function conversationOpenPlan(conversation: Email[], openId: string): { expanded: string[]; foldOpen: boolean; newestId: string | null } { const expanded = conversation.filter((e) => e.id !== openId && !e.keywords?.$seen).map((e) => e.id); const open = conversation.find((e) => e.id === openId); - const candidates = conversation.filter((e) => !e.keywords?.$draft && !e.isScheduled); + const candidates = conversation.filter((e) => !e.keywords?.$draft && !e.isScheduled && !e.sentEcho); const newest = candidates[candidates.length - 1]; if (!open || !newest || newest.id === openId) return { expanded, foldOpen: false, newestId: null }; if (!expanded.includes(newest.id)) expanded.push(newest.id); @@ -1646,6 +1647,17 @@ export function EmailViewer({ }); return () => cancelAnimationFrame(frame); }, [mvNewestToShow]); + // A reply just sent joins the end of the conversation, folded like the + // others: bring it into view so it's seen to be there (mail-p2-1). + const lastEchoId = mvConversation?.filter((e) => e.sentEcho).at(-1)?.id ?? null; + useEffect(() => { + if (!lastEchoId) return; + const frame = requestAnimationFrame(() => { + document.querySelector(`[data-mv-conversation-card][data-email-id="${CSS.escape(lastEchoId)}"]`) + ?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }); + }); + return () => cancelAnimationFrame(frame); + }, [lastEchoId]); const toggleMvExpanded = useCallback((target: Email) => { emitThreadExpand(target.threadId, [target], !mvExpandedIdsRef.current.has(target.id)); setMvExpandedIds((prev) => { @@ -2501,6 +2513,23 @@ export function EmailViewer({ setEmbeddedEmailAttachments(NO_POSTAL_ATTACHMENTS); }, [email?.id, externalContentPolicy]); + // Undo send on a quick reply puts its text back here, where it was + // written (mail-p2-8). After the reset above, which runs on the same open. + const quickReplyRestoreWaiting = useQuickReplyRestoreStore((s) => !!email && s.pending?.emailId === email.id); + useEffect(() => { + if (!quickReplyRestoreWaiting || !email) return; + const text = useQuickReplyRestoreStore.getState().take(email.id); + if (text === null) return; + setQuickReplyText(text); + setIsQuickReplyFocused(true); + requestAnimationFrame(() => { + const box = quickReplyRef.current; + if (!box) return; + box.focus(); + box.setSelectionRange(box.value.length, box.value.length); + }); + }, [quickReplyRestoreWaiting, email]); + // Crypto-plugin body takeover (S/MIME, PGP, …). A privileged crypto plugin // can fetch the raw message via api.jmap.fetchBlob, decrypt/verify it, and // return a replaced body through the onRenderEmailBody hook. The host stays diff --git a/components/mail/__tests__/mail-app-held-reply.test.tsx b/components/mail/__tests__/mail-app-held-reply.test.tsx new file mode 100644 index 000000000..fa1ec6fba --- /dev/null +++ b/components/mail/__tests__/mail-app-held-reply.test.tsx @@ -0,0 +1,217 @@ +import React, { Suspense, lazy } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useToastStore } from '@/stores/toast-store'; +import { useQuickReplyRestoreStore } from '@/stores/quick-reply-restore-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { clearAllHooks } from '@/lib/plugin-hooks'; +import { forgetEmailPrefetches } from '@/lib/email-prefetch'; + +// mail-p2-1: a quick reply that went out shows in the open conversation at +// once. mail-p2-8: during its Undo window it isn't counted in Sent or +// Scheduled and the message isn't marked answered; Undo takes it out of the +// conversation and puts the text back into the quick reply box, leaving no +// draft behind. + +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +type ViewerProps = { mvConversation?: { id: string }[]; onQuickReply?: (body: string) => Promise; onReply?: () => void }; +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: ({ mvConversation, onQuickReply, onReply }: ViewerProps) => ( +
+
{mvConversation ? mvConversation.map((e) => e.id).join(',') : 'no conversation'}
+ + +
+ ), +})); +type ComposerProps = { + mode: string; + initialData?: { draftId?: string | null; body?: string } | null; + onSend?: (data: Record, state?: Record) => Promise; +}; +vi.mock('@/components/email/email-composer', () => ({ + EmailComposer: ({ mode, initialData, onSend }: ComposerProps) => ( +
+ +
+ ), +})); +vi.mock('@/components/layout/sidebar', () => ({ + Sidebar: ({ scheduledTotal, mailboxes }: { scheduledTotal?: number; mailboxes: Mailbox[] }) => ( +
{`scheduled ${scheduledTotal} sent ${mailboxes.find((m) => m.role === 'sent')?.totalEmails}`}
+ ), +})); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }), getCachedConfig: () => null })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +function mailbox(id: string, role: string, totalEmails = 3): Mailbox { + return { + id, name: role, role, sortOrder: 0, totalEmails, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, isSubscribed: true, + } as Mailbox; +} +const row = (id: string, minute: number, extra: Partial = {}) => ({ + id, threadId: 't-m1', subject: 'Lunch tomorrow?', receivedAt: new Date(Date.UTC(2026, 9, 31, 10, minute)).toISOString(), + keywords: { $seen: true }, mailboxIds: { inbox: true }, from: [{ name: 'Sam', email: 'sam@example.org' }], + to: [{ name: 'Me', email: 'a@example.org' }], ...extra, +}) as unknown as Email; + +let client: Record | (() => unknown)>; +let sendEmail: ReturnType; +let markEmailKeyword: ReturnType; +let cancelUndoSend: ReturnType; +let folders: Mailbox[]; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + useToastStore.setState({ toasts: [] }); + useQuickReplyRestoreStore.setState({ pending: null }); + clearAllHooks(); + forgetEmailPrefetches(); + folders = [mailbox('inbox', 'inbox'), mailbox('sent', 'sent', 51), mailbox('drafts', 'drafts')]; + const thread = [row('m0', 0, { mailboxIds: { inbox: true } }), row('m1', 30)]; + client = { + getAccountId: () => 'acc', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => true, getSession: () => null, + getMailboxes: vi.fn().mockResolvedValue(folders), + getThread: vi.fn(async (threadId: string) => ({ id: threadId, emailIds: ['m0', 'm1'] })), + getSomeEmails: vi.fn(async (ids: string[]) => thread.filter((e) => ids.includes(e.id))), + getEmail: vi.fn(async (id: string) => ({ ...thread.find((e) => e.id === id)!, bodyValues: {} })), + deleteEmail: vi.fn().mockResolvedValue(undefined), + markAsRead: vi.fn().mockResolvedValue(undefined), + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [], + getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + }); + useSettingsStore.setState({ + enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', disableThreading: false, + sendDelaySeconds: 5, sendConfirmation: false, placeholderWarningEnabled: false, attachmentReminderEnabled: false, + } as never); + sendEmail = vi.fn(async () => { + const sendAt = new Date(Date.now() + 5000).toISOString(); + useEmailStore.setState({ + pendingUndoSend: { + submissionId: 'sub-1', emailId: 'sent-1', sendAt, isSmime: false, + countsBefore: { sent: 51, scheduled: 0 }, + }, + } as never); + // The server files it in Sent and lists its submission at once. + useEmailStore.setState({ scheduledTotal: 1, mailboxes: folders.map((m) => m.role === 'sent' ? { ...m, totalEmails: 52 } : m) } as never); + return { scheduled: true, emailId: 'sent-1', emailSubmissionId: 'sub-1', sendAt }; + }); + markEmailKeyword = vi.fn().mockResolvedValue(undefined); + cancelUndoSend = vi.fn(async () => { + useEmailStore.setState({ pendingUndoSend: null } as never); + return { ...row('sent-1', 40), id: 'sent-1', mailboxIds: { drafts: true }, keywords: { $draft: true } }; + }); + const emails = [row('m1', 30)]; + useEmailStore.setState({ + emails, selectedEmail: emails[0], selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: folders, + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', scheduledTotal: 0, pendingUndoSend: null, + selectedEmailKeys: new Set(), unreadSection: null, listRows: null, + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + fetchEmailContent: vi.fn().mockResolvedValue(undefined), + sendEmail, markEmailKeyword, cancelUndoSend, + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('a quick reply in its Undo window', () => { + it('shows in the conversation at once, counts nowhere and answers nothing yet; Undo puts it back in the box', async () => { + render(); + const reader = await screen.findByTestId('reader', {}, { timeout: 5000 }); + await waitFor(() => expect(reader).toHaveTextContent('m0,m1')); + + fireEvent.click(screen.getByRole('button', { name: 'quick send' })); + await waitFor(() => expect(sendEmail).toHaveBeenCalled()); + await waitFor(() => expect(reader).toHaveTextContent('m0,m1,sent-1')); + + expect(markEmailKeyword).not.toHaveBeenCalled(); + expect(screen.getByTestId('sidebar-counts')).toHaveTextContent('scheduled 0 sent 51'); + + const undo = await waitFor(() => { + const toast = useToastStore.getState().toasts.find((t) => t.action); + expect(toast).toBeTruthy(); + return toast!; + }); + await act(async () => { undo.action!.onClick(); }); + await waitFor(() => expect(cancelUndoSend).toHaveBeenCalled()); + await waitFor(() => expect(reader).toHaveTextContent(/^m0,m1$/)); + await waitFor(() => expect(useQuickReplyRestoreStore.getState().pending).toEqual({ emailId: 'm1', text: 'Thanks, see you there' })); + expect(client.deleteEmail).toHaveBeenCalledWith('sent-1', undefined); + expect(markEmailKeyword).not.toHaveBeenCalled(); + }); + + it('brings an undone reply back into the dock, as it was', async () => { + render(); + await screen.findByTestId('reader', {}, { timeout: 5000 }); + fireEvent.click(screen.getByRole('button', { name: 'reply' })); + const composer = await screen.findByTestId('composer'); + expect(composer.closest('[data-reply-dock]')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'composer send' })); + await waitFor(() => expect(sendEmail).toHaveBeenCalled()); + await waitFor(() => expect(screen.queryByTestId('composer')).toBeNull()); + expect(markEmailKeyword).not.toHaveBeenCalled(); + + const undo = await waitFor(() => { + const toast = useToastStore.getState().toasts.find((t) => t.action); + expect(toast).toBeTruthy(); + return toast!; + }); + await act(async () => { undo.action!.onClick(); }); + const back = await screen.findByTestId('composer'); + expect(back).toHaveAttribute('data-mode', 'reply'); + expect(back).toHaveAttribute('data-draft', 'sent-1'); + expect(back).toHaveAttribute('data-body', '

Works for me

'); + expect(back.closest('[data-reply-dock]')).not.toBeNull(); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 0c5061e4c..d0afb5daf 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -11,6 +11,18 @@ import { productName } from "@/lib/product-name"; import { MessageListTabs, useMessageListPanelProps } from "@/components/email/message-list-tabs"; import dynamic from "next/dynamic"; import type { ComposerDraftData } from "@/components/email/email-composer"; +import { addEcho, isUndoWindow, mergeEchoes, sentEcho, withoutHeldSend } from "@/lib/held-send"; +import { useQuickReplyRestoreStore } from "@/stores/quick-reply-restore-store"; + +/** What waits with a send in its Undo window (mail-p2-8). */ +type HeldSendExtras = { + emailId?: string; + /** Marks the answered message; runs once the message really goes. */ + answer?: () => Promise; + origin?: + | { kind: 'quick-reply'; emailId: string; text: string } + | { kind: 'composer'; state: ComposerDraftData; sourceEmailId?: string }; +}; // Neither the viewer (5k+ lines, postal-mime/dompurify) nor the composer (the // entire TipTap/ProseMirror stack) is needed to paint the mail list, and both @@ -2627,7 +2639,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { headers?: Record; labelHeaders?: Record; composerId?: string; - }) => { + }, composerState?: ComposerDraftData) => { // Nothing is sent: the composer keeps what only goes once the mail is out. if (!client) return false; @@ -2707,13 +2719,29 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // This runs before the scheduled early-return: the undo-send delay is a // HOLDFOR submission, so `scheduled` is true for every delayed send and // the flag was never set. (#985) - if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) { + // In the Undo window the flag waits until the message really goes, and + // Undo brings the composer back as it was, where it was (mail-p2-8). + const answeredSource = answeredEmailSource; + const markAnswered = async () => { + if (!originalEmailId || !(effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) return; const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered'; try { - await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, answeredEmailSource); + await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, answeredSource); } catch (e) { debug.error(`Failed to set ${keyword} keyword:`, e); } + }; + const held = holdSend(result, { + answer: markAnswered, + origin: composerState ? { kind: 'composer', state: composerState, sourceEmailId: originalEmailId } : undefined, + }); + if (!held) await markAnswered(); + if (effectiveMode === 'reply' || effectiveMode === 'replyAll') { + const replied = composerSourceEmail?.id === originalEmailId ? composerSourceEmail : null; + echoSentReply(replied, { + emailId: result.emailId, to: data.to, cc: data.cc, subject: data.subject, text: data.body, + fromEmail: data.fromEmail, fromName: data.fromName, + }); } const refreshRepliedThreadAfterSend = async (emailId: string | undefined) => { @@ -3033,6 +3061,69 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { ); }; + // A send in its Undo window (mail-p2-8): what waits until it really goes + // (the answered flag), and where Undo puts it back. Keyed by submission. + const heldSendsRef = useRef(new Map()); + const releaseHeldSend = (submissionId: string) => { + const held = heldSendsRef.current.get(submissionId); + if (!held) return; + heldSendsRef.current.delete(submissionId); + void held.answer?.(); + }; + /** True when the send waits in its Undo window; `extras` then wait with it. */ + const holdSend = (result: SendEmailResult, extras: Omit): boolean => { + if (!result.scheduled || !result.emailSubmissionId || !isUndoWindow(result.sendAt)) return false; + const submissionId = result.emailSubmissionId; + heldSendsRef.current.set(submissionId, { ...extras, emailId: result.emailId }); + const remainingMs = Math.max(Date.parse(result.sendAt!) - Date.now(), 0); + setTimeout(() => releaseHeldSend(submissionId), remainingMs); + return true; + }; + // Undo: the message goes back where it was written, as it was. + const restoreUndoneSend = async ( + held: HeldSendExtras | undefined, + restored: Email | null | undefined, + pending: { submissionAccountId?: string }, + undoLogin: string | undefined, + undoClient: NonNullable, + ): Promise => { + const origin = held?.origin; + if (!origin || !restored) return false; + const openNow = useEmailStore.getState().selectedEmail; + const reopen = async (emailId: string): Promise => { + if (openNow?.id === emailId) return true; + const listed = useEmailStore.getState().emails.find((e) => e.id === emailId); + if (!listed) return false; + return (await handleEmailSelect(listed)) === 'opened'; + }; + if (origin.kind === 'quick-reply') { + if (!(await reopen(origin.emailId))) return false; + // Back in the quick reply box, so the draft the server made of it goes. + try { + await undoClient.deleteEmail(restored.id, pending.submissionAccountId); + } catch (error) { + debug.error('Undid a quick reply, but removing its draft failed:', error); + } + useQuickReplyRestoreStore.getState().restore(origin.emailId, origin.text); + return true; + } + const state: ComposerDraftData = { + ...origin.state, + draftId: restored.id, + draftAccountId: pending.submissionAccountId, + draftLocalAccountId: undoLogin, + }; + // A reply docks under its message again; with that message gone from + // view it opens in the pane, still as a reply. + if (origin.sourceEmailId && state.mode !== 'compose' && !(await reopen(origin.sourceEmailId))) selectEmail(null); + startFreshComposerSession(); + setPendingDraft(state); + setComposerMode(state.mode); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + return true; + }; + useEffect(() => { if (!pendingUndoSend || !client) return; if (lastUndoToastSubmissionRef.current === pendingUndoSend.submissionId) return; @@ -3084,6 +3175,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { pending.submissionAccountId, ); clearPendingUndoSend(); + releaseHeldSend(pending.submissionId); // The recipients are checked again for the release. const refused = partlyRefusedWarning(result, tComposer); if (refused) toast.warning(refused); @@ -3121,10 +3213,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } // The send is undone; opening the draft or refreshing the list // failing is no reason to say otherwise. + const held = heldSendsRef.current.get(pending.submissionId); + heldSendsRef.current.delete(pending.submissionId); + dropEcho(pending.emailId); try { // Never left the server: plugins don't hear about it. cancelAnnouncedSend(pending.submissionId); - if (restored && !pending.isSmime) { + if (!pending.isSmime && await restoreUndoneSend(held, restored, pending, undoLogin, undoClient)) { + // Back where it was written. + } else if (restored && !pending.isSmime) { // Back in the Drafts of the account it was sent from, through // the login that sent it. await handleEditDraft(restored, { accountId: pending.submissionAccountId, localAccountId: undoLogin }); @@ -4644,13 +4741,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // account so the flag lands on shared/group-mailbox messages instead of // being dropped against the reaching account. (#281) Runs before the // scheduled early-return so a delayed (undo-send) reply is flagged too. (#985) - { + // In the Undo window it waits until the reply really goes (mail-p2-8). + const markAnswered = async () => { try { await useEmailStore.getState().markEmailKeyword(client, originalEmailId, '$answered', originalSource); } catch (e) { debug.error('Failed to set $answered keyword:', e); } - } + }; + const held = holdSend(result, { + answer: markAnswered, + origin: { kind: 'quick-reply', emailId: originalEmailId, text: body }, + }); + if (!held) await markAnswered(); + echoSentReply(selectedEmail, { + emailId: result.emailId, to: recipients, subject: labelled.subject, text: finalBody, + fromEmail: headerFromEmail, fromName: headerFromName, + }); const refreshInlineRepliedThread = async () => { const emailState = useEmailStore.getState(); @@ -4779,6 +4886,43 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the open message, not on every list refresh }, [mvConversationThreadId, mvConversationRef?.id, mvBlobSource.client, mvBlobSource.accountId, client]); + // mail-p2-1: a reply that went out shows at the end of the open + // conversation at once, then the server's reading of the thread replaces + // it (a moment later, and again after a few seconds for a server that + // threads new mail late). + const confirmConversation = async (ref: Email) => { + if (!client || useEmailStore.getState().selectedEmail?.threadId !== ref.threadId) return; + mvConversationLoadRef.current = null; + const fresh = await loadMvConversation(ref, client); + if (!fresh || useEmailStore.getState().selectedEmail?.threadId !== ref.threadId) return; + setMvConversationResult((prev) => prev && prev.threadId === ref.threadId + ? { ...prev, emails: mergeEchoes(fresh, prev.emails) } + : prev); + }; + const echoSentReply = (replied: Email | null | undefined, sent: { + emailId?: string; to: string[]; cc?: string[]; subject: string; text: string; fromEmail?: string; fromName?: string; + }) => { + if (!replied || !client || !readerConversationView) return; + const open = useEmailStore.getState().selectedEmail; + if (!open || open.threadId !== replied.threadId) return; + const echo = sentEcho({ ...sent, threadId: replied.threadId }); + setMvConversationResult((prev) => { + if (prev && prev.threadId === replied.threadId) return { ...prev, emails: addEcho(prev.emails, echo) }; + if (prev) return prev; + // A message alone so far: it becomes a conversation of two. + return { emails: [open, echo], source: resolveEmailBlobContext(open, client), threadId: replied.threadId }; + }); + for (const delay of [2000, 8000]) setTimeout(() => { void confirmConversation(open); }, delay); + }; + const dropEcho = (emailId: string | undefined) => { + if (!emailId) return; + setMvConversationResult((prev) => { + if (!prev || !prev.emails.some((e) => e.id === emailId)) return prev; + const emails = prev.emails.filter((e) => e.id !== emailId); + return emails.length > 1 ? { ...prev, emails } : null; + }); + }; + const patchMvConversation = (id: string, patch: (e: Email) => Email) => setMvConversationResult(prev => prev ? { ...prev, emails: prev.emails.map(e => e.id === id ? patch(e) : e), @@ -4944,6 +5088,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { ? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`)) : "Inbox"; })(); + // A send in its Undo window isn't counted in Sent or Scheduled yet (mail-p2-8). + const heldSendView = withoutHeldSend(mailboxes, scheduledTotal, pendingUndoSend); const isFocusedMailLayout = mailLayout === 'focus'; const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile; // Under Mountain View on desktop the composer floats over the list in a @@ -5366,8 +5512,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } setPendingDraft(data); }} - onSend={async (data) => { - const outcome = await handleEmailSend(data); + onSend={async (data, state) => { + const outcome = await handleEmailSend(data, state); setPendingDraft(null); setComposerPrefill(null); return outcome; @@ -5550,10 +5696,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { > = {}) => + ({ id: role, name: role, role, totalEmails, unreadEmails: 0, ...extra }) as Mailbox; +const now = Date.parse('2026-11-04T10:00:00Z'); +const soon = new Date(now + 5000).toISOString(); + +describe('withoutHeldSend', () => { + it('shows Sent and Scheduled as they were before a send in its Undo window', () => { + const view = withoutHeldSend([mb('inbox', 146), mb('sent', 52)], 1, { sendAt: soon, countsBefore: { sent: 51, scheduled: 0 } }, now); + expect(view.scheduledTotal).toBe(0); + expect(view.mailboxes.find((m) => m.role === 'sent')!.totalEmails).toBe(51); + expect(view.mailboxes.find((m) => m.role === 'inbox')!.totalEmails).toBe(146); + }); + + it('shows a count that fell meanwhile as it is', () => { + const view = withoutHeldSend([mb('sent', 49)], 0, { sendAt: soon, countsBefore: { sent: 51, scheduled: 1 } }, now); + expect(view.mailboxes[0].totalEmails).toBe(49); + expect(view.scheduledTotal).toBe(0); + }); + + it('counts a mail scheduled for later at once', () => { + const tomorrow = new Date(now + 86_400_000).toISOString(); + const view = withoutHeldSend([mb('sent', 52)], 1, { sendAt: tomorrow, countsBefore: { sent: 51, scheduled: 0 } }, now); + expect(view.scheduledTotal).toBe(1); + expect(view.mailboxes[0].totalEmails).toBe(52); + }); + + it('leaves a shared Sent folder alone', () => { + const view = withoutHeldSend([mb('sent', 9, { isShared: true })], 0, { sendAt: soon, countsBefore: { sent: 5, scheduled: 0 } }, now); + expect(view.mailboxes[0].totalEmails).toBe(9); + }); +}); + +describe('isUndoWindow', () => { + it('is two minutes at most', () => { + expect(isUndoWindow(soon, now)).toBe(true); + expect(isUndoWindow(new Date(now + 121_000).toISOString(), now)).toBe(false); + expect(isUndoWindow(undefined, now)).toBe(false); + }); +}); + +describe('echoes of sent replies', () => { + const server = [{ id: 'a' }, { id: 'b' }] as Email[]; + const echo = sentEcho({ emailId: 'c', threadId: 't', to: ['sam@example.org'], subject: 'Re: x', text: 'Thanks', fromEmail: 'me@example.org' }); + + it('is read, carries its text and the server id', () => { + expect(echo.id).toBe('c'); + expect(echo.keywords.$seen).toBe(true); + expect(Object.values(echo.bodyValues!)[0].value).toBe('Thanks'); + expect(echo.sentEcho).toBe(true); + }); + + it('is added once', () => { + expect(addEcho(addEcho(server, echo), echo).map((e) => e.id)).toEqual(['a', 'b', 'c']); + }); + + it('gives way to the server copy, and stays while the server lacks it', () => { + expect(mergeEchoes([...server, { id: 'c' } as Email], [...server, echo]).map((e) => (e.sentEcho ? 'echo' : e.id))).toEqual(['a', 'b', 'c']); + expect(mergeEchoes(server, [...server, echo]).map((e) => (e.sentEcho ? 'echo' : e.id))).toEqual(['a', 'b', 'echo']); + }); +}); diff --git a/lib/held-send.ts b/lib/held-send.ts new file mode 100644 index 000000000..78b66e5fc --- /dev/null +++ b/lib/held-send.ts @@ -0,0 +1,102 @@ +import type { Email, Mailbox } from '@/lib/jmap/types'; + +/** + * A message in its Undo send window: sent to the server with a short hold, so + * the server already files it in Sent and lists its submission. Until the + * window is over it isn't sent as far as the user can tell (mail-p2-8): + * Sent and Scheduled don't count it, and the message it answers isn't marked + * as answered yet. + */ +export const UNDO_WINDOW_MS = 120_000; + +export interface HeldSendCounts { + /** Sent's total before the send, when the send went through the open login. */ + sent?: number; + /** The Scheduled count before the send. */ + scheduled: number; +} + +export function isUndoWindow(sendAt: string | undefined, now = Date.now()): boolean { + const at = sendAt ? Date.parse(sendAt) : NaN; + return Number.isFinite(at) && at - now <= UNDO_WINDOW_MS; +} + +/** + * The folder list and Scheduled count to show while a send waits in its + * Undo window: neither goes above what it was before the send. A count that + * fell meanwhile (a mail deleted) shows as it is. + */ +export function withoutHeldSend( + mailboxes: Mailbox[], + scheduledTotal: number, + held: { sendAt: string; countsBefore?: HeldSendCounts } | null | undefined, + now = Date.now(), +): { mailboxes: Mailbox[]; scheduledTotal: number } { + if (!held?.countsBefore || !isUndoWindow(held.sendAt, now)) return { mailboxes, scheduledTotal }; + const before = held.countsBefore; + const sentBefore = before.sent; + const adjusted = sentBefore === undefined ? mailboxes : mailboxes.map((mb) => + mb.role === 'sent' && !mb.isShared && mb.totalEmails > sentBefore ? { ...mb, totalEmails: sentBefore } : mb); + return { mailboxes: adjusted, scheduledTotal: Math.min(scheduledTotal, before.scheduled) }; +} + +/** + * The reply just sent, as the conversation shows it until the server's copy + * is read back (mail-p2-1). With the server's id when it gave one, so the + * real message replaces it in place. + */ +export function sentEcho(input: { + emailId?: string; + threadId: string; + fromEmail?: string; + fromName?: string; + to: string[]; + cc?: string[]; + subject: string; + text: string; + now?: Date; +}): Email { + const at = (input.now ?? new Date()).toISOString(); + const preview = input.text.replace(/\s+/g, ' ').trim().slice(0, 256); + return { + id: input.emailId ?? `sent-echo-${at}`, + blobId: '', + threadId: input.threadId, + mailboxIds: {}, + keywords: { $seen: true }, + size: 0, + receivedAt: at, + sentAt: at, + from: input.fromEmail ? [{ name: input.fromName ?? '', email: input.fromEmail }] : [], + to: input.to.map((email) => ({ name: '', email })), + cc: (input.cc ?? []).map((email) => ({ name: '', email })), + subject: input.subject, + preview, + hasAttachment: false, + textBody: [{ partId: 'sent-echo', blobId: '', size: 0, type: 'text/plain' }], + htmlBody: [], + attachments: [], + bodyValues: { 'sent-echo': { value: input.text, isEncodingProblem: false, isTruncated: false } }, + sentEcho: true, + } as unknown as Email; +} + +/** + * The conversation with a just-sent reply added at its end (once). + */ +export function addEcho(conversation: Email[], echo: Email): Email[] { + if (conversation.some((e) => e.id === echo.id)) return conversation; + return [...conversation, echo]; +} + +/** + * The server's reading of the conversation, with the replies shown before it + * had them kept at the end: a server that threads a new message a moment + * later (or an offline send that waits in the outbox) doesn't make the reply + * vanish again. + */ +export function mergeEchoes(server: Email[], shown: Email[]): Email[] { + const ids = new Set(server.map((e) => e.id)); + const echoes = shown.filter((e) => e.sentEcho && !ids.has(e.id)); + return echoes.length ? [...server, ...echoes] : server; +} diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 29e0ea8af..ea75a89fb 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -113,6 +113,11 @@ export interface Email { scheduledAccountId?: string; isScheduled?: boolean; isSmimeScheduled?: boolean; + /** + * Client-only: a reply just sent, shown in the open conversation before the + * server's copy is read back (lib/held-send.ts). + */ + sentEcho?: boolean; } export interface SendEmailResult { diff --git a/stores/__tests__/email-store-held-send-counts.test.ts b/stores/__tests__/email-store-held-send-counts.test.ts new file mode 100644 index 000000000..6b4f54aa3 --- /dev/null +++ b/stores/__tests__/email-store-held-send-counts.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Mailbox } from '@/lib/jmap/types'; + +// mail-p2-8: a send in its Undo window keeps what Sent and Scheduled counted +// before it, so the sidebar can show those counts until the message goes. + +function mailbox(id: string, role: string, totalEmails: number): Mailbox { + return { id, name: role, role, sortOrder: 0, totalEmails, unreadEmails: 0, totalThreads: totalEmails, unreadThreads: 0, isSubscribed: true } as Mailbox; +} + +describe('the counts before a held send', () => { + let client: IJMAPClient; + + beforeEach(() => { + vi.restoreAllMocks(); + client = { + getAccountId: () => 'acc-1', + pruneEmailSubmissions: vi.fn().mockResolvedValue(0), + sendEmail: vi.fn().mockResolvedValue({ scheduled: true, emailId: 'sent-1', emailSubmissionId: 'sub-1', sendAt: new Date(Date.now() + 5000).toISOString() }), + getAllMailboxesWithState: vi.fn().mockResolvedValue({ mailboxes: [mailbox('mb-sent', 'sent', 52)], states: { 'acc-1': 'm2' } }), + } as unknown as IJMAPClient; + useAuthStore.setState({ client }); + useEmailStore.setState({ + emails: [], mailboxes: [mailbox('mb-sent', 'sent', 51)], mailboxSyncStates: { 'acc-1': 'm1' }, + selectedMailbox: 'mb-sent', pendingUndoSend: null, scheduledTotal: 2, + }); + }); + + it('records Sent and Scheduled as they were', async () => { + await useEmailStore.getState().sendEmail(client, ['bob@example.net'], 'Subject', 'body'); + expect(useEmailStore.getState().pendingUndoSend?.countsBefore).toEqual({ sent: 51, scheduled: 2 }); + }); + + it("records no Sent count for another login's send", async () => { + const otherLogin = { ...client } as unknown as IJMAPClient; + await useEmailStore.getState().sendEmail(otherLogin, ['bob@example.net'], 'Subject', 'body'); + expect(useEmailStore.getState().pendingUndoSend?.countsBefore).toEqual({ sent: undefined, scheduled: 2 }); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 73676edc8..429d1dcdc 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -6,6 +6,7 @@ import { RescheduleRestoredError } from "@/lib/jmap/reschedule-error"; import { create } from "zustand"; import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types"; import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types"; +import type { HeldSendCounts } from "@/lib/held-send"; import type { IJMAPClient, SearchEmailsResult } from "@/lib/jmap/client-interface"; import { jmapErrorFailedIds } from "@/lib/jmap/jmap-error"; import type { MessagePriority } from "@/lib/message-priority"; @@ -70,6 +71,8 @@ type PendingUndoSend = { * was sent from a shared identity (#874). Distinct from localAccountId, * which selects *which login's client* to talk to. */ submissionAccountId?: string; + /** Sent's and Scheduled's counts before the send, shown during the Undo window (lib/held-send.ts). */ + countsBefore?: HeldSendCounts; }; // Nothing ticked stays the same object: a new empty set re-renders all that reads the selection. @@ -4410,6 +4413,12 @@ export const useEmailStore = create((set, get) => ({ // under "Loading emails…" until the message was out. set({ error: null }); try { + // What Sent and Scheduled counted before: a send in its Undo window + // doesn't count yet (mail-p2-8). + const countsBefore: HeldSendCounts = { + sent: useAuthStore.getState().client === client ? get().mailboxes.find((mb) => mb.role === 'sent' && !mb.isShared)?.totalEmails : undefined, + scheduled: get().scheduledTotal, + }; // Files attached while offline are uploaded now that the server answers. const sendAttachments = attachments && getOutboxHooks() ? await getOutboxHooks()!.resolveHeldAttachments(client, attachments) : attachments; const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, sendAttachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options); @@ -4434,6 +4443,7 @@ export const useEmailStore = create((set, get) => ({ // Cross-account send: undo/send-now must talk to the account that // holds the submission, not the active one (#461). localAccountId: options?.localAccountId, + countsBefore, } : get().pendingUndoSend, }); diff --git a/stores/quick-reply-restore-store.ts b/stores/quick-reply-restore-store.ts new file mode 100644 index 000000000..a903fcc76 --- /dev/null +++ b/stores/quick-reply-restore-store.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand'; + +/** + * A quick reply whose send was undone, waiting for the reader of the message + * it answers to take its text back into the quick reply box (mail-p2-8). + */ +interface QuickReplyRestoreState { + pending: { emailId: string; text: string } | null; + restore: (emailId: string, text: string) => void; + /** The reader of `emailId` takes the text; null when none waits for it. */ + take: (emailId: string) => string | null; +} + +export const useQuickReplyRestoreStore = create()((set, get) => ({ + pending: null, + restore: (emailId, text) => set({ pending: { emailId, text } }), + take: (emailId) => { + const pending = get().pending; + if (!pending || pending.emailId !== emailId) return null; + set({ pending: null }); + return pending.text; + }, +})); From 664ec3831d44640e29f44f44fc0a3a0fe02c0ea6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:36:16 +0100 Subject: [PATCH 38/50] fix: open a recipient's menu when its chip is clicked --- components/email/__tests__/composer-a11y.test.tsx | 15 +++++++++++++++ components/email/email-composer.tsx | 8 ++++++++ 2 files changed, 23 insertions(+) diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx index f71f24a0d..52783b107 100644 --- a/components/email/__tests__/composer-a11y.test.tsx +++ b/components/email/__tests__/composer-a11y.test.tsx @@ -394,6 +394,21 @@ describe('moving a recipient by keyboard (MO-07)', () => { expect(liveRegion()).toHaveTextContent('recipient_moved:Anna Berg|cc'); }); + // compose-p2-16: a click did nothing visible, and Escape then closed the composer. + it('opens the chip menu on a click, and Escape closes only the menu', async () => { + const onClose = vi.fn(); + render(); + const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem'); + fireEvent.click(chip.querySelector('.truncate')!); + + const move = await screen.findByTestId('recipient-move-bcc'); + fireEvent.keyDown(move, { key: 'Escape' }); + + await waitFor(() => expect(screen.queryByTestId('recipient-move-bcc')).toBeNull()); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getAllByRole('listitem')).toHaveLength(1); + }); + it('opens the chip menu with Shift+F10 too', async () => { render(); const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem'); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d0d922b13..615dfd910 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -6860,6 +6860,14 @@ export function RecipientChipInput({ !isEditing && "focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary" )} onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)} + // A click opens the chip's menu too (address, Edit, Move to, + // Remove), as Gmail and Outlook do; it did nothing visible. + onClick={isEditing ? undefined : (e) => { + if (e.target instanceof Element && e.target.closest('button')) return; + // Not on to the field, whose click puts the caret in the input. + e.stopPropagation(); + openChipMenu(i); + }} data-invalid={isInvalid || undefined} title={isInvalid ? t('invalid_recipient') : undefined} role="listitem" From af8c8a6ea4a97e64d2b7178e1f28b4eb23469a70 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:39:07 +0100 Subject: [PATCH 39/50] fix: keep toasts above the quick reply box --- .../__tests__/viewer-quick-reply-words.test.tsx | 15 +++++++++++++++ components/email/email-viewer.tsx | 17 ++++++++++++++++- components/ui/__tests__/toast-layout.test.tsx | 8 ++++++++ components/ui/toast.tsx | 3 +++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/components/email/__tests__/viewer-quick-reply-words.test.tsx b/components/email/__tests__/viewer-quick-reply-words.test.tsx index f980a7c93..2262a4ba1 100644 --- a/components/email/__tests__/viewer-quick-reply-words.test.tsx +++ b/components/email/__tests__/viewer-quick-reply-words.test.tsx @@ -85,3 +85,18 @@ describe('quick reply wording', () => { expect(onReply).toHaveBeenCalledWith('Thanks, will do'); }); }); + +// compose-p2-17: the toasts lift over the box by its height (toast.tsx). +describe('quick reply box and the toasts', () => { + it('marks the box and gives the page its height, and takes both back when it goes', async () => { + const height = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(96); + const { unmount } = render(); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + + expect(document.querySelector('[data-quick-reply-box]')).not.toBeNull(); + expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe('96px'); + unmount(); + expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe(''); + height.mockRestore(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 80ffc1c4c..7cdfca9a8 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3516,6 +3516,21 @@ export function EmailViewer({ // Not on spam: a reply confirms the address to the sender. const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder && (!effectiveEmailContent.isHtml || iframeReady); + // Toasts sit above the quick reply box, as above a composer's footer + // (compose-p2-17): its height for toast.tsx, as it grows when focused. + useEffect(() => { + const box = quickReplyBoxRef.current; + if (!showQuickReply || !box || typeof ResizeObserver === 'undefined') return; + const root = document.documentElement; + const update = () => root.style.setProperty('--quick-reply-height', `${box.offsetHeight}px`); + update(); + const observer = new ResizeObserver(update); + observer.observe(box); + return () => { + observer.disconnect(); + root.style.removeProperty('--quick-reply-height'); + }; + }, [showQuickReply]); // The plain-text body, where reader-above-quote mounts between text and quote. const [plainBodyEl, setPlainBodyEl] = useState(null); // Read aloud (browser speech): the body as shown, HTML frame or plain text. @@ -6191,7 +6206,7 @@ ${printWatermark ? printWatermark.html : ''} )} {/* Quick Reply Section - hidden for drafts and while loading a new email */} - {showQuickReply && (
+ {showQuickReply && (
{ expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20'); }); + // compose-p2-17: "Draft saved" covered the quick reply box. + it('sits over the quick reply box, by its height', () => { + render(); + const container = toastContainer(); + expect(container).toHaveClass('[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]'); + expect(container).toHaveClass('max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]'); + }); + it('sits above a registered bottom bar on the desktop too, such as a form footer', () => { render(); const container = toastContainer(); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 33ebeab9b..8de8acca0 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -475,10 +475,13 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: // lifted over a registered bottom bar (a form's Save/Cancel footer) // and over an open composer's footer. "fixed bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))] end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-20", + // And over the reader's quick reply box (compose-p2-17). + "[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]", // Phone: full width with 12px margins, above the bottom controls // (the tab bar, or whichever bar registered with useBottomControls). "max-sm:inset-x-3 max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]", "max-sm:[html[data-composer-open]_&]:bottom-[max(5rem,calc(var(--bottom-controls-height,3.75rem)+0.75rem))]", + "max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]", // Out of the way of the focused element: the top end corner. dodge && "top-5 bottom-auto!", held && "invisible" From 4637f67b37b660c5193eb9690db3599f8ae6c8de Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:39:51 +0100 Subject: [PATCH 40/50] fix: draw the To field's focus ring whole --- components/email/__tests__/composer-a11y.test.tsx | 6 ++++++ components/email/email-composer.tsx | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx index 52783b107..437bb596a 100644 --- a/components/email/__tests__/composer-a11y.test.tsx +++ b/components/email/__tests__/composer-a11y.test.tsx @@ -253,6 +253,12 @@ describe('recipient fields (SR-06, CV-02)', () => { expect(screen.getByRole('list', { name: 'to:' })).toBeInTheDocument(); }); + // compose-p2-18: the ring outside the input was cut by the field's scroller. + it("draws the To field's keyboard focus ring inside it", () => { + render(); + expect(screen.getByRole('combobox', { name: 'to:' })).toHaveClass('focus-inset'); + }); + it('labels the subject field', () => { render(); expect(screen.getByLabelText('subject_label')).toBe(screen.getByTestId('composer-subject')); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 615dfd910..834bc7120 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -6984,7 +6984,10 @@ export function RecipientChipInput({ onKeyDown={handleKeyDown} onPaste={handlePaste} onBlur={handleBlur} - className="flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none" + // focus-inset: the keyboard focus ring goes inside the box. Drawn + // outside, the field's own scroller cut it to a line along the top + // and the end (compose-p2-18, a pop-out focuses To from a key). + className="focus-inset flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none" role="combobox" aria-expanded={activeAutoField === field && autocompleteResults.length > 0} aria-autocomplete="list" From 0728f854d20ca45d874f96b32d7f27dfc7945b2a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:50:22 +0100 Subject: [PATCH 41/50] fix: keep the message in view above a docked reply, which starts compact and grows to half the pane, and takes short panes with a Show the message bar --- .../email/__tests__/composer-docked.test.tsx | 237 ++++++++++++++++++ components/email/email-composer.tsx | 24 +- components/email/email-viewer.tsx | 37 +++ components/mail/__tests__/reply-dock.test.tsx | 58 +++++ components/mail/mail-app.tsx | 8 +- components/mail/reply-dock.tsx | 86 +++++++ 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/vi/common.json | 4 + locales/zh-TW/common.json | 4 + locales/zh/common.json | 4 + 36 files changed, 558 insertions(+), 12 deletions(-) create mode 100644 components/email/__tests__/composer-docked.test.tsx create mode 100644 components/mail/__tests__/reply-dock.test.tsx create mode 100644 components/mail/reply-dock.tsx diff --git a/components/email/__tests__/composer-docked.test.tsx b/components/email/__tests__/composer-docked.test.tsx new file mode 100644 index 000000000..3c221d5fe --- /dev/null +++ b/components/email/__tests__/composer-docked.test.tsx @@ -0,0 +1,237 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// mail-p2-2: a reply docked under its message starts compact: no Subject +// line, no From line when there's only one address to send from, and the +// formatting bar folded behind its button. Full screen shows them again. + +// ─── Heavy component mocks (mirrors composer-mobile-formatting.test.tsx) ────── + +const toastError = vi.hoisted(() => vi.fn()); + +// Shows whether the composer asked for the toolbar to be folded away. +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ toolbarPlacement }: { toolbarPlacement?: string }) => + React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-toolbar': toolbarPlacement ?? 'top' }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +const updateSetting = vi.fn(); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: (...args: unknown[]) => updateSetting(...args), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: toastError, success: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + identityHooks: { + onSignatureRender: { size: 0, transform: async (value: unknown) => value }, + onIdentitySelect: { size: 0, emit: async () => {} }, + }, + templateHooks: { + onTemplateRender: { size: 0, transform: async (value: unknown) => value }, + onTemplateApply: { size: 0, emit: async () => {} }, + }, + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, + onComposeSend: { takeOver: async () => 'default' }, + onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +const DATA = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Re: Lunch', + body: '

Hello there

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'reply' as const, + draftId: null, +}; + +const hidden = (el: Element | null) => !!el && el.className.split(/\s+/).includes('hidden'); + +describe('a reply docked under its message', () => { + beforeEach(() => { + toastError.mockClear(); + updateSetting.mockClear(); + }); + + it('starts compact, with the formatting bar behind its button', () => { + const { container } = render(); + expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(true); + expect(hidden(container.querySelector('[data-composer-from]'))).toBe(true); + const editor = screen.getByTestId('rich-text-editor'); + expect(editor).toHaveAttribute('data-toolbar', 'hidden'); + fireEvent.click(container.querySelector('[data-dock-formatting-toggle]')!); + expect(editor).toHaveAttribute('data-toolbar', 'top'); + }); + + it('shows everything in full screen', () => { + const { container } = render(); + fireEvent.click(screen.getByRole('button', { name: 'maximize' })); + expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(false); + expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-toolbar', 'top'); + }); + + it('keeps every line when not docked', () => { + const { container } = render(); + expect(hidden(container.querySelector('[data-composer-subject]'))).toBe(false); + expect(hidden(container.querySelector('[data-composer-from]'))).toBe(false); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 7777a7bb3..71bfccf51 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -423,6 +423,12 @@ interface EmailComposerProps { popOut?: () => ComposerPopOut | null; /** The move goes back to the main window: the button says that, and shows at any width. */ popOutToMainLabel?: string; + /** + * Docked under the message it answers (mail-p2-2): the composer starts + * compact, without the Subject line, the From line when there's nothing to + * pick, and the formatting bar until asked for. Full screen shows them all. + */ + docked?: boolean; /** * This composer is all a window shows (lib/popout). It closes itself once a * message has gone. Closing the window with unsaved text hands the draft to @@ -611,6 +617,7 @@ export function EmailComposer({ onSaveState, popOut, popOutToMainLabel, + docked = false, ownWindow, onSubjectChange, className, @@ -1252,6 +1259,8 @@ export function EmailComposer({ // button or Escape puts it back. Not in a window of its own, which is all composer. const [maximized, setMaximized] = useState(false); const canMaximize = !ownWindow && !mvWindow; + const dockCompact = docked && !maximized; + const [showDockFormatting, setShowDockFormatting] = useState(false); // "close" (X, Escape): save or discard. "discard" (the Discard button): the // user already chose, so only confirm it. const [closeDialogKind, setCloseDialogKind] = useState<'close' | 'discard'>('close'); @@ -5171,7 +5180,7 @@ export function EmailComposer({ >
{/* From field */} -
+
{t('from')}:
{fromOverrideEnabled ? ( @@ -5473,7 +5482,7 @@ export function EmailComposer({ )} {/* Subject field */} -
+
mvWindow ? setMvShowFormatting((v) => !v) : setShowFormattingOnMobile((v) => !v)} + onClick={() => mvWindow ? setMvShowFormatting((v) => !v) : dockCompact ? setShowDockFormatting((v) => !v) : setShowFormattingOnMobile((v) => !v)} className={cn( "h-9 w-9 max-md:h-11 max-md:w-11", - !mvWindow && "md:hidden", - (mvWindow ? mvShowFormatting : showFormattingOnMobile) && "bg-accent text-accent-foreground", + !mvWindow && !dockCompact && "md:hidden", + (mvWindow ? mvShowFormatting : dockCompact ? showDockFormatting : showFormattingOnMobile) && "bg-accent text-accent-foreground", )} data-mv-formatting-toggle={mvWindow ? "" : undefined} + data-dock-formatting-toggle={dockCompact ? "" : undefined} title={t('formatting')} aria-label={t('formatting')} - aria-pressed={mvWindow ? mvShowFormatting : showFormattingOnMobile} + aria-pressed={mvWindow ? mvShowFormatting : dockCompact ? showDockFormatting : showFormattingOnMobile} > diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 432749293..dfdc04a8b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -827,6 +827,19 @@ export function conversationOpenPlan(conversation: Email[], openId: string): { e return { expanded, foldOpen: !!open.keywords?.$seen, newestId: newest.id }; } +/** + * The reading pane's scroll position that shows the message being answered + * above a docked reply: its end at the pane's bottom edge, or its start at + * the top when the whole message doesn't fit. + */ +export function replyScrollTop(pane: HTMLElement, end: HTMLElement, start: HTMLElement | null): number { + const paneTop = pane.getBoundingClientRect().top; + const endY = end.getBoundingClientRect().top - paneTop + pane.scrollTop; + const startY = start ? start.getBoundingClientRect().top - paneTop + pane.scrollTop : 0; + const toEnd = endY - pane.clientHeight; + return Math.max(0, startY > toEnd ? toEnd : startY); +} + /** A button's tooltip with the key the menus and the shortcut help show (mail-p2-13). */ function useKeyTip() { const shortcut = useMessageActionShortcut(); @@ -3662,6 +3675,27 @@ export function EmailViewer({ const latestSrcDocRef = useRef(null); const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc; // Not on spam: a reply confirms the address to the sender. + // A reply docks under the message it answers: keep that message in view + // above it, its end just over the dock, or its start when it is taller + // than what is left of the pane (mail-p2-2). + useEffect(() => { + if (!replyComposerOpen || !email) return; + // The message answered is read while answering: unfold it. + setMvOpenCollapsed(false); + let frame = 0; + const place = () => { + const pane = document.querySelector('[data-reader-scroll]'); + const end = pane?.querySelector('[data-open-message-end]'); + if (!pane || !end) return; + const start = pane.querySelector('[data-mv-open-header]') ?? pane.querySelector('[data-mv-open-folded]'); + pane.scrollTop = replyScrollTop(pane, end, start); + }; + // Once the dock has taken its room, and again when its height settles. + frame = requestAnimationFrame(() => { frame = requestAnimationFrame(place); }); + const timer = setTimeout(place, 250); + return () => { cancelAnimationFrame(frame); clearTimeout(timer); }; + }, [replyComposerOpen, email?.id]); // eslint-disable-line react-hooks/exhaustive-deps -- once per reply opened + const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder && (!effectiveEmailContent.isHtml || iframeReady); // The plain-text body, where reader-above-quote mounts between text and quote. @@ -6320,6 +6354,9 @@ ${printWatermark ? printWatermark.html : ''} + {/* Where the open message ends: a reply docked under it scrolls to here. */} +