From 94bb55765d783f6852f2942f678ca0043a5ad145 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:04:28 +0100 Subject: [PATCH 1/6] fix: give pop-out and quick compose windows the main composer's layout, with Send bottom right and Discard as a bin --- .../composer-windows-layout.test.tsx | 240 ++++++++++++++++++ components/email/email-composer.tsx | 33 +-- 2 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 components/email/__tests__/composer-windows-layout.test.tsx diff --git a/components/email/__tests__/composer-windows-layout.test.tsx b/components/email/__tests__/composer-windows-layout.test.tsx new file mode 100644 index 000000000..fd1ccd18c --- /dev/null +++ b/components/email/__tests__/composer-windows-layout.test.tsx @@ -0,0 +1,240 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// One layout in every composer: Send at the bottom end, Discard as a bin left +// of it, the formatting toolbar showing. A pop-out or quick compose window is +// narrower than a tablet, and used to get the phone's layout: Send in the +// header, Discard where Send was, the toolbar behind a button. + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ collapseToolbarOnMobile }: { collapseToolbarOnMobile?: boolean }) => + React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-collapsed': String(!!collapseToolbarOnMobile) }), +})); + +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 }), +})); + +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 }; +}); + +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: () => {}, + }; + 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 }; +}); + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {}, warning: () => {} }, +})); + +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 })); + +const DATA = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Lunch', + body: '

Hello there

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +const footer = () => document.querySelector('[data-composer-footer]')!; +const header = () => document.querySelector('[data-composer-header]')!; + +describe('a composer in a window of its own', () => { + it('has one Send, at the bottom end, with Discard as a bin right before it', () => { + render(); + const sends = screen.getAllByTestId('composer-send'); + expect(sends).toHaveLength(1); + expect(footer()).toContainElement(sends[0]); + expect(sends[0].className).not.toMatch(/(^|\s)hidden(\s|$)/); + expect(within(header()).queryByTestId('composer-send')).toBeNull(); + + const discard = screen.getByTestId('composer-discard'); + expect(discard.querySelector('svg')).not.toBeNull(); + // Right before Send, in the same group. + expect(discard.nextElementSibling).toBe(sends[0]); + }); + + it('shows the formatting toolbar, with no button to fold it away', () => { + render(); + expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-collapsed', 'false'); + expect(screen.queryByRole('button', { name: 'formatting' })).toBeNull(); + }); + + it('keeps the close button where the main window has it', () => { + render(); + expect(screen.getByTestId('composer-close').className).not.toContain('order-first'); + }); +}); + +describe('a composer in the main window', () => { + it('keeps the phone layout for a phone: Send in the header there, toolbar folded', () => { + render(); + expect(within(header()).getByTestId('composer-send')).toBeInTheDocument(); + expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-collapsed', 'true'); + expect(screen.getByRole('button', { name: 'formatting' })).toBeInTheDocument(); + }); + + it('shows Discard as a bin too, with its name for screen readers', () => { + render(); + const discard = screen.getByTestId('composer-discard'); + expect(discard).toHaveAccessibleName('discard'); + expect(discard.querySelector('svg')).not.toBeNull(); + expect(within(discard).getByText('discard').className).toContain('sr-only'); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 3409f77db..42a8fb93b 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1196,6 +1196,9 @@ export function EmailComposer({ // bar folded away behind "Aa" until asked for. const mvWindow = useMvComposeWindow(); const [mvShowFormatting, setMvShowFormatting] = useState(false); + // A pop-out or quick compose window is narrower than a tablet, but it is a + // window on a computer: it keeps the main window's layout, not the phone's. + const phoneLayout = !ownWindow; // "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'); @@ -4825,25 +4828,25 @@ export function EmailComposer({ {saveStatus === 'saving' && (
- {t('saving')} + {t('saving')}
)} {saveStatus === 'saved' && (
- {savedOnDevice ? tOfflineDrafts('saved_on_device') : t('draft_saved')} + {savedOnDevice ? tOfflineDrafts('saved_on_device') : t('draft_saved')}
)} {saveStatus === 'error' && (
- {t('save_failed')} + {t('save_failed')}
)} {/* Mobile: keep scheduled send reachable without consuming footer space. */} - {composerClient?.hasDelayedSend() || hasSendMenuActions ? ( + {phoneLayout && (composerClient?.hasDelayedSend() || hasSendMenuActions ? (
- )} + ))} {rightPanelToggle} - {popOutButton("hidden md:grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", "w-4 h-4")} + {popOutButton(cn(phoneLayout ? "hidden md:grid" : "grid", "h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"), "w-4 h-4")} {/* Close at the top end (R4). On a phone it moves to the start, as the phone's mail apps have it, with Send at the end. */} handleClose()} - className="-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11" + className={phoneLayout ? "-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11" : "-me-2"} label={tCommon('close')} data-testid="composer-close" data-own-window-close={ownWindow ? "" : undefined} @@ -5300,7 +5303,7 @@ export function EmailComposer({ ariaInvalid={validationErrors.body} ariaDescribedBy={`${fieldIdBase}-body-error`} defaultTextStyle={composeStyle} - collapseToolbarOnMobile={!showFormattingOnMobile} + collapseToolbarOnMobile={phoneLayout && !showFormattingOnMobile} toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : 'top'} onEditorReady={handleEditorReady} mentionCandidates={recipientMentionsEnabled ? mentionCandidates : undefined} @@ -5565,7 +5568,7 @@ export function EmailComposer({ {t('attach')} )} - {!plainTextMode && ( + {!plainTextMode && (mvWindow || phoneLayout) && ( )} {composerClient?.hasDelayedSend() || hasSendMenuActions ? ( -
+
))} {rightPanelToggle} + {canMaximize && ( + + )} {popOutButton(cn(phoneLayout ? "hidden md:grid" : "grid", "h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"), "w-4 h-4")} {/* Close at the top end (R4). On a phone it moves to the start, as the phone's mail apps have it, with Send at the end. */} From c0300bcb7fb0229be5e94be53268d90bd2ef63e7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:24:28 +0100 Subject: [PATCH 4/6] feat: keep the formatting toolbar in one row, with the rest behind More formatting --- ...rich-text-editor-toolbar-overflow.test.tsx | 114 +++++++++++++++ components/email/rich-text-editor.tsx | 136 +++++++++++++++++- locales/ar/common.json | 3 + locales/ca/common.json | 3 + locales/cs/common.json | 3 + locales/da/common.json | 3 + locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/et/common.json | 3 + locales/fa/common.json | 3 + locales/fr/common.json | 3 + locales/he/common.json | 3 + locales/hu/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/ko/common.json | 3 + locales/lv/common.json | 3 + locales/mn/common.json | 3 + locales/nb/common.json | 3 + locales/nl/common.json | 3 + locales/pl/common.json | 3 + locales/pt/common.json | 3 + locales/ro/common.json | 3 + locales/ru/common.json | 3 + locales/sk/common.json | 3 + locales/th/common.json | 3 + locales/tr/common.json | 3 + locales/uk/common.json | 3 + locales/vi/common.json | 3 + locales/zh-TW/common.json | 3 + locales/zh/common.json | 3 + 32 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx diff --git a/components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx b/components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx new file mode 100644 index 000000000..c45bc217c --- /dev/null +++ b/components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx @@ -0,0 +1,114 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; + +import { RichTextEditor, formattingFoldCount } from '../rich-text-editor'; + +// The formatting toolbar keeps one row: in a narrow composer (585 px at +// 1280x720) it wrapped to two and took the body's room. What doesn't fit goes +// behind "More formatting", the least used first. + +let toolbarWidth = 1200; +const observers = new Set(); +class ReportingResizeObserver { + constructor(private cb: ResizeObserverCallback) {} + observe() { observers.add(this.cb); } + unobserve() {} + disconnect() { observers.delete(this.cb); } +} +vi.stubGlobal('ResizeObserver', ReportingResizeObserver); + +const realClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')!; +Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get(this: HTMLElement) { + return this.getAttribute('role') === 'toolbar' ? toolbarWidth : realClientWidth.get!.call(this); + }, +}); + +afterEach(() => { + toolbarWidth = 1200; +}); + +async function setup() { + let ready = false; + render( {}} onEditorReady={() => { ready = true; }} />); + await waitFor(() => expect(ready).toBe(true)); +} + +async function resize(width: number) { + toolbarWidth = width; + await act(async () => { + for (const cb of observers) cb([], {} as ResizeObserver); + }); +} + +const toolbar = () => screen.getByRole('toolbar', { name: 'formatting' }); +const rowItem = (name: string) => toolbar().querySelector(`[data-toolbar-item="${name}"]`); + +describe('formatting toolbar in a narrow composer', () => { + it('shows every button in one row when there is room, without a More button', async () => { + await setup(); + for (const name of ['heading-1', 'quote', 'align-left', 'table', 'undo']) expect(rowItem(name)).not.toBeNull(); + expect(rowItem('more-formatting')).toBeNull(); + }); + + it('moves what does not fit behind "More formatting", and shows it there', async () => { + await setup(); + await resize(561); + + // The least used go first: undo, redo, clear formatting, then alignment. + for (const name of ['undo', 'redo', 'clear-formatting', 'align-left', 'align-right']) expect(rowItem(name)).toBeNull(); + for (const name of ['bold', 'bullet-list', 'link', 'heading-1', 'table']) expect(rowItem(name)).not.toBeNull(); + + const more = rowItem('more-formatting') as HTMLButtonElement; + expect(more).toHaveAccessibleName('more_formatting'); + expect(more).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(more); + const panel = screen.getByRole('dialog', { name: 'more_formatting' }); + expect(more).toHaveAttribute('aria-expanded', 'true'); + for (const name of ['align-left', 'align-center', 'align-right', 'clear-formatting', 'undo', 'redo']) { + expect(within(panel).getByRole('button', { name: new RegExp(`^${name.replace('-', '_')}`) })).toBeInTheDocument(); + } + }); + + it('keeps the arrows in the row: the panel buttons are no Tab stops of the toolbar', async () => { + await setup(); + await resize(561); + fireEvent.click(rowItem('more-formatting')!); + const panel = screen.getByRole('dialog', { name: 'more_formatting' }); + const rowStops = Array.from(toolbar().querySelectorAll('[data-toolbar-item]')) + .filter((el) => !panel.contains(el) && el.tabIndex === 0); + expect(rowStops).toHaveLength(1); + }); + + it('brings everything back into the row when the composer gets wider', async () => { + await setup(); + await resize(400); + expect(rowItem('heading-1')).toBeNull(); + await resize(1000); + expect(rowItem('heading-1')).not.toBeNull(); + expect(rowItem('undo')).not.toBeNull(); + expect(rowItem('more-formatting')).toBeNull(); + }); +}); + +describe('formattingFoldCount', () => { + const sizes = { button: 28, gap: 2, separator: 5 }; + + it('folds nothing for a toolbar that is not laid out', () => { + expect(formattingFoldCount(0, sizes)).toBe(0); + }); + + it('folds more groups the narrower the row', () => { + const counts = [900, 700, 561, 480, 400, 300].map((width) => formattingFoldCount(width, sizes)); + expect(counts[0]).toBe(0); + for (let i = 1; i < counts.length; i++) expect(counts[i]).toBeGreaterThanOrEqual(counts[i - 1]); + expect(counts[counts.length - 1]).toBe(5); + }); + + it('makes room for the text direction button', () => { + const width = 700; + expect(formattingFoldCount(width, { ...sizes, directionButton: true })).toBeGreaterThanOrEqual(formattingFoldCount(width, sizes)); + }); +}); diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 3448897cd..d4e4ac6cd 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -70,8 +70,56 @@ import { Rows3, Columns3, ImageIcon, + Ellipsis, } from "@/components/icons"; +/** + * Groups of the formatting toolbar that move behind "More formatting" when one + * row is too narrow for all of them, the least used first. Each with its + * buttons and the separators that go with it. + */ +const FOLDABLE_GROUPS = [ + { id: "history", buttons: 3, separators: 2 }, + { id: "align", buttons: 3, separators: 1 }, + { id: "headings", buttons: 2, separators: 1 }, + { id: "table", buttons: 1, separators: 0 }, + { id: "blocks", buttons: 2, separators: 0 }, +] as const; +type FoldableGroup = (typeof FOLDABLE_GROUPS)[number]["id"]; +/** + * Always in the row: font size, bold, italic, underline, strikethrough, the + * two colours, the lists, link, an image's description and More (12), with + * their 3 separators. + */ +const FIXED_TOOLBAR = { buttons: 12, separators: 3 }; + +/** + * How many of FOLDABLE_GROUPS go behind "More formatting" for one row of + * `width` px. 0 for a toolbar that isn't laid out (hidden, or not measured). + */ +export function formattingFoldCount(width: number, sizes: { button: number; gap: number; separator: number; directionButton?: boolean }): number { + if (!(width > 0)) return 0; + const span = (buttons: number, separators: number) => buttons * (sizes.button + sizes.gap) + separators * (sizes.separator + sizes.gap); + const groups = FOLDABLE_GROUPS.map((group) => span(group.buttons + (group.id === "align" && sizes.directionButton ? 1 : 0), group.separators)); + let needed = span(FIXED_TOOLBAR.buttons, FIXED_TOOLBAR.separators) + groups.reduce((sum, group) => sum + group, 0); + // All of it fits without the More button. + if (needed - (sizes.button + sizes.gap) <= width + 1) return 0; + for (let count = 1; count <= groups.length; count++) { + needed -= groups[count - 1]; + if (needed <= width + 1) return count; + } + return groups.length; +} + +/** The toolbar's inner width and the sizes of what is in it, in px. */ +function measureFormattingToolbar(toolbar: HTMLElement, directionButton: boolean): number { + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + const style = getComputedStyle(toolbar); + const width = toolbar.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0); + const button = toolbar.querySelector(":scope > [data-toolbar-item], :scope > div > [data-toolbar-item]")?.offsetWidth || 1.75 * rem; + return formattingFoldCount(width, { button, gap: 0.125 * rem, separator: 1 + 0.25 * rem, directionButton }); +} + export interface InlineImageUpload { src: string; cid?: string; @@ -699,6 +747,7 @@ export function RichTextEditor({ }, [editor, linkDialog]); const tToolbar = useTranslations("email_composer.toolbar"); + const tWindows = useTranslations("compose_windows"); const tCommon = useTranslations("common"); const [tableMenuOpen, setTableMenuOpen] = useState(false); const tableWrapperRef = useRef(null); @@ -717,11 +766,13 @@ export function RichTextEditor({ // focus last keeps it, the arrows, Home and End move inside. const toolbarRef = useRef(null); const rovingItemRef = useRef(null); + // Buttons in the "More formatting" panel are the panel's, with its own keys. + const inRow = (el: HTMLElement) => !el.closest("[data-toolbar-more-panel]"); const toolbarItems = () => Array.from(toolbarRef.current?.querySelectorAll("[data-toolbar-item]") ?? []) - .filter((el) => !el.disabled); + .filter((el) => !el.disabled && inRow(el)); const syncToolbarTabStops = (focus?: HTMLButtonElement) => { - const all = Array.from(toolbarRef.current?.querySelectorAll("[data-toolbar-item]") ?? []); + const all = Array.from(toolbarRef.current?.querySelectorAll("[data-toolbar-item]") ?? []).filter(inRow); const enabled = all.filter((el) => !el.disabled); const current = focus ?? enabled.find((el) => el.dataset.toolbarItem === rovingItemRef.current) ?? enabled[0]; if (current) rovingItemRef.current = current.dataset.toolbarItem ?? null; @@ -740,6 +791,36 @@ export function RichTextEditor({ return !!stop && document.activeElement === stop; }; + // One row: what doesn't fit goes behind "More formatting" (a wrapped + // second row took the body's room in a narrow composer). + const [foldCount, setFoldCount] = useState(0); + const [moreOpen, setMoreOpen] = useState(false); + const moreButtonRef = useRef(null); + const moreWrapperRef = useRef(null); + const hasEditor = !!editor; + useLayoutEffect(() => { + const toolbar = toolbarRef.current; + if (!toolbar) return; + const measure = () => setFoldCount(measureFormattingToolbar(toolbar, !!rtlEditingSupport)); + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + measure(); + setMoreOpen(false); + }); + observer.observe(toolbar); + return () => observer.disconnect(); + }, [hasEditor, rtlEditingSupport]); + + useEffect(() => { + if (!moreOpen) return; + const handler = (e: MouseEvent) => { + if (moreWrapperRef.current && !moreWrapperRef.current.contains(e.target as Node)) setMoreOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [moreOpen]); + useEffect(() => { if (!fontSizeMenuOpen) return; const handler = (e: MouseEvent) => { @@ -799,6 +880,15 @@ export function RichTextEditor({ const altTarget = findAltTarget(editor, altRequest); const imageSelected = editor.isActive("image"); + // A group that doesn't fit renders in the "More formatting" panel instead, in row order. + const folded = new Set(FOLDABLE_GROUPS.slice(0, foldCount).map((group) => group.id)); + const overflow: React.ReactNode[] = []; + const place = (group: FoldableGroup, node: React.ReactNode) => { + if (!folded.has(group)) return node; + overflow.push({node}); + return null; + }; + const onToolbarKeyDown = (e: React.KeyboardEvent) => { const target = e.target as HTMLElement; // Keys inside a popover are the popover's. @@ -819,7 +909,7 @@ export function RichTextEditor({ }; const onToolbarFocus = (e: React.FocusEvent) => { const target = e.target as HTMLElement; - if (target instanceof HTMLButtonElement && target.hasAttribute("data-toolbar-item")) syncToolbarTabStops(target); + if (target instanceof HTMLButtonElement && target.hasAttribute("data-toolbar-item") && inRow(target)) syncToolbarTabStops(target); }; const renderPalette = (current: string | null, apply: (color: string) => void, removeLabel: string, remove: () => void) => ( @@ -1084,6 +1174,7 @@ export function RichTextEditor({ + {place("headings", <> + )} + {place("blocks", <> + )} + {place("align", <> + )} )} -
+ {place("table",
) )} -
+
)} + {place("history", <> + )} + + {overflow.length > 0 && ( +
+ setMoreOpen((v) => !v)} + title={tWindows("more_formatting")} + > + + + {moreOpen && ( + setMoreOpen(false)} + className="p-1 flex w-max max-w-[16rem] flex-wrap items-center gap-0.5" + > +
+ {overflow} +
+
+ )} +
+ )}
{/* Editor */} diff --git a/locales/ar/common.json b/locales/ar/common.json index 5bf41a9d5..47150985b 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -7066,6 +7066,9 @@ "types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.", "reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى." }, + "compose_windows": { + "more_formatting": "مزيد من التنسيق" + }, "desktopReminders": { "generic_title": "تذكير", "snooze": "تأجيل {count} د", diff --git a/locales/ca/common.json b/locales/ca/common.json index eca36135b..ec7b404e9 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -7066,6 +7066,9 @@ "types_character": "Aquestes tecles escriuen un caràcter al teu teclat, i deixaria de funcionar a totes les aplicacions. Prova altres tecles.", "reserved": "El sistema o la majoria d'aplicacions fan servir aquestes tecles. Prova altres tecles." }, + "compose_windows": { + "more_formatting": "Més format" + }, "desktopReminders": { "generic_title": "Recordatori", "snooze": "Posposa {count} min", diff --git a/locales/cs/common.json b/locales/cs/common.json index f1f5076dd..2201071a4 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -7066,6 +7066,9 @@ "types_character": "Tyto klávesy na vaší klávesnici píší znak, který by pak nefungoval v žádné aplikaci. Zkuste jiné klávesy.", "reserved": "Tyto klávesy používá systém nebo většina aplikací. Zkuste jiné klávesy." }, + "compose_windows": { + "more_formatting": "Další formátování" + }, "desktopReminders": { "generic_title": "Připomenutí", "snooze": "Odložit o {count} min", diff --git a/locales/da/common.json b/locales/da/common.json index 7461d652b..9c2068085 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -7066,6 +7066,9 @@ "types_character": "Disse taster skriver et tegn på dit tastatur, og det ville holde op med at virke i alle apps. Prøv andre taster.", "reserved": "Dit system eller de fleste apps bruger disse taster. Prøv andre taster." }, + "compose_windows": { + "more_formatting": "Mere formatering" + }, "desktopReminders": { "generic_title": "Påmindelse", "snooze": "Udsæt {count} min.", diff --git a/locales/de/common.json b/locales/de/common.json index 31d15c0ed..eb87601a1 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -7066,6 +7066,9 @@ "types_character": "Diese Tasten erzeugen auf Ihrer Tastatur ein Zeichen, das dann in keiner App mehr funktionieren würde. Versuchen Sie andere Tasten.", "reserved": "Ihr System oder die meisten Apps verwenden diese Tasten. Versuchen Sie andere Tasten." }, + "compose_windows": { + "more_formatting": "Weitere Formatierung" + }, "desktopReminders": { "generic_title": "Erinnerung", "snooze": "{count} Min. schlummern", diff --git a/locales/en/common.json b/locales/en/common.json index e0491ee32..df18634ac 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -7066,6 +7066,9 @@ "types_character": "These keys type a character on your keyboard, and it would stop working in every app. Try other keys.", "reserved": "Your system or most apps use these keys. Try other keys." }, + "compose_windows": { + "more_formatting": "More formatting" + }, "desktopReminders": { "generic_title": "Reminder", "snooze": "Snooze {count} min", diff --git a/locales/es/common.json b/locales/es/common.json index 1050f0f56..4fdecd792 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -7066,6 +7066,9 @@ "types_character": "Estas teclas escriben un carácter en tu teclado, y dejaría de funcionar en todas las aplicaciones. Prueba otras teclas.", "reserved": "Tu sistema o la mayoría de las aplicaciones usan estas teclas. Prueba otras teclas." }, + "compose_windows": { + "more_formatting": "Más formato" + }, "desktopReminders": { "generic_title": "Recordatorio", "snooze": "Posponer {count} min", diff --git a/locales/et/common.json b/locales/et/common.json index df425b8bd..cde6d1575 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -7066,6 +7066,9 @@ "types_character": "Need klahvid kirjutavad teie klaviatuuril märgi, mis lakkaks kõigis rakendustes töötamast. Proovige teisi klahve.", "reserved": "Neid klahve kasutab süsteem või enamik rakendusi. Proovige teisi klahve." }, + "compose_windows": { + "more_formatting": "Rohkem vormindust" + }, "desktopReminders": { "generic_title": "Meeldetuletus", "snooze": "Lükka {count} min edasi", diff --git a/locales/fa/common.json b/locales/fa/common.json index bf242d98d..fead83ed0 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -7066,6 +7066,9 @@ "types_character": "این کلیدها روی صفحه‌کلید شما نویسه‌ای می‌نویسند و آن نویسه دیگر در هیچ برنامه‌ای کار نمی‌کند. کلیدهای دیگری را امتحان کنید.", "reserved": "سیستم یا بیشتر برنامه‌ها از این کلیدها استفاده می‌کنند. کلیدهای دیگری را امتحان کنید." }, + "compose_windows": { + "more_formatting": "قالب‌بندی بیشتر" + }, "desktopReminders": { "generic_title": "یادآوری", "snooze": "تعویق {count} دقیقه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 03545491c..2405729a9 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -7066,6 +7066,9 @@ "types_character": "Ces touches produisent un caractère sur votre clavier, et il ne fonctionnerait plus dans aucune application. Essayez d'autres touches.", "reserved": "Votre système ou la plupart des applications utilisent ces touches. Essayez d'autres touches." }, + "compose_windows": { + "more_formatting": "Plus de mise en forme" + }, "desktopReminders": { "generic_title": "Rappel", "snooze": "Reporter de {count} min", diff --git a/locales/he/common.json b/locales/he/common.json index f8258a747..f3c8a59bb 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -7067,6 +7067,9 @@ "types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.", "reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים." }, + "compose_windows": { + "more_formatting": "עוד עיצוב" + }, "desktopReminders": { "generic_title": "תזכורת", "snooze": "דחייה ב-{count} דק'", diff --git a/locales/hu/common.json b/locales/hu/common.json index bd65a8ec0..52ce79571 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -7066,6 +7066,9 @@ "types_character": "Ezek a billentyűk karaktert írnak az Ön billentyűzetén, amely többé egyik alkalmazásban sem működne. Próbáljon más billentyűket.", "reserved": "Ezeket a billentyűket a rendszer vagy a legtöbb alkalmazás használja. Próbáljon más billentyűket." }, + "compose_windows": { + "more_formatting": "További formázás" + }, "desktopReminders": { "generic_title": "Emlékeztető", "snooze": "Halasztás {count} perccel", diff --git a/locales/it/common.json b/locales/it/common.json index b79013a3a..7fbfffd54 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -7066,6 +7066,9 @@ "types_character": "Questi tasti scrivono un carattere sulla tua tastiera, che smetterebbe di funzionare in tutte le app. Prova altri tasti.", "reserved": "Il sistema o la maggior parte delle app usano questi tasti. Prova altri tasti." }, + "compose_windows": { + "more_formatting": "Altre opzioni di formattazione" + }, "desktopReminders": { "generic_title": "Promemoria", "snooze": "Rimanda di {count} min", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0fc511808..7e2153f1e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -7066,6 +7066,9 @@ "types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。", "reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。" }, + "compose_windows": { + "more_formatting": "その他の書式" + }, "desktopReminders": { "generic_title": "リマインダー", "snooze": "{count} 分後に再通知", diff --git a/locales/ko/common.json b/locales/ko/common.json index 21bc7b936..184b4c591 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -7066,6 +7066,9 @@ "types_character": "이 키는 사용 중인 키보드에서 문자를 입력하며, 지정하면 모든 앱에서 그 문자를 입력할 수 없게 됩니다. 다른 키를 사용해 보세요.", "reserved": "시스템 또는 대부분의 앱이 이 키를 사용합니다. 다른 키를 사용해 보세요." }, + "compose_windows": { + "more_formatting": "추가 서식" + }, "desktopReminders": { "generic_title": "알림", "snooze": "{count}분 후 다시 알림", diff --git a/locales/lv/common.json b/locales/lv/common.json index 2958f811c..53457abe5 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -7066,6 +7066,9 @@ "types_character": "Šie taustiņi jūsu tastatūrā raksta rakstzīmi, kas pārstātu darboties visās lietotnēs. Izmēģiniet citus taustiņus.", "reserved": "Šos taustiņus izmanto sistēma vai lielākā daļa lietotņu. Izmēģiniet citus taustiņus." }, + "compose_windows": { + "more_formatting": "Vairāk formatēšanas" + }, "desktopReminders": { "generic_title": "Atgādinājums", "snooze": "Atlikt par {count} min", diff --git a/locales/mn/common.json b/locales/mn/common.json index 57128748e..1d6eefc33 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -7066,6 +7066,9 @@ "types_character": "Эдгээр товч таны гар дээр тэмдэгт бичдэг тул тэр тэмдэгт бүх аппад ажиллахаа болино. Өөр товч туршина уу.", "reserved": "Систем эсвэл ихэнх апп эдгээр товчийг ашигладаг. Өөр товч туршина уу." }, + "compose_windows": { + "more_formatting": "Бусад хэлбэржүүлэлт" + }, "desktopReminders": { "generic_title": "Сануулга", "snooze": "{count} минут хойшлуулах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 27c9f7642..9952353ed 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -7066,6 +7066,9 @@ "types_character": "Disse tastene skriver et tegn på tastaturet ditt, og det ville slutte å virke i alle apper. Prøv andre taster.", "reserved": "Systemet ditt eller de fleste apper bruker disse tastene. Prøv andre taster." }, + "compose_windows": { + "more_formatting": "Mer formatering" + }, "desktopReminders": { "generic_title": "Påminnelse", "snooze": "Utsett {count} min", diff --git a/locales/nl/common.json b/locales/nl/common.json index 69e5afbcc..8a57c3d58 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -7066,6 +7066,9 @@ "types_character": "Deze toetsen typen een teken op je toetsenbord, en dat zou in geen enkele app meer werken. Probeer andere toetsen.", "reserved": "Je systeem of de meeste apps gebruiken deze toetsen. Probeer andere toetsen." }, + "compose_windows": { + "more_formatting": "Meer opmaak" + }, "desktopReminders": { "generic_title": "Herinnering", "snooze": "{count} min uitstellen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 35e69ac26..7c63c556d 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -7066,6 +7066,9 @@ "types_character": "Te klawisze wpisują znak na Twojej klawiaturze i przestałby on działać we wszystkich aplikacjach. Wypróbuj inne klawisze.", "reserved": "Te klawisze są używane przez system lub większość aplikacji. Wypróbuj inne klawisze." }, + "compose_windows": { + "more_formatting": "Więcej formatowania" + }, "desktopReminders": { "generic_title": "Przypomnienie", "snooze": "Odłóż o {count} min", diff --git a/locales/pt/common.json b/locales/pt/common.json index 7a094b8ea..784fe41f3 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -7066,6 +7066,9 @@ "types_character": "Estas teclas escrevem um carácter no seu teclado, e ele deixaria de funcionar em todas as aplicações. Experimente outras teclas.", "reserved": "O seu sistema ou a maioria das aplicações usam estas teclas. Experimente outras teclas." }, + "compose_windows": { + "more_formatting": "Mais formatação" + }, "desktopReminders": { "generic_title": "Lembrete", "snooze": "Adiar {count} min", diff --git a/locales/ro/common.json b/locales/ro/common.json index 1c3d817ce..859167031 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -7066,6 +7066,9 @@ "types_character": "Aceste taste scriu un caracter pe tastatura ta, iar el n-ar mai funcționa în nicio aplicație. Încearcă alte taste.", "reserved": "Sistemul sau majoritatea aplicațiilor folosesc aceste taste. Încearcă alte taste." }, + "compose_windows": { + "more_formatting": "Mai multe opțiuni de formatare" + }, "desktopReminders": { "generic_title": "Memento", "snooze": "Amână {count} min", diff --git a/locales/ru/common.json b/locales/ru/common.json index c5c112ea2..a69ddb27f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -7066,6 +7066,9 @@ "types_character": "На вашей клавиатуре эти клавиши вводят символ, и он перестал бы работать во всех приложениях. Попробуйте другие клавиши.", "reserved": "Эти клавиши использует система или большинство приложений. Попробуйте другие клавиши." }, + "compose_windows": { + "more_formatting": "Дополнительное форматирование" + }, "desktopReminders": { "generic_title": "Напоминание", "snooze": "Отложить на {count} мин", diff --git a/locales/sk/common.json b/locales/sk/common.json index 81d50e05d..a2d4e899d 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -7066,6 +7066,9 @@ "types_character": "Tieto klávesy na vašej klávesnici píšu znak, ktorý by potom nefungoval v žiadnej aplikácii. Skúste iné klávesy.", "reserved": "Tieto klávesy používa systém alebo väčšina aplikácií. Skúste iné klávesy." }, + "compose_windows": { + "more_formatting": "Ďalšie formátovanie" + }, "desktopReminders": { "generic_title": "Pripomienka", "snooze": "Odložiť o {count} min", diff --git a/locales/th/common.json b/locales/th/common.json index c34c371f1..0f75e53a6 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -7074,6 +7074,9 @@ "types_character": "ปุ่มเหล่านี้พิมพ์อักขระบนแป้นพิมพ์ของคุณ และอักขระนั้นจะใช้ไม่ได้ในทุกแอป ลองใช้ปุ่มอื่น", "reserved": "ระบบหรือแอปส่วนใหญ่ใช้ปุ่มเหล่านี้ ลองใช้ปุ่มอื่น" }, + "compose_windows": { + "more_formatting": "การจัดรูปแบบเพิ่มเติม" + }, "desktopReminders": { "generic_title": "การแจ้งเตือน", "snooze": "เลื่อน {count} นาที", diff --git a/locales/tr/common.json b/locales/tr/common.json index 43380e45c..6b6e6b1d6 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -7066,6 +7066,9 @@ "types_character": "Bu tuşlar klavyenizde bir karakter yazar ve bu karakter hiçbir uygulamada çalışmaz olur. Başka tuşlar deneyin.", "reserved": "Bu tuşları sisteminiz veya çoğu uygulama kullanıyor. Başka tuşlar deneyin." }, + "compose_windows": { + "more_formatting": "Daha fazla biçimlendirme" + }, "desktopReminders": { "generic_title": "Anımsatıcı", "snooze": "{count} dk ertele", diff --git a/locales/uk/common.json b/locales/uk/common.json index d8d7ecf49..9fce99633 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -7066,6 +7066,9 @@ "types_character": "На вашій клавіатурі ці клавіші вводять символ, і він перестав би працювати в усіх застосунках. Спробуйте інші клавіші.", "reserved": "Ці клавіші використовує система або більшість застосунків. Спробуйте інші клавіші." }, + "compose_windows": { + "more_formatting": "Додаткове форматування" + }, "desktopReminders": { "generic_title": "Нагадування", "snooze": "Відкласти на {count} хв", diff --git a/locales/vi/common.json b/locales/vi/common.json index ccb97537d..73d2113ab 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -7078,6 +7078,9 @@ "types_character": "Các phím này gõ một ký tự trên bàn phím của bạn, và ký tự đó sẽ không dùng được trong mọi ứng dụng. Hãy thử phím khác.", "reserved": "Hệ thống hoặc hầu hết ứng dụng dùng các phím này. Hãy thử phím khác." }, + "compose_windows": { + "more_formatting": "Thêm định dạng" + }, "desktopReminders": { "generic_title": "Lời nhắc", "snooze": "Báo lại sau {count} phút", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 3bc47f02b..599c9c3b5 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -7066,6 +7066,9 @@ "types_character": "這些鍵在您的鍵盤上會輸入一個字元,占用後該字元在所有應用程式中都無法輸入。請試試其他按鍵。", "reserved": "系統或大多數應用程式正在使用這些鍵。請試試其他按鍵。" }, + "compose_windows": { + "more_formatting": "更多格式" + }, "desktopReminders": { "generic_title": "提醒", "snooze": "{count} 分鐘後再提醒", diff --git a/locales/zh/common.json b/locales/zh/common.json index d4646cf11..d558b39e7 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -7066,6 +7066,9 @@ "types_character": "这些键在您的键盘上会输入一个字符,占用后该字符在所有应用中都无法输入。请试试其他按键。", "reserved": "系统或大多数应用在使用这些键。请试试其他按键。" }, + "compose_windows": { + "more_formatting": "更多格式" + }, "desktopReminders": { "generic_title": "提醒", "snooze": "{count} 分钟后再提醒", From 7d2859e064b0ca2d33650ccc3b7b6f05a31ffdd6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:44:56 +0100 Subject: [PATCH 5/6] feat: keep undo send in the pop-out or quick compose window until the time is over --- .../__tests__/popout-compose-app.test.tsx | 88 ++++++++++ components/popout/popout-compose-app.tsx | 162 +++++++++++++++++- 2 files changed, 244 insertions(+), 6 deletions(-) diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 6ceedf1fa..d92e90439 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -4,6 +4,7 @@ import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-ch import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { createComposeHandoff, rememberComposePayload, rememberedComposePayload } from '@/lib/popout/compose-handoff'; import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; import { useIdentityStore } from '@/stores/identity-store'; import { usePluginStore } from '@/stores/plugin-store'; import { emailHooks } from '@/lib/plugin-hooks'; @@ -547,3 +548,90 @@ describe('after this page crashed', () => { expect(rememberedComposePayload('h2')).toBeNull(); }); }); + +describe('a send held for undo', () => { + const closeSelf = vi.fn(); + const MAILBOXES = [{ id: 'mDrafts', role: 'drafts' }, { id: 'mSent', role: 'sent' }]; + const startDraft = { + id: 'd0', subject: 'Before sending', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [], + }; + const restoredDraft = { + id: 'e1', subject: 'Back again', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [], + }; + let originalEmail: ReturnType; + let sendAt: string; + let cancelUndoSend: ReturnType; + + beforeEach(() => { + closeSelf.mockClear(); + originalEmail = useEmailStore.getState(); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf, setRestore: vi.fn() } } as never; + useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never); + identityMocks.multi = { enabled: false, allIdentities: [], groups: [] }; + const client = { getEmail: vi.fn(async (id: string) => (id === 'e1' ? restoredDraft : startDraft)), getMailboxes: vi.fn(async () => MAILBOXES) }; + useAuthStore.setState({ client: client as never, getClientForAccount: (id: string) => (id === 'alice' ? client : undefined) } as never); + cancelUndoSend = vi.fn(async () => restoredDraft); + useEmailStore.setState({ + sendEmail: vi.fn(async () => { + useEmailStore.setState({ pendingUndoSend: { submissionId: 'sub1', emailId: 'e1', identityId: 'id-1', sendAt, isSmime: false } }); + return { scheduled: true, emailSubmissionId: 'sub1', emailId: 'e1', sendAt }; + }), + cancelUndoSend, + } as never); + }); + + afterEach(() => { + useEmailStore.setState(originalEmail); + window.bulwarkDesktop = undefined; + }); + + type Sending = { onSend: (data: object) => Promise; onClose: () => void }; + const DATA = { to: ['a@example.org'], cc: [], bcc: [], subject: 'Hi', body: 'Text', localAccountId: 'alice' }; + + /** Opens the window, sends from it and lets the composer close, as it does after a send. */ + async function sendFromWindow(seconds: number) { + sendAt = new Date(Date.now() + seconds * 1000).toISOString(); + window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice'); + render(); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending')); + const props = composer as unknown as Sending; + await act(async () => { await props.onSend(DATA); }); + act(() => props.onClose()); + } + + it('stays open with "Sending in" and Undo, and does not hand the undo to the main window', async () => { + await sendFromWindow(5); + expect(screen.getByTestId('popout-sending').textContent).toContain('sending_in'); + expect(screen.getByRole('button', { name: 'undo_send' })).toBeInTheDocument(); + expect(closeSelf).not.toHaveBeenCalled(); + const sent = heard.find((message) => message.type === 'sent') as Extract | undefined; + expect(sent?.notice.pendingUndoSend ?? null).toBeNull(); + }); + + it('closes once the undo time is over', async () => { + await sendFromWindow(1.2); + expect(closeSelf).not.toHaveBeenCalled(); + await waitFor(() => expect(closeSelf).toHaveBeenCalled(), { timeout: 4000 }); + }); + + it('Undo takes the send back and opens the message again in this window', async () => { + await sendFromWindow(30); + fireEvent.click(screen.getByRole('button', { name: 'undo_send' })); + await waitFor(() => expect(cancelUndoSend).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ submissionId: 'sub1', localAccountId: 'alice' }))); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Back again')); + expect(closeSelf).not.toHaveBeenCalled(); + expect(new URLSearchParams(window.location.search).get('draft')).toBe('e1'); + }); + + 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(); + 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 4b9f058a8..e29b8810d 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer"; import { ErrorBoundary, ComposerErrorFallback } from "@/components/error"; @@ -13,8 +13,9 @@ import { toast } from "@/stores/toast-store"; import { formatRejectedRecipients } from "@/lib/jmap/client"; import { debug } from "@/lib/debug"; import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; -import { announceSent } from "@/lib/plugin-after-send"; -import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type RescuedDraft } from "@/lib/popout/channel"; +import { announceSent, cancelAnnouncedSend, releaseAnnouncedSend } from "@/lib/plugin-after-send"; +import { cancelFailureToast } from "@/lib/send-failure-message"; +import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type PopoutSentNotice, type RescuedDraft } from "@/lib/popout/channel"; import { acknowledgeComposeHandoff, forgetComposePayload, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; import { returnComposerToMain, type ReturnResult } from "@/lib/popout/return-to-main"; @@ -30,6 +31,7 @@ import { emailHooks } from "@/lib/plugin-hooks"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { useIdentityStore } from "@/stores/identity-store"; import type { Email } from "@/lib/jmap/types"; +import { Send } from "@/components/icons"; import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell"; /** A warning with no main window left to show it stays this long before the window goes. */ @@ -49,6 +51,91 @@ const MAIN_CHECK_MS = 1000; /** How long a reloaded window waits to hear what became of the text it handed over on the way out. */ const RESCUE_ANSWER_MS = 2000; +/** A send held this long or less is an undo window ("Sending in 5 s"), not a message scheduled for later. */ +const UNDO_WINDOW_MS = 120_000; +/** Undo goes this long before the server lets the message go, as in the main window. */ +const UNDO_MARGIN_MS = 1000; +/** What the plugins are told when the hold runs out gets this long before the window goes. */ +const RELEASE_WAIT_MS = 1000; + +type HeldSend = NonNullable; + +/** A draft to open again in this window, after Undo brought the message back to Drafts. */ +interface ReopenTarget { + draftId: string; + accountId: string; + ownerAccountId?: string; +} + +/** + * 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. + * The window stays until the hold runs out, then goes. + */ +function SendingWithUndo({ held, onUndone, onGone }: { + held: HeldSend; + /** The send was taken back; the message, back in Drafts, when there is one to open. */ + onUndone: (restored: Email | null) => void; + onGone: () => void; +}) { + const tViewer = useTranslations("email_viewer"); + const [now, setNow] = useState(() => Date.now()); + const [busy, setBusy] = useState(false); + const sendAt = Date.parse(held.sendAt); + const over = !Number.isFinite(sendAt) || now >= sendAt - UNDO_MARGIN_MS; + + useEffect(() => { + if (over) return; + const timer = setInterval(() => setNow(Date.now()), 250); + return () => clearInterval(timer); + }, [over]); + + // Too late to undo: the plugins hear of the send now, and the window goes. + useEffect(() => { + if (!over || busy) return; + releaseAnnouncedSend(held.submissionId); + const timer = setTimeout(onGone, RELEASE_WAIT_MS); + return () => clearTimeout(timer); + }, [over, busy, held.submissionId, onGone]); + + const undo = async () => { + const auth = useAuthStore.getState(); + // Never another login: the submission id names another submission there. + const client = held.localAccountId ? auth.getClientForAccount(held.localAccountId) : auth.client; + if (!client) { + toast.error(tViewer("undo_send_signed_out")); + return; + } + setBusy(true); + let restored: Email | null; + try { + restored = await useEmailStore.getState().cancelUndoSend(client, held); + } catch (error) { + debug.error("Failed to undo the send:", error); + const { title, message } = cancelFailureToast(error, tViewer, tViewer("undo_send_failed")); + toast.error(title, message ? { message } : undefined); + setBusy(false); + return; + } + // Never left the server: plugins don't hear about it. + cancelAnnouncedSend(held.submissionId); + onUndone(held.isSmime ? null : restored); + }; + + const seconds = Math.max(0, Math.ceil((sendAt - now) / 1000)); + return ( +
+
+ ); +} + function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } { if (typeof window === "undefined") return { handoffId: null }; const search = new URLSearchParams(window.location.search); @@ -214,7 +301,12 @@ function NewMessageGate({ onReady }: { onReady: (payload: ComposePopoutPayload) } /** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */ -function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; handoffId: string | null }) { +function PopoutComposer({ payload, handoffId, onReopen }: { + payload: ComposePopoutPayload; + handoffId: string | null; + /** Undo brought the message back to Drafts: open it again here. */ + onReopen: (target: ReopenTarget) => void; +}) { const t = useTranslations(); const tQuick = useTranslations("desktopQuickCompose"); const client = useAuthStore((s) => s.client); @@ -267,6 +359,16 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; const accountId = data.localAccountId ?? useAuthStore.getState().activeAccountId; const pending = useEmailStore.getState().pendingUndoSend; + const withLogin = pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null; + // An undo window stays here, where Send was pressed: the window shows it + // and closes when it runs out. A message scheduled for later goes to the + // main window as before. + const remaining = withLogin ? Date.parse(withLogin.sendAt) - Date.now() : NaN; + const keepHere = result.scheduled && withLogin && Number.isFinite(remaining) && remaining <= UNDO_WINDOW_MS ? withLogin : null; + if (keepHere) { + heldRef.current = keepHere; + useEmailStore.setState({ pendingUndoSend: null }); + } const rejectedRecipients = result.rejectedRecipients?.length ? formatRejectedRecipients(result.rejectedRecipients) : undefined; const taken = await announceSentToMainWindow({ accountId, @@ -274,7 +376,7 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; queued: !!result.queued, filingError: !!result.filingError, rejectedRecipients, - pendingUndoSend: pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null, + pendingUndoSend: keepHere ? null : withLogin, }); // No main window to say so, and this is the only notice these get (#1123). if (!taken && (result.filingError || rejectedRecipients)) { @@ -301,11 +403,36 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; } }, [client, t]); + // A send held for undo, kept in this window until it runs out. + const heldRef = useRef(null); + const [held, setHeld] = useState(null); + const handleClose = useCallback(() => { + announceMailChanged(useAuthStore.getState().activeAccountId); + if (heldRef.current) { + setHeld(heldRef.current); + return; + } + closePopoutWindow(); + }, []); + + const heldGone = useCallback(() => { announceMailChanged(useAuthStore.getState().activeAccountId); closePopoutWindow(); }, []); + const heldUndone = useCallback((restored: Email | null) => { + const sent = heldRef.current; + heldRef.current = null; + const login = sent?.localAccountId ?? useAuthStore.getState().activeAccountId; + announceMailChanged(login); + if (!restored || !login) { + closePopoutWindow(); + return; + } + onReopen({ draftId: restored.id, accountId: login, ownerAccountId: sent?.submissionAccountId }); + }, [onReopen]); + const [mainWindow] = useState(watchMainWindow); useEffect(() => () => mainWindow.stop(), [mainWindow]); @@ -373,6 +500,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; }, }), [handoffId, mainWindow, payload]); + if (held) return ; + return ( (named && draftId && !rememberedComposePayload(named) ? null : named)); const [payload, setPayload] = useState(null); + // After Undo: the message back in Drafts, opened again in a new composer here. + const [reopen, setReopen] = useState(null); + const [generation, setGeneration] = useState(0); + const handleReopen = useCallback((target: ReopenTarget) => { + // A reload starts from the draft, as after a save. + const url = new URL(window.location.href); + url.searchParams.delete("handoff"); + url.searchParams.set("draft", target.draftId); + url.searchParams.set("account", target.accountId); + if (target.ownerAccountId) url.searchParams.set("owner", target.ownerAccountId); + else url.searchParams.delete("owner"); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); + setPayload(null); + setReopen(target); + }, []); + const reopened = useCallback((next: ComposePopoutPayload) => { + setReopen(null); + setGeneration((n) => n + 1); + setPayload(next); + }, []); useEffect(() => { if (!handoffId) return; @@ -438,7 +587,8 @@ export function PopoutComposeApp() { return ( - {payload ? + {payload ? + : reopen ? : handoffId ? : draftId ? : } From 1b13b4ce88b8ccd0a949b6f5a36d805169ee3056 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:12:24 +0100 Subject: [PATCH 6/6] feat: show Offline in the composer and keep the message when Send can't go out --- .../__tests__/composer-offline-send.test.tsx | 289 ++++++++++++++++++ .../__tests__/composer-own-window.test.tsx | 21 +- components/email/email-composer.tsx | 67 +++- hooks/use-browser-online.ts | 21 ++ locales/ar/common.json | 7 +- locales/ca/common.json | 7 +- locales/cs/common.json | 7 +- locales/da/common.json | 7 +- locales/de/common.json | 7 +- locales/en/common.json | 7 +- locales/es/common.json | 7 +- locales/et/common.json | 7 +- locales/fa/common.json | 7 +- locales/fr/common.json | 7 +- locales/he/common.json | 7 +- locales/hu/common.json | 7 +- locales/it/common.json | 7 +- locales/ja/common.json | 7 +- locales/ko/common.json | 7 +- locales/lv/common.json | 7 +- locales/mn/common.json | 7 +- locales/nb/common.json | 7 +- locales/nl/common.json | 7 +- locales/pl/common.json | 7 +- locales/pt/common.json | 7 +- locales/ro/common.json | 7 +- locales/ru/common.json | 7 +- locales/sk/common.json | 7 +- locales/th/common.json | 7 +- locales/tr/common.json | 7 +- locales/uk/common.json | 7 +- locales/vi/common.json | 7 +- locales/zh-TW/common.json | 7 +- locales/zh/common.json | 7 +- 34 files changed, 572 insertions(+), 36 deletions(-) create mode 100644 components/email/__tests__/composer-offline-send.test.tsx create mode 100644 hooks/use-browser-online.ts diff --git a/components/email/__tests__/composer-offline-send.test.tsx b/components/email/__tests__/composer-offline-send.test.tsx new file mode 100644 index 000000000..5c7b5f425 --- /dev/null +++ b/components/email/__tests__/composer-offline-send.test.tsx @@ -0,0 +1,289 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// Writing offline. The footer says "Offline" next to Send. With "Keep mail on +// this device" on, Send puts the message in the outbox as before. Without it +// nothing can keep the message safely for later: Send says so, keeps the +// text, and points at the setting. It used to fail with "Failed to send". + +const offline = vi.hoisted(() => ({ active: false, allowed: true })); +vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({ + ...(await importOriginal()), + offlineActive: () => offline.active, + offlineAllowed: () => offline.allowed, +})); + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ collapseToolbarOnMobile }: { collapseToolbarOnMobile?: boolean }) => + React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-collapsed': String(!!collapseToolbarOnMobile) }), +})); + +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 }), +})); + +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 }; +}); + +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: () => {}, + }; + 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 }; +}); + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {}, warning: () => {} }, +})); + +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 })); + +const DATA = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Lunch', + body: '

Hello there

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +let online = true; +beforeEach(() => { + online = true; + offline.active = false; + offline.allowed = true; + Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online }); +}); +afterEach(() => { + online = true; +}); + +function goOffline() { + online = false; + act(() => { window.dispatchEvent(new Event('offline')); }); +} +function goOnline() { + online = true; + act(() => { window.dispatchEvent(new Event('online')); }); +} + +const footer = () => document.querySelector('[data-composer-footer]')!; + +describe('the composer while offline', () => { + it('says "Offline" next to Send, and stops once the network is back', () => { + render(); + expect(screen.queryByTestId('composer-offline')).toBeNull(); + goOffline(); + const label = screen.getByTestId('composer-offline'); + expect(footer()).toContainElement(label); + expect(label.textContent).toContain('offline'); + expect(label.textContent).toContain('offline_hint'); + goOnline(); + expect(screen.queryByTestId('composer-offline')).toBeNull(); + }); + + it('without offline mode, Send keeps the message and says why, with the way to the setting', async () => { + const onSend = vi.fn(); + render(); + goOffline(); + fireEvent.click(screen.getByTestId('composer-send')); + const notice = await screen.findByTestId('composer-offline-notice'); + expect(notice.textContent).toContain('offline_send_unsaved'); + const link = screen.getByRole('link', { name: 'settings.keep_label' }); + expect(link.getAttribute('href')).toMatch(/\/settings\/backup\/offline$/); + expect(link).toHaveAttribute('target', '_blank'); + expect(onSend).not.toHaveBeenCalled(); + expect(screen.getByTestId('email-composer')).toBeInTheDocument(); + + goOnline(); + expect(screen.queryByTestId('composer-offline-notice')).toBeNull(); + }); + + it('says the message is in Drafts only when the server has it', async () => { + render(); + goOffline(); + fireEvent.click(screen.getByTestId('composer-send')); + expect((await screen.findByTestId('composer-offline-notice')).textContent).toContain('offline_send_saved'); + }); + + it('Ctrl+Enter takes the same way', async () => { + const onSend = vi.fn(); + render(); + goOffline(); + fireEvent.keyDown(screen.getByTestId('composer-send'), { key: 'Enter', ctrlKey: true }); + await screen.findByTestId('composer-offline-notice'); + expect(onSend).not.toHaveBeenCalled(); + }); + + it('leaves out the link where this device may not keep mail', async () => { + offline.allowed = false; + render(); + goOffline(); + fireEvent.click(screen.getByTestId('composer-send')); + await screen.findByTestId('composer-offline-notice'); + expect(screen.queryByRole('link', { name: 'settings.keep_label' })).toBeNull(); + }); + + it('with offline mode on, Send goes ahead: the outbox keeps it until the network is back', async () => { + offline.active = true; + const onSend = vi.fn(); + render(); + goOffline(); + expect(screen.getByTestId('composer-offline').textContent).toContain('offline_hint_outbox'); + fireEvent.click(screen.getByTestId('composer-send')); + await waitFor(() => expect(onSend).toHaveBeenCalled()); + expect(screen.queryByTestId('composer-offline-notice')).toBeNull(); + }); +}); diff --git a/components/email/__tests__/composer-own-window.test.tsx b/components/email/__tests__/composer-own-window.test.tsx index 199eee835..ab200fb56 100644 --- a/components/email/__tests__/composer-own-window.test.tsx +++ b/components/email/__tests__/composer-own-window.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextIntlClientProvider } from 'next-intl'; import { EmailComposer, type ComposerDraftData, type ComposerPopOut } from '../email-composer'; @@ -364,3 +364,22 @@ describe('a composer that is all its window shows', () => { expect(creations).toHaveLength(saved); }); }); + +describe('back online', () => { + it('saves what could not be saved while offline, so "saved in Drafts once you are back online" holds', async () => { + let onLine = true; + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine); + rejectSave = true; + mount({}); + onLine = false; + act(() => { window.dispatchEvent(new Event('offline')); }); + fireEvent.change(subjectField(), { target: { value: 'Written on a train' } }); + await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 }); + + rejectSave = false; + onLine = true; + act(() => { window.dispatchEvent(new Event('online')); }); + await waitFor(() => expect(creations).toHaveLength(2)); + expect(creations[1].subject).toBe('Written on a train'); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index dbbe94385..aee9fbe6b 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -41,6 +41,7 @@ import { Reply, Forward, BookUser, + CloudOff, } from "@/components/icons"; import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu"; import { ComposerToolbarMenuRadioItem } from "@/components/email/composer-toolbar-menu"; @@ -95,6 +96,9 @@ import type { DirectoryEntry } from "@/stores/contact-store"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { isStandInId } from "@/lib/jmap/stand-in-ids"; import { noteDraftReplacedElsewhere } from "@/lib/offline/queue"; +import { offlineActive, offlineAllowed } from "@/lib/offline/offline-mode"; +import { useBrowserOnline } from "@/hooks/use-browser-online"; +import { appPath, buildSettingsPath } from "@/lib/deep-links"; import type { FileNode } from "@/lib/jmap/types"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store"; @@ -4626,6 +4630,35 @@ export function EmailComposer({ }; }); + // Offline. With "Keep mail on this device" on, a send waits in the outbox + // and goes out once the server answers again. Without it there is nowhere + // safe to keep a message for later: Send says so and the text stays here. + const tComposeWindows = useTranslations('compose_windows'); + const browserOnline = useBrowserOnline(); + const [offlineSendNotice, setOfflineSendNotice] = useState<'saved' | 'unsaved' | null>(null); + const sendOrSayOffline = () => { + if (typeof navigator !== 'undefined' && navigator.onLine === false && !offlineActive(composingAccountId ?? null)) { + const saved = !!draftIdRef.current && changeVersionRef.current === savedVersionRef.current && !saveFailedRef.current; + setOfflineSendNotice(saved ? 'saved' : 'unsaved'); + announceStatus(tComposeWindows(saved ? 'offline_send_saved' : 'offline_send_unsaved')); + return; + } + handleSend(); + }; + // Back online: the notice goes, and what couldn't be saved meanwhile is saved now. + const wasOnlineRef = useRef(browserOnline); + useEffect(() => { + const cameBack = browserOnline && !wasOnlineRef.current; + wasOnlineRef.current = browserOnline; + if (!cameBack) return; + setOfflineSendNotice(null); + if (saveCloseAttemptRef.current || sentRef.current || isSendingRef.current || !isDirtyRef.current) return; + if (changeVersionRef.current === savedVersionRef.current && !saveFailedRef.current) return; + void saveDraftRef.current({ auto: true }); + }, [browserOnline]); + // Only asked while offline: it reads the organisation's policy and the store's support. + const offlineHint = browserOnline ? '' : tComposeWindows(offlineActive(composingAccountId ?? null) ? 'offline_hint_outbox' : 'offline_hint'); + const handleComposerKeyDown = (e: React.KeyboardEvent) => { if (e.defaultPrevented) return; // Keys from a dialog the composer opened (Insert link, ...) reach here @@ -4694,7 +4727,7 @@ export function EmailComposer({ } e.preventDefault(); - handleSend(); + sendOrSayOffline(); return; } @@ -4864,7 +4897,7 @@ export function EmailComposer({ {phoneLayout && (composerClient?.hasDelayedSend() || hasSendMenuActions ? (
) : (