From 3f4a1365f8e18e6d22a2a4ac0ed290a941bb6012 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:11:36 +0100 Subject: [PATCH 01/34] fix: keep the window's buttons clear of the app's after a maximise, restore, snap or resize --- lib/__tests__/window-controls-host.test.tsx | 26 +++++++++++++++++++++ lib/window-controls-overlay.ts | 17 ++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/lib/__tests__/window-controls-host.test.tsx b/lib/__tests__/window-controls-host.test.tsx index ee7075b60..77599c97e 100644 --- a/lib/__tests__/window-controls-host.test.tsx +++ b/lib/__tests__/window-controls-host.test.tsx @@ -348,6 +348,32 @@ describe('the window controls over the app', () => { expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false); expect(rightInset()).toBe(''); }); + + describe('when the window changes size in one step', () => { + const startWidth = window.innerWidth; + const setWidth = (width: number) => Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true }); + afterEach(() => setWidth(startWidth)); + + // As Chromium does it: geometrychange with the new rect while the page still has + // its old width, then the page takes the new width and fires resize. + const resizeTo = (fake: EventTarget, width: number) => act(() => { + (fake as unknown as { getTitlebarAreaRect: () => object }).getTitlebarAreaRect = () => ({ x: 0, y: 0, width: width - 137, height: 56 }); + fake.dispatchEvent(new Event('geometrychange')); + setWidth(width); + window.dispatchEvent(new Event('resize')); + }); + + it('keeps the room for the controls after a maximise, a restore, a snap and a shrink', () => { + setWidth(1366); + const fake = overlayAt(0, 1366 - 137); + render(); + expect(rightInset()).toBe('137px'); + for (const width of [2048, 1366, 1024, 640, 1920]) { + resizeTo(fake, width); + expect(rightInset(), `at ${width} px`).toBe('137px'); + } + }); + }); }); describe('which rows hold the controls', () => { diff --git a/lib/window-controls-overlay.ts b/lib/window-controls-overlay.ts index d679f023f..100434fba 100644 --- a/lib/window-controls-overlay.ts +++ b/lib/window-controls-overlay.ts @@ -94,9 +94,14 @@ export function applyControlsArea(area: TitlebarArea | null, root: HTMLElement = /** * Calls `listener` now and whenever the overlay's size or visibility changes - * (`geometrychange`). Without the API it calls it once with null and listens - * to nothing, so a browser without it runs no extra code. Returns the - * unsubscribe. + * (`geometrychange`), and again when the page's width does (`resize`). + * Without the API it calls it once with null and listens to nothing, so a + * browser without it runs no extra code. Returns the unsubscribe. + * + * `geometrychange` comes before the page has its new width: after a maximise, + * restore, snap or drag the rect is the new one and innerWidth still the old, + * so the room worked out from the two is wrong (none at all after growing, more + * than the window after shrinking). The `resize` that follows has both. */ export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void): () => void { listener(readTitlebarArea()); @@ -104,7 +109,11 @@ export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void) if (!current) return () => {}; const onChange = () => listener(readTitlebarArea()); current.addEventListener('geometrychange', onChange); - return () => current.removeEventListener('geometrychange', onChange); + window.addEventListener('resize', onChange); + return () => { + current.removeEventListener('geometrychange', onChange); + window.removeEventListener('resize', onChange); + }; } const ROW_CANDIDATES = '[data-window-top-row], [data-composer-header], header, div.h-14, div.min-h-14, div.h-12, div.h-16, div.min-h-16'; From 79fde240ac2a8e705af50096cdbb78e7a26a92a9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:22:58 +0100 Subject: [PATCH 02/34] 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 03/34] 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) && + + + + + )} + {/* 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 09/34] =?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 c50726a15a00b66f7b3b1c388b02bc9a7a7dd5ea Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:51:45 +0100 Subject: [PATCH 10/34] =?UTF-8?q?fix:=20run=20Message=20>=20Move=20to?= =?UTF-8?q?=E2=80=A6=20on=20every=20selected=20message,=20and=20offer=20no?= =?UTF-8?q?=20Reply=20or=20Forward=20with=20several=20selected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mail-app-menu-selection.test.tsx | 171 ++++++++++++++++++ components/mail/mail-app.tsx | 35 +++- 2 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 components/mail/__tests__/mail-app-menu-selection.test.tsx diff --git a/components/mail/__tests__/mail-app-menu-selection.test.tsx b/components/mail/__tests__/mail-app-menu-selection.test.tsx new file mode 100644 index 000000000..62424ebbf --- /dev/null +++ b/components/mail/__tests__/mail-app-menu-selection.test.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { act, cleanup, render, 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 { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands'; +import { useMoveDialogStore } from '@/components/email/move-to-dialog'; + +// The desktop app's Message menu with several messages selected: each line acts on all +// of them, or isn't offered. Reply to one of seven, or Move for the open one only, acts on +// mail nobody picked (usability pass 2, desktop-p2-3). + +const seen = vi.hoisted(() => ({ sourceEmailId: undefined as string | undefined })); + + +vi.mock('@/components/email/email-composer', async () => { + const { useEffect, useRef, useState } = await import('react'); + const { registerComposeFlush } = await import('@/lib/compose-flush-registry'); + return { + EmailComposer: ({ initialData, onSaveState, onSend, sourceEmailId }: { + initialData?: { subject?: string; mode?: string } | null; + onSaveState: (data: { subject: string; mode?: string }) => void; + onSend: (data: { to: string[]; cc: string[]; bcc: string[]; subject: string; body: string }) => Promise; + sourceEmailId?: string; + }) => { + seen.sourceEmailId = sourceEmailId; + useEffect(() => registerComposeFlush(async () => {}), []); + const [subject, setSubject] = useState(initialData?.subject ?? ''); + const initial = useRef(subject); + const latest = useRef(subject); + latest.current = subject; + useEffect(() => () => { + if (latest.current !== initial.current) onSaveState({ subject: latest.current, mode: initialData?.mode }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + <> + setSubject(e.target.value)} /> + + + ); + }, + }; +}); +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null })); +vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () =>
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: () => {} })); +// A compact desktop: rail and folders beside the list, the composer in the pane. +const device = vi.hoisted(() => ({ current: { isMobile: false, isCompact: true, isDesktop: true } })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => device.current, + useMediaQuery: () => false, + useIsDesktop: () => true, + atLeast: (px: number) => `(width >= ${px}px)`, + PHONE_BELOW: 600, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 3, unreadEmails: 0, myRights: { mayRemoveItems: true, mayAddItems: true } } as unknown as Mailbox; +const later = { id: 'later', name: 'Read later', role: null, totalEmails: 0, unreadEmails: 0, myRights: { mayRemoveItems: true, mayAddItems: true } } as unknown as Mailbox; +const message = (id: string) => ({ id, threadId: `t-${id}`, subject: `Subject ${id}`, receivedAt: '2026-10-31T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } }) as unknown as Email; +const [a, b, c] = ['a', 'b', 'c'].map(message); + +let reported: Record = {}; +let batchMoveToMailbox: ReturnType; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} }))); + reported = {}; + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { + version: 1, + platform: 'win32', + menu: { setState: (state: Record) => { reported = state; }, onCommand: () => () => {} }, + }; + 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, + }; + 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', proInterface: false } as never); + batchMoveToMailbox = vi.fn().mockResolvedValue(true); + useEmailStore.setState({ + emails: [a, b, c], selectedEmail: a, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox, later], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false, + selectedEmailKeys: new Set(['a', 'b', 'c']), unreadSection: null, threadEmailsCache: new Map(), error: 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), + refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined), + batchMoveToMailbox, + } as never); +}); +afterEach(() => { + cleanup(); + resetDesktopCommands(); + useMoveDialogStore.setState({ request: null }); + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('the Message menu with three messages selected', () => { + it('offers no Reply, Reply all, Forward or Star, which would act on one of them', async () => { + render(); + await waitFor(() => expect(reported['message.archive']).toBe(true)); + expect(reported['message.reply']).toBe(false); + expect(reported['message.replyAll']).toBe(false); + expect(reported['message.forward']).toBe(false); + expect(reported['message.star']).toBe(false); + expect(reported['message.openWindow']).toBe(false); + expect(reported['message.move']).toBe(true); + }); + + it('Move to… moves all three, and the toast says how many', async () => { + render(); + await waitFor(() => expect(reported['message.move']).toBe(true)); + act(() => { runDesktopCommand('message.move'); }); + const request = useMoveDialogStore.getState().request; + expect(request?.emails.map((email) => email.id)).toEqual(['a', 'b', 'c']); + await act(async () => { request!.onMove('later'); }); + await waitFor(() => expect(batchMoveToMailbox).toHaveBeenCalledWith(expect.anything(), 'later', undefined, expect.any(Array))); + await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('moved_count_to')); + }); + + it('with one message open and none selected, Reply and Move are for that one', async () => { + useEmailStore.setState({ selectedEmailKeys: new Set() } as never); + render(); + await waitFor(() => expect(reported['message.reply']).toBe(true)); + expect(reported['message.move']).toBe(true); + act(() => { runDesktopCommand('message.move'); }); + expect(useMoveDialogStore.getState().request).toBeNull(); + expect(batchMoveToMailbox).not.toHaveBeenCalled(); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index bdc888cfc..9c3ffb395 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -145,7 +145,7 @@ import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-ident import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload, Star } from "@/components/icons"; import { useRegisterCommands } from "@/lib/command-registry"; -import { MoveToDialog, MoveToDialogHost } from "@/components/email/move-to-dialog"; +import { MoveToDialog, MoveToDialogHost, useMoveDialogStore } from "@/components/email/move-to-dialog"; import { SearchResultCount } from "@/components/email/search-result-count"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; @@ -1366,7 +1366,26 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // The desktop app's menu bar: the same handlers as the keys, and what's enabled // from what's on screen (lib/desktop/commands.ts). const desktopTarget = !showComposer && (!!selectedEmail || selectedEmailKeys.size > 0); - const desktopSingle = !showComposer && !!selectedEmail; + // With several selected, a menu line acts on all of them or isn't offered: Reply to one + // of seven, or Move for the open one only, would act on mail nobody picked. + const desktopMulti = selectedEmailKeys.size > 1; + const desktopSingle = !showComposer && !!selectedEmail && !desktopMulti; + const tListExtra = useTranslations('mail_list_extra'); + /** Message > Move to… on the selection, as the selection bar's Move does: one toast with the count. */ + const moveSelectionTo = async (mailboxId: string) => { + if (!client) return; + const count = selectedEmailKeys.size; + const restore = restoreBatchesForSelection(client); + const receipts: CrossAccountMoveReceipt[] = []; + const destination = mailboxes.find((m) => m.id === mailboxId); + await runBatchEmailAction(() => useEmailStore.getState().batchMoveToMailbox(client, mailboxId, undefined, receipts), { + success: destination + ? tListExtra('moved_count_to', { count, mailbox: getMailboxPath(destination, mailboxes) }) + : tNotifications('emails_moved', { count }), + error: tNotifications('move_failed'), + undo: undoMoveAction(client, restore, undo, receipts), + }); + }; const mailLayoutSetting = useSettingsStore((s) => s.mailLayout); const layoutFixed = interfaceLayout === 'mountain-view'; // A shared folder you may only read from: nothing can leave it. @@ -1384,7 +1403,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { 'message.markRead': () => void keyboardHandlers.onMarkAsRead?.(), 'message.markUnread': () => void keyboardHandlers.onMarkAsUnread?.(), 'message.star': () => void keyboardHandlers.onToggleStar?.(), - 'message.move': () => { if (selectedEmail) setMoveDialogEmail(selectedEmail); }, + 'message.move': () => { + if (shortcutsTargetSelection) { + const state = useEmailStore.getState(); + const picked = resolveSelectedEmails({ emails: state.emails, threadEmailsCache: state.threadEmailsCache, selectedEmailKeys }); + if (picked.length > 0) useMoveDialogStore.getState().open({ emails: picked, onMove: (mailboxId) => void moveSelectionTo(mailboxId) }); + } else if (selectedEmail) { + setMoveDialogEmail(selectedEmail); + } + }, 'message.archive': keyboardHandlers.onArchive ? () => void keyboardHandlers.onArchive?.() : undefined, 'message.spam': () => void keyboardHandlers.onToggleSpam?.(), 'message.delete': () => void keyboardHandlers.onDelete?.(), @@ -1408,7 +1435,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { 'message.markRead': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && selectedEmail.keywords?.$seen), 'message.markUnread': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && !selectedEmail.keywords?.$seen), 'message.star': desktopSingle, - 'message.move': desktopSingle && desktopMayRemove, + 'message.move': desktopTarget && desktopMayRemove, 'message.archive': desktopTarget && archiveActionEnabled && desktopMayRemove, 'message.spam': desktopTarget && desktopMayRemove, 'message.delete': desktopTarget && desktopMayRemove, 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 11/34] 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 12/34] 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 a2edb10e935f88138640a50af27467e443c68e35 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:04:15 +0100 Subject: [PATCH 13/34] docs: list Ctrl+F for Forward in the desktop app's shortcut help --- lib/__tests__/desktop-shortcut-keys.test.ts | 1 + lib/keyboard-shortcuts/mail.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/desktop-shortcut-keys.test.ts b/lib/__tests__/desktop-shortcut-keys.test.ts index ffe0fd0be..672a63395 100644 --- a/lib/__tests__/desktop-shortcut-keys.test.ts +++ b/lib/__tests__/desktop-shortcut-keys.test.ts @@ -15,6 +15,7 @@ describe('the shortcut help in the desktop app', () => { 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.forward'), 'mac')).toBe('Mod + Shift + F / f'); + expect(shortcutKeyFor(row('shortcuts.actions.forward'), 'windows')).toBe('Ctrl + F / Ctrl + 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..f9cdf761f 100644 --- a/lib/keyboard-shortcuts/mail.ts +++ b/lib/keyboard-shortcuts/mail.ts @@ -13,7 +13,8 @@ export const MAIL_SHORTCUTS = { actions: [ { key: "r", description: "shortcuts.actions.reply", desktopKey: "Mod + R" }, { key: "R / a", description: "shortcuts.actions.reply_all", desktopKey: "Mod + Shift + R" }, - { key: "f", description: "shortcuts.actions.forward", desktopKey: "Mod + Shift + F" }, + // Ctrl+F forwards with a message selected (Outlook) and searches elsewhere; Ctrl+Shift+F still works. + { key: "f", description: "shortcuts.actions.forward", desktopKey: "Mod + Shift + F", desktopPcKey: "Ctrl + F / Ctrl + Shift + F" }, { key: "s", description: "shortcuts.actions.star" }, { key: "e", description: "shortcuts.actions.archive", feature: "archiveActionEnabled" }, { key: "# / Del", description: "shortcuts.actions.delete" }, From 5e34dc59abd08c80387c2a9b54847801442553b1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:09:08 +0100 Subject: [PATCH 14/34] fix: name keys in the app's language in menus, hints and the shortcut help (Strg, Umschalt, Entf in German) --- components/layout/window-menu-bar.tsx | 2 +- lib/__tests__/shortcut-keys.test.ts | 19 ++++++++ lib/__tests__/window-controls-host.test.tsx | 8 ++++ lib/desktop/__tests__/shortcut-keys.test.ts | 8 ++++ lib/desktop/menu-bar.ts | 12 ++++-- lib/desktop/shortcut-keys.ts | 7 ++- lib/shortcut-keys.ts | 48 +++++++++++++++++---- 7 files changed, 88 insertions(+), 16 deletions(-) diff --git a/components/layout/window-menu-bar.tsx b/components/layout/window-menu-bar.tsx index 1c39ff3b5..33e8f517e 100644 --- a/components/layout/window-menu-bar.tsx +++ b/components/layout/window-menu-bar.tsx @@ -372,7 +372,7 @@ export function WindowMenuBar() { aria-haspopup={isSub ? "menu" : undefined} aria-expanded={isSub ? opened : undefined} aria-disabled={item.enabled ? undefined : true} - aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform) : undefined} + aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform, 'en') : undefined} data-menu-line={index} tabIndex={-1} onMouseEnter={(event) => { diff --git a/lib/__tests__/shortcut-keys.test.ts b/lib/__tests__/shortcut-keys.test.ts index db7319ac3..eb0f927eb 100644 --- a/lib/__tests__/shortcut-keys.test.ts +++ b/lib/__tests__/shortcut-keys.test.ts @@ -13,6 +13,25 @@ describe('shortcut notation', () => { expect(formatShortcutCombo('Ctrl+Shift+Y', false)).toBe('Ctrl+Shift+Y'); }); + it('names keys the way Windows does in the app\'s language, and a Mac keeps its symbols', () => { + expect(formatShortcutCombo('Mod + Shift + R', false, 'de')).toBe('Strg + Umschalt + R'); + expect(formatShortcutCombo('Shift + Mod + Enter', false, 'de-AT')).toBe('Umschalt + Strg + Eingabe'); + expect(shortcutAlternatives('# / Del', false, 'de')).toEqual(['#', 'Entf']); + expect(formatShortcutCombo('Alt + Shift + N', false, 'fr')).toBe('Alt + Maj + N'); + expect(formatShortcutCombo('Mod + Shift + R', true, 'de')).toBe('⌘ + Shift + R'); + expect(formatShortcutCombo('Mod + Shift + R', false, 'nl')).toBe('Ctrl + Shift + R'); + expect(formatShortcutCombo('Mod + Shift + R', false, 'en')).toBe('Ctrl + Shift + R'); + }); + + it('follows the app\'s language when none is given', () => { + document.documentElement.lang = 'de'; + try { + expect(formatShortcutCombo('Mod + K', false)).toBe('Strg + K'); + } finally { + document.documentElement.lang = ''; + } + }); + it('splits alternatives', () => { expect(shortcutAlternatives('j / ↓', false)).toEqual(['j', '↓']); expect(shortcutAlternatives('Mod + A', true)).toEqual(['⌘ + A']); diff --git a/lib/__tests__/window-controls-host.test.tsx b/lib/__tests__/window-controls-host.test.tsx index 77599c97e..416ccd5ed 100644 --- a/lib/__tests__/window-controls-host.test.tsx +++ b/lib/__tests__/window-controls-host.test.tsx @@ -278,6 +278,14 @@ describe('the menu bar in the desktop app', () => { expect(menuKeyText('?', 'win32')).toBe('?'); expect(menuKeyText('CommandOrControl+Shift+Z', 'win32')).toBe('Ctrl+Shift+Z'); }); + + it('and in the app\'s language, as German Windows writes them', () => { + expect(menuKeyText('CmdOrCtrl+Shift+R', 'win32', 'de')).toBe('Strg+Umschalt+R'); + expect(menuKeyText('Delete', 'win32', 'de')).toBe('Entf'); + expect(menuKeyText('CmdOrCtrl+Enter', 'linux', 'de')).toBe('Strg+Eingabe'); + expect(menuKeyText('Alt+Left', 'win32', 'de')).toBe('Alt+←'); + expect(menuKeyText('Delete', 'win32', 'fr')).toBe('Suppr'); + }); }); describe('the window controls over the app', () => { diff --git a/lib/desktop/__tests__/shortcut-keys.test.ts b/lib/desktop/__tests__/shortcut-keys.test.ts index 678406f51..e3e54d6b4 100644 --- a/lib/desktop/__tests__/shortcut-keys.test.ts +++ b/lib/desktop/__tests__/shortcut-keys.test.ts @@ -27,6 +27,14 @@ describe('describeAccelerator', () => { }); }); +describe('describeAccelerator in the app\'s language', () => { + it('says Strg and Umschalt in German, and keeps a Mac\'s symbols', () => { + expect(describeAccelerator('CommandOrControl+Alt+F11', 'win32', 'de')).toBe('Strg+Alt+F11'); + expect(describeAccelerator('CommandOrControl+Shift+Enter', 'linux', 'de')).toBe('Strg+Umschalt+Eingabe'); + expect(describeAccelerator('CommandOrControl+Shift+B', 'darwin', 'de')).toBe('⇧⌘B'); + }); +}); + describe('readShortcutPress', () => { it('turns a press into what the shell takes', () => { expect(readShortcutPress(press('n', 'KeyN', CTRL_ALT), 'win32')).toEqual({ accelerator: 'CommandOrControl+Alt+N' }); diff --git a/lib/desktop/menu-bar.ts b/lib/desktop/menu-bar.ts index 5ac75b267..effc2633f 100644 --- a/lib/desktop/menu-bar.ts +++ b/lib/desktop/menu-bar.ts @@ -8,6 +8,7 @@ import { useEffect, useState } from 'react'; import { getDesktopBridge } from './bridge'; import { describeAccelerator } from './shortcut-keys'; +import { localKeyName } from '@/lib/shortcut-keys'; import { MENU_BAR_STORAGE_KEY } from './boot'; @@ -66,14 +67,17 @@ const KEY_NAMES: Record = { space: 'Space', }; -/** An Electron accelerator the way a Windows or Linux menu shows it: `Ctrl+Shift+N`, `Alt+←`, `?`. */ -export function menuKeyText(accelerator: string, platform: string | undefined): string { +/** + * An Electron accelerator the way a Windows or Linux menu shows it: `Ctrl+Shift+N`, `Alt+←`, `?`, + * in the app's language (`Strg+Umschalt+N`, `Entf` in German). `language` defaults to the app's. + */ +export function menuKeyText(accelerator: string, platform: string | undefined, language?: string): string { // A key that is itself "+", as in Ctrl++, ends in an empty part. const parts = accelerator.split('+'); const last = parts[parts.length - 1] === '' ? '+' : parts[parts.length - 1]; - const key = KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last); + const key = localKeyName(KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last), language); const modifiers = parts.slice(0, parts[parts.length - 1] === '' ? -2 : -1); if (modifiers.length === 0) return key; - const named = describeAccelerator(`${modifiers.join('+')}+X`, platform); + const named = describeAccelerator(`${modifiers.join('+')}+X`, platform, language); return `${named.slice(0, -1)}${key}`; } diff --git a/lib/desktop/shortcut-keys.ts b/lib/desktop/shortcut-keys.ts index c2a6f4b20..2b7ddba54 100644 --- a/lib/desktop/shortcut-keys.ts +++ b/lib/desktop/shortcut-keys.ts @@ -4,6 +4,8 @@ * so the settings page can say why before the shell silently refuses. */ +import { localKeyName } from '@/lib/shortcut-keys'; + /** * What turning quick compose on gives when the shell doesn't say. On Windows the shell * answers Ctrl+Alt+Enter: Ctrl+Alt+N is AltGr+N, which types a character on many layouts. @@ -50,7 +52,8 @@ const OTHER_NAMES: Record = { /** The order a Mac shows modifiers in. */ const MAC_ORDER = ['⌃', '⌥', '⇧', '⌘']; -export function describeAccelerator(accelerator: string, platform: string | undefined): string { +/** An Electron accelerator as people read it: ⇧⌘B on a Mac, Ctrl+Alt+F11 elsewhere, in the app's language (Strg in German). */ +export function describeAccelerator(accelerator: string, platform: string | undefined, language?: string): string { const parts = accelerator.split('+'); const key = parts.pop() ?? ''; const named = parts.map((part) => part.toLowerCase()); @@ -59,7 +62,7 @@ export function describeAccelerator(accelerator: string, platform: string | unde return [...symbols].sort((a, b) => MAC_ORDER.indexOf(a) - MAC_ORDER.indexOf(b)).join('') + key; } const superKey = platform === 'win32' ? 'Win' : 'Super'; - return [...named.map((part) => OTHER_NAMES[part] ?? superKey), key].join('+'); + return [...named.map((part) => OTHER_NAMES[part] ?? superKey), key].map((part) => localKeyName(part, language)).join('+'); } export interface ShortcutKeyEvent { diff --git a/lib/shortcut-keys.ts b/lib/shortcut-keys.ts index 498365b86..c97f87ccd 100644 --- a/lib/shortcut-keys.ts +++ b/lib/shortcut-keys.ts @@ -12,23 +12,53 @@ export function isApplePlatform(): boolean { const MOD = /^(mod|ctrl\/⌘|ctrl\/cmd|cmd\/ctrl|⌘\/ctrl)$/i; -/** One key or combination ("Mod + Enter") in the notation of this platform. */ -export function formatShortcutCombo(combo: string, apple = isApplePlatform()): string { +/** + * Key names as Windows writes them in each language, on its keyboards and in every app's + * menus: German "Strg+Umschalt+R", "Entf". Languages not here keep the English names, as + * Windows does for them. macOS has symbols, which need no words. + */ +const KEY_NAMES: Record> = { + de: { Ctrl: 'Strg', Shift: 'Umschalt', Del: 'Entf', Delete: 'Entf', Enter: 'Eingabe', Backspace: 'Rücktaste', Home: 'Pos1', End: 'Ende', PageUp: 'Bild↑', PageDown: 'Bild↓', Insert: 'Einfg', Space: 'Leertaste' }, + fr: { Shift: 'Maj', Del: 'Suppr', Delete: 'Suppr', Enter: 'Entrée', Backspace: 'Retour arrière', Home: 'Origine', End: 'Fin', PageUp: 'Pg.Préc', PageDown: 'Pg.Suiv', Insert: 'Inser', Space: 'Espace' }, + es: { Shift: 'Mayús', Del: 'Supr', Delete: 'Supr', Enter: 'Entrar', Backspace: 'Retroceso', Home: 'Inicio', End: 'Fin', PageUp: 'Re Pág', PageDown: 'Av Pág', Space: 'Espacio' }, + it: { Shift: 'Maiusc', Del: 'Canc', Delete: 'Canc', Enter: 'Invio', Space: 'Spazio' }, +}; + +/** The app's language, from (components/providers/intl-provider.tsx keeps it). */ +function pageLanguage(): string { + if (typeof document === 'undefined') return 'en'; + return (document.documentElement.lang || 'en').toLowerCase().split('-')[0]; +} + +/** + * One key's name in a language: "Ctrl" is "Strg" in German. Takes the English names this + * notation and Electron use ("Ctrl", "Shift", "Del", "Delete", "Enter", "PageUp"); anything + * else comes back as it is. `language` defaults to the app's. + */ +export function localKeyName(name: string, language: string = pageLanguage()): string { + const table = KEY_NAMES[language.toLowerCase().split('-')[0]]; + if (!table) return name; + const canonical = Object.keys(table).find((key) => key.toLowerCase() === name.toLowerCase()); + return canonical ? table[canonical] : name; +} + +/** One key or combination ("Mod + Enter") in the notation of this platform and the app's language. */ +export function formatShortcutCombo(combo: string, apple = isApplePlatform(), language: string = pageLanguage()): string { // Keeps the spacing it was written with ("Ctrl+Shift+Y" from a plugin). const separator = /\s\+\s/.test(combo) ? " + " : "+"; return combo .split(/\s*\+\s*/) .map((part) => { - if (MOD.test(part)) return apple ? '⌘' : 'Ctrl'; - if (/^ctrl$/i.test(part)) return apple ? '⌘' : 'Ctrl'; - if (/^(cmd|⌘)$/i.test(part)) return apple ? '⌘' : 'Ctrl'; + if (MOD.test(part)) return apple ? '⌘' : localKeyName('Ctrl', language); + if (/^ctrl$/i.test(part)) return apple ? '⌘' : localKeyName('Ctrl', language); + if (/^(cmd|⌘)$/i.test(part)) return apple ? '⌘' : localKeyName('Ctrl', language); if (/^alt$/i.test(part)) return apple ? '⌥' : 'Alt'; - return part; + return apple ? part : localKeyName(part, language); }) .join(separator); } -/** The alternatives of a shortcut ("j / ↓"), each in the notation of this platform. */ -export function shortcutAlternatives(key: string, apple = isApplePlatform()): string[] { - return key.split(' / ').map((combo) => formatShortcutCombo(combo, apple)); +/** The alternatives of a shortcut ("j / ↓"), each in the notation of this platform and the app's language. */ +export function shortcutAlternatives(key: string, apple = isApplePlatform(), language: string = pageLanguage()): string[] { + return key.split(' / ').map((combo) => formatShortcutCombo(combo, apple, language)); } 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 15/34] 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 16/34] 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 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 17/34] 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 df62335545c7a5e3eef00f8620690ca82e023fa2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:25:33 +0100 Subject: [PATCH 18/34] feat: name the desktop app's windows in the Window menu by folder and account, subject or New message --- components/calendar/calendar-app.tsx | 2 + components/contacts/contacts-app.tsx | 2 + components/files/files-app.tsx | 2 + .../mail-app-menu-selection.test.tsx | 12 ++++++ components/mail/mail-app.tsx | 7 ++++ components/popout/popout-compose-app.tsx | 5 +++ components/popout/popout-message-app.tsx | 3 ++ components/settings/settings-app.tsx | 2 + lib/desktop/__tests__/window-name.test.tsx | 41 +++++++++++++++++++ lib/desktop/types.ts | 4 ++ lib/desktop/window-name.ts | 26 ++++++++++++ 11 files changed, 106 insertions(+) create mode 100644 lib/desktop/__tests__/window-name.test.tsx create mode 100644 lib/desktop/window-name.ts diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 35f12e35f..7ffe209f7 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -114,6 +114,7 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; import { IS_LITE } from "@/lib/lite"; import { findMeetingLink } from "@/lib/event-links"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; @@ -160,6 +161,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const tSidebar = useTranslations("sidebar"); const tOffline = useTranslations("calendar_offline"); useDocumentTitle(tSidebar("calendar")); + useDesktopWindowName(tSidebar("calendar"), { withAccount: true }); const skeletonLabels = useSkeletonLabels(); const tWebcalAction = useTranslations("calendar.webcal_action"); const { demoMode } = useConfig(); diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 3a3b7b004..ea5acafad 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -57,6 +57,7 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { ContactsSkeleton, ContactsSkeletonBody } from "@/components/boot-skeleton/contacts-skeleton"; import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels"; import { useRegisterCommands } from "@/lib/command-registry"; @@ -104,6 +105,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const tCommon = useTranslations("common"); const tShell = useTranslations("shell"); useDocumentTitle(tSidebar("contacts")); + useDesktopWindowName(tSidebar("contacts"), { withAccount: true }); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index a4bdf9456..dc3851cf1 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -42,6 +42,7 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; import { FilesSkeleton, FilesSkeletonBody } from "@/components/boot-skeleton/files-skeleton"; @@ -60,6 +61,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const tSidebar = useTranslations("sidebar"); const tShell = useTranslations("shell"); useDocumentTitle(tSidebar("files")); + useDesktopWindowName(tSidebar("files"), { withAccount: true }); const tDeepLink = useTranslations("deep_link"); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); diff --git a/components/mail/__tests__/mail-app-menu-selection.test.tsx b/components/mail/__tests__/mail-app-menu-selection.test.tsx index 62424ebbf..9234ef35e 100644 --- a/components/mail/__tests__/mail-app-menu-selection.test.tsx +++ b/components/mail/__tests__/mail-app-menu-selection.test.tsx @@ -88,6 +88,7 @@ const message = (id: string) => ({ id, threadId: `t-${id}`, subject: `Subject ${ const [a, b, c] = ['a', 'b', 'c'].map(message); let reported: Record = {}; +let named: string[] = []; let batchMoveToMailbox: ReturnType; beforeEach(() => { @@ -95,10 +96,12 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} }))); reported = {}; + named = []; (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: (state: Record) => { reported = state; }, onCommand: () => () => {} }, + windows: { setMenuName: (name: string) => { named.push(name); } }, }; usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); @@ -169,3 +172,12 @@ describe('the Message menu with three messages selected', () => { expect(batchMoveToMailbox).not.toHaveBeenCalled(); }); }); + +describe('the Window menu name of the main window', () => { + it('is the folder, not the message open in it, so it never reads like the window of that message', async () => { + useEmailStore.setState({ selectedEmailKeys: new Set() } as never); + render(); + await waitFor(() => expect(named.at(-1)).toBe('sidebar.mailboxes.inbox')); + expect(named).not.toContain('Subject a'); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 9c3ffb395..d51bce35e 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -146,6 +146,7 @@ import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-iden import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload, Star } from "@/components/icons"; import { useRegisterCommands } from "@/lib/command-registry"; import { MoveToDialog, MoveToDialogHost, useMoveDialogStore } from "@/components/email/move-to-dialog"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { SearchResultCount } from "@/components/email/search-result-count"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; @@ -1524,6 +1525,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return mailTitleContext({ composer, subject, mailbox: mailboxLine }, { showSubject: subjectInTabTitle }); }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle]); useDocumentTitle(titleContext); + // The Window menu names this window by its folder, never by the open message (desktop-p2-5). + const windowView = useMemo(() => { + const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined; + return mailbox ? localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`)) : t('sidebar.mail'); + }, [selectedMailbox, mailboxes, t]); + useDesktopWindowName(windowView, { withAccount: true }); // When this page is rendered inside the Pro shell as the Mail tab body, // we hoist every "show composer" intent into its own Pro tab and reset diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 5f8f5433b..798e7517b 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer"; import { ErrorBoundary, ComposerErrorFallback } from "@/components/error"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; @@ -313,6 +314,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: { const [sessionKey] = useState(() => `popout-${handoffId ?? Date.now().toString(36)}`); const mode = payload.initialData?.mode ?? payload.mode; + // The Window menu names a composer by its subject, or "New message" before there is one. + const [subject, setSubject] = useState(payload.initialData?.subject ?? ""); + useDesktopWindowName(subject.trim() || t("email_composer.new_message")); useDocumentTitle( mode === "reply" ? t("email_composer.reply") : mode === "replyAll" ? t("email_composer.reply_all") @@ -523,6 +527,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: { ownWindow={ownWindow} popOut={mainHoldsLogin ? backToMain : undefined} popOutToMainLabel={mainHoldsLogin ? tQuick("back_to_main") : undefined} + onSubjectChange={setSubject} className="min-h-0 flex-1" /> diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx index 0c68ce71a..80d81fb16 100644 --- a/components/popout/popout-message-app.tsx +++ b/components/popout/popout-message-app.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { ProEmailView } from "@/components/pro/pro-email-tab-body"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { usePolicyStore } from "@/stores/policy-store"; @@ -105,6 +106,8 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email const [subject, setSubject] = useState(null); const subjectInTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled("tabTitleSubjectEnabled")); useDocumentTitle(subjectInTitle ? subject : null); + // Kept out where the organisation keeps subjects out of titles: the shell then says "Message". + useDesktopWindowName(subjectInTitle ? subject : null); // Folders first: without them Delete has no Trash to move to, Archive has no // button yet, and the toolbar moves under the pointer once they arrive. diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 4e4a21d55..e0d617729 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -95,6 +95,7 @@ import { useAccountStore } from '@/stores/account-store'; import { useMediaQuery, atLeast, PHONE_BELOW } from '@/hooks/use-media-query'; import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments'; import { useDocumentTitle } from '@/hooks/use-document-title'; +import { useDesktopWindowName } from '@/lib/desktop/window-name'; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; import { scrollBehavior } from '@/lib/reduced-motion'; @@ -816,6 +817,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = ? `${activeTabLabel} - ${tSidebar('settings')}` : tSidebar('settings'), ); + useDesktopWindowName(tSidebar('settings'), { withAccount: true }); const handleTabSelect = (tabId: SettingsTabId) => { setActiveTab(tabId); diff --git a/lib/desktop/__tests__/window-name.test.tsx b/lib/desktop/__tests__/window-name.test.tsx new file mode 100644 index 000000000..2dd5a4c51 --- /dev/null +++ b/lib/desktop/__tests__/window-name.test.tsx @@ -0,0 +1,41 @@ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { useDesktopWindowName, windowMenuName } from '../window-name'; +import { useAccountStore } from '@/stores/account-store'; + +// The desktop app's Window menu: each window by what it shows (usability pass 2, desktop-p2-5). + +function Named({ view, withAccount }: { view: string | null; withAccount?: boolean }) { + useDesktopWindowName(view, { withAccount }); + return null; +} + +afterEach(() => { + cleanup(); + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('the Window menu name', () => { + it('is the view and the account for the main window, the view alone otherwise', () => { + expect(windowMenuName('Inbox', 'a@example.org')).toBe('Inbox - a@example.org'); + expect(windowMenuName('Quarterly budget', null)).toBe('Quarterly budget'); + expect(windowMenuName(' ', null)).toBeNull(); + }); + + it('goes to the shell, with the account in view, and again when it changes', () => { + const named: string[] = []; + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', windows: { setMenuName: (name: string) => named.push(name) } }; + useAccountStore.setState({ accounts: [{ id: 'acc-1', email: 'a@example.org' }], activeAccountId: 'acc-1' } as never); + const { rerender } = render(); + expect(named).toEqual(['Inbox - a@example.org']); + rerender(); + expect(named).toEqual(['Inbox - a@example.org', 'Sent - a@example.org']); + rerender(); + expect(named.at(-1)).toBe('A subject'); + }); + + it('is nothing to an older shell, or in a browser', () => { + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', windows: {} }; + expect(() => render()).not.toThrow(); + }); +}); diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 23044b5de..467f918d1 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -17,6 +17,8 @@ export interface DesktopWindowInfo { id: number; type: DesktopWindowType; title: string; + /** What the page calls the window in the Window menu (`setMenuName`), if it said. */ + menuName?: string; focused: boolean; /** False for a main window hidden while pop-outs are open. */ visible?: boolean; @@ -46,6 +48,8 @@ export interface DesktopWindowsBridge { /** What this window's open drafts come back as at the next start: pop-out composer addresses. */ setRestore?: (urls: string[]) => void; setRestoreEnabled?: (enabled: boolean) => void; + /** This window's name in the Window menu: lib/desktop/window-name.ts. */ + setMenuName?: (name: string) => void; /** Signed in, with a session to lend: windows from last time can open. */ sessionReady?: () => void; /** Nobody is signed in any more: a pop-out would find no session. */ diff --git a/lib/desktop/window-name.ts b/lib/desktop/window-name.ts new file mode 100644 index 000000000..413421f90 --- /dev/null +++ b/lib/desktop/window-name.ts @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect } from 'react'; +import { getDesktopBridge } from './bridge'; +import { useAccountStore } from '@/stores/account-store'; + +/** + * What the desktop app's Window menu calls this window (the shell numbers two alike and + * cuts long ones). The main window goes by its view and account ("Inbox - a@example.org"), + * not by the message open in it, so it never reads like a message window; a message window + * by its subject; a composer by its subject or "New message". Nothing in a browser. + */ +export function windowMenuName(view: string | null | undefined, account: string | null | undefined): string | null { + const parts = [view?.trim(), account?.trim()].filter(Boolean); + return parts.length ? parts.join(' - ') : null; +} + +/** Tells the shell this window's Window menu name. `withAccount` adds the account in view, for the main window. */ +export function useDesktopWindowName(view: string | null | undefined, { withAccount = false }: { withAccount?: boolean } = {}): void { + const account = useAccountStore((s) => (withAccount ? s.accounts.find((a) => a.id === s.activeAccountId)?.email ?? null : null)); + const name = windowMenuName(view, account); + useEffect(() => { + if (!name) return; + getDesktopBridge()?.windows?.setMenuName?.(name); + }, [name]); +} 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 19/34] 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 20/34] 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 03ea1ab1aaf7f3e4f0f2ab1eeaa8604709df36dc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:32:54 +0100 Subject: [PATCH 21/34] fix: open files sent from Explorer's Send to and Open with in a compose window, leaving the main window where it is --- .../mail/__tests__/compose-plugin-hooks.test.tsx | 11 +++++++++++ components/mail/mail-app.tsx | 5 ++++- components/popout/popout-compose-app.tsx | 1 + lib/desktop/__tests__/os-commands.test.ts | 6 ++++++ lib/desktop/os-commands.ts | 7 +++++-- lib/launch-handoff.ts | 5 ++++- lib/popout/compose-handoff.ts | 4 +++- lib/popout/compose-payload.ts | 5 +++++ 8 files changed, 39 insertions(+), 5 deletions(-) diff --git a/components/mail/__tests__/compose-plugin-hooks.test.tsx b/components/mail/__tests__/compose-plugin-hooks.test.tsx index c7cb0c74f..8e0c18506 100644 --- a/components/mail/__tests__/compose-plugin-hooks.test.tsx +++ b/components/mail/__tests__/compose-plugin-hooks.test.tsx @@ -211,6 +211,17 @@ describe('a mail link from another program in the desktop app', () => { expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); }); + it('files from Send to or Open with go to a compose window too, attached, and the main window shows no composer', async () => { + const { handOffDraft } = await import('@/lib/launch-handoff'); + render(); + const file = new File(['pdf'], 'report.pdf', { type: 'application/pdf' }); + act(() => { handOffDraft({ files: [file], fromOs: true }); }); + await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1)); + expect(desktopWindows.handOver).toHaveBeenCalledWith(expect.objectContaining({ mode: 'compose', initialFiles: [file] })); + await act(() => new Promise((r) => setTimeout(r, 50))); + expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); + }); + it('a link inside the app still opens here', async () => { render(); act(() => { requestInternalMailto('mailto:bob@example.com?subject=Hi'); }); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index d51bce35e..8fd320a02 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -1717,7 +1717,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // as Outlook and Thunderbird do, and the main window stays where it was. Plugins get // their say first. False when it has to open here instead. const openMailtoInWindow = useCallback(async (pending: DraftRequest): Promise => { - if (!pending.fromOs || pending.initialFiles?.length || !hasDesktopWindows() || !popoutsAvailable()) return false; + if (!pending.fromOs || !hasDesktopWindows() || !popoutsAvailable()) return false; let prefill: ComposePrefill | null = null; if (emailHooks.onBeforeCompose.size > 0) { prefill = await runBeforeCompose(composeOptionsFor('compose', { @@ -1735,6 +1735,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { mode: 'compose', initialData: mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode), prefill: prefill && Object.keys(prefill).length > 0 ? prefill : null, + // Send to and Open with: the files go along (desktop-p2-6). + ...(pending.initialFiles?.length ? { initialFiles: pending.initialFiles } : {}), }); return true; }, [identities, t]); @@ -1857,6 +1859,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { body: handoff.body ?? '', initialFiles: handoff.files, unedited: true, + ...(handoff.fromOs ? { fromOs: true } : {}), }); }; diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 798e7517b..3670f4c83 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -516,6 +516,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: { replyTo={payload.replyTo} initialDraftText={payload.initialDraftText} initialData={payload.initialData} + initialFiles={payload.initialFiles ?? null} prefill={payload.prefill} sessionKey={sessionKey} sourceEmailId={payload.sourceEmailId ?? undefined} diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts index 730477814..cd724ed85 100644 --- a/lib/desktop/__tests__/os-commands.test.ts +++ b/lib/desktop/__tests__/os-commands.test.ts @@ -132,6 +132,12 @@ describe('launches', () => { expect(takeHandoff('compose')?.files.map((file) => file.name)).toEqual(['a.pdf', 'b.png']); }); + it('files sent from another program are marked so, for a compose window of their own, as a mail link', async () => { + const { takeHandoff } = await import('@/lib/launch-handoff'); + await run({ type: 'files', reason: 'attach', failed: [], files: [{ name: 'a.pdf', data: text('a') }] }); + expect(takeHandoff('compose')).toMatchObject({ fromOs: true }); + }); + it('a file opened with the app asks: new message or Files, a new message first', async () => { const { takeHandoff } = await import('@/lib/launch-handoff'); const { listenForFileChoices } = await import('@/lib/file-drop/choice'); diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts index 9df939646..3a8628fba 100644 --- a/lib/desktop/os-commands.ts +++ b/lib/desktop/os-commands.ts @@ -133,10 +133,13 @@ function handleFiles(command: Extract, env: const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name)); const failed = [...command.failed]; if (command.reason === 'attach' && command.ask) { - // Opened with the app ("Open with"): the same choice as dropping it on the window. + // An older shell's "Open with": the same choice as dropping it on the window. requestFileChoice({ files: made, preferred: 'new-message' }); } else if (command.reason === 'attach') { - if (made.length > 0) handOffDraft({ files: made }); + // Send to and Open with: a compose window with the files, as a mail link gets, and the + // main window stays where it is. Signed out, the sign-in has to show. + if (!useAuthStore.getState().isAuthenticated) showWindow(); + if (made.length > 0) handOffDraft({ files: made, fromOs: true }); } else { for (const file of made) { const kind = kindForFile(file); diff --git a/lib/launch-handoff.ts b/lib/launch-handoff.ts index 8f5be39ec..3e49de11d 100644 --- a/lib/launch-handoff.ts +++ b/lib/launch-handoff.ts @@ -42,6 +42,8 @@ export interface Handoff { /** New draft only. */ subject?: string; body?: string; + /** From another program through the desktop app (Send to, Open with): a compose window of its own. */ + fromOs?: boolean; createdAt: number; } @@ -307,13 +309,14 @@ export function handOffFiles(files: File[], kind: HandoffKind): void { } /** Opens a new draft with this text and these files. Trusted: what came from outside was asked about first. */ -export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void { +export function handOffDraft(draft: { subject?: string; body?: string; files?: File[]; fromOs?: boolean }): void { const subject = cleanMailtoSubject(draft.subject ?? ''); const body = cleanMailtoBody(draft.body ?? ''); enqueue({ id: newId(), kind: 'compose', files: draft.files ?? [], + ...(draft.fromOs ? { fromOs: true } : {}), ...(subject ? { subject } : {}), ...(body ? { body } : {}), createdAt: Date.now(), diff --git a/lib/popout/compose-handoff.ts b/lib/popout/compose-handoff.ts index ec0fdf85b..0a4b17335 100644 --- a/lib/popout/compose-handoff.ts +++ b/lib/popout/compose-handoff.ts @@ -109,7 +109,9 @@ export function createComposeHandoff(): ComposeHandoff | null { /** Keeps the draft with this window, so a reload starts from it. */ export function rememberComposePayload(handoffId: string, payload: ComposePopoutPayload): void { try { - sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(payload)); + // A File doesn't go into JSON: once attached, the draft holds it. + const { initialFiles: _files, ...kept } = payload; + sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(kept)); } catch { // Full or unavailable: a reload then starts from the saved draft's first state. } diff --git a/lib/popout/compose-payload.ts b/lib/popout/compose-payload.ts index 714bfc1ea..25057c441 100644 --- a/lib/popout/compose-payload.ts +++ b/lib/popout/compose-payload.ts @@ -20,4 +20,9 @@ export interface ComposePopoutPayload { sourceEmailSource?: EmailWriteSource | null; /** The mailbox a new message was started from; picks the identity (see the composer's prop). */ composeFromAccountEmail?: string; + /** + * Files to attach as it opens: Send to and Open with in the desktop app. They cross to the + * window as they are (BroadcastChannel clones a File) and aren't kept for a reload. + */ + initialFiles?: File[]; } 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 22/34] 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 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 23/34] 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 24/34] 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 25/34] 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 98f814fcedf417bc3b628f27acd518148932b98a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:42:30 +0100 Subject: [PATCH 26/34] fix: open a mail link from another program from the default account with no question, and drop "protocol link" from the account picker --- .../__tests__/compose-plugin-hooks.test.tsx | 24 +++++++++++++++++ components/mail/mail-app.tsx | 26 +++++++++---------- .../protocol/protocol-account-picker.tsx | 2 +- lib/protocol-handlers/session.ts | 11 ++++++++ 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 - 34 files changed, 48 insertions(+), 45 deletions(-) diff --git a/components/mail/__tests__/compose-plugin-hooks.test.tsx b/components/mail/__tests__/compose-plugin-hooks.test.tsx index 8e0c18506..8645e3d3e 100644 --- a/components/mail/__tests__/compose-plugin-hooks.test.tsx +++ b/components/mail/__tests__/compose-plugin-hooks.test.tsx @@ -3,6 +3,7 @@ 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 { useAccountStore } from '@/stores/account-store'; import { useEmailStore } from '@/stores/email-store'; import { useSettingsStore } from '@/stores/settings-store'; import { usePluginStore } from '@/stores/plugin-store'; @@ -222,6 +223,29 @@ describe('a mail link from another program in the desktop app', () => { expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); }); + it('with two accounts it opens from the default one, in its own window, with no question', async () => { + const client = useAuthStore.getState().client; + useAuthStore.setState({ getAllConnectedClients: () => new Map([['login', client], ['second', client]]) as never }); + useAccountStore.setState({ + accounts: [ + { id: 'login', email: 'me@example.com', isConnected: true, isDefault: false }, + { id: 'second', email: 'work@example.com', isConnected: true, isDefault: true }, + ], + defaultAccountId: 'second', + } as never); + try { + render(); + await fromOs(); + await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1)); + expect(desktopWindows.open).toHaveBeenCalledWith('second'); + expect(screen.queryByText('select_account_title')).not.toBeInTheDocument(); + // The main window keeps the account it shows. + expect(useAuthStore.getState().activeAccountId).toBe('login'); + } finally { + useAccountStore.setState({ accounts: [], defaultAccountId: null } as never); + } + }); + it('a link inside the app still opens here', async () => { render(); act(() => { requestInternalMailto('mailto:bob@example.com?subject=Hi'); }); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 8fd320a02..ca0f59b53 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -156,7 +156,7 @@ import { mailTitleContext } from "@/lib/tab-title"; import { usePluginStore } from "@/stores/plugin-store"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { canDownload } from "@/lib/device-policy"; -import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; +import { consumePendingMailto, mailLinkAccountId, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions"; import { setMailLinkOpener } from "@/lib/desktop/mail-links"; import { requestTabReply } from "@/lib/pwa-notify/tab-reply"; @@ -1716,7 +1716,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // A mail link from another program, in the desktop app: a compose window of its own, // as Outlook and Thunderbird do, and the main window stays where it was. Plugins get // their say first. False when it has to open here instead. - const openMailtoInWindow = useCallback(async (pending: DraftRequest): Promise => { + const openMailtoInWindow = useCallback(async (pending: DraftRequest, accountId?: string): Promise => { if (!pending.fromOs || !hasDesktopWindows() || !popoutsAvailable()) return false; let prefill: ComposePrefill | null = null; if (emailHooks.onBeforeCompose.size > 0) { @@ -1729,7 +1729,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // A plugin said no: nothing opens, here or there. if (!prefill) return true; } - const opened = openComposerWindow(useAuthStore.getState().activeAccountId); + // The account the link goes out from, in its own window: the main window keeps the one it shows. + const opened = openComposerWindow(accountId ?? useAuthStore.getState().activeAccountId); if (!opened) return false; void opened.handOver({ mode: 'compose', @@ -1768,6 +1769,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }, [openMailtoInWindow, openMailtoHere]); const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => { + if (await openMailtoInWindow(pending, accountId)) { + setPendingMailtoAccountChoice(null); + return; + } setIsProtocolAccountSwitching(true); try { if (useAuthStore.getState().activeAccountId !== accountId) { @@ -1778,7 +1783,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } finally { setIsProtocolAccountSwitching(false); } - }, [openMailtoDraft, switchAccount]); + }, [openMailtoDraft, openMailtoInWindow, switchAccount]); // onMailtoIntercept: a plugin may stop a mailto: from opening the composer. const mailtoAllowed = useCallback(async (pending: ParsedMailto & { href?: string }): Promise => { @@ -1791,17 +1796,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }); }, []); - // A draft that comes from outside: which account it goes out from. + // A draft that comes from outside goes out from the default account, as in Outlook and + // Thunderbird, with no question: the composer's From changes it (desktop-p2-8). const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise => { - const protocolAccounts = getMailtoProtocolAccounts(); - if (protocolAccounts.length > 1) { - // The question is asked in this window: bring it up. - if (pending.fromOs) getDesktopBridge()?.windows?.focus?.(); - setPendingMailtoAccountChoice(pending); - return; - } - - const accountId = protocolAccounts[0]?.id ?? activeAccountId; + const accountId = mailLinkAccountId(getMailtoProtocolAccounts(), useAccountStore.getState().defaultAccountId, activeAccountId); if (accountId) { await openMailtoForAccount(pending, accountId); return; diff --git a/components/protocol/protocol-account-picker.tsx b/components/protocol/protocol-account-picker.tsx index 9bc672a4a..bb594ce9e 100644 --- a/components/protocol/protocol-account-picker.tsx +++ b/components/protocol/protocol-account-picker.tsx @@ -167,7 +167,7 @@ export function ProtocolAccountPicker({ {t("switching_account")} ) : ( - {t("select_account_note")} + )} ); From 850ebce944465e36b1dca0a435e55e8356391657 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:57:24 +0100 Subject: [PATCH 31/34] fix: open a resumed reply draft under its message --- .../mail/__tests__/mail-app-drafts.test.tsx | 2 ++ components/mail/mail-app.tsx | 16 +++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/components/mail/__tests__/mail-app-drafts.test.tsx b/components/mail/__tests__/mail-app-drafts.test.tsx index 84411113b..980410488 100644 --- a/components/mail/__tests__/mail-app-drafts.test.tsx +++ b/components/mail/__tests__/mail-app-drafts.test.tsx @@ -144,6 +144,8 @@ describe('a message with a reply draft (compose-p2-6)', () => { 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')); + // Under the message it answers, as a new reply is, not over it. + expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).not.toBeNull(); }); it('starts a new reply when the conversation has no reply to this message', async () => { diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 0492dba12..451e78a0c 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -306,6 +306,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const composerHostRef = useRef('pane'); // The compose session docked under its message (dockedReply). const [dockedSession, setDockedSession] = useState(null); + // A reply draft opened again from its message (compose-p2-6): it docks too. + const [resumedReplySession, setResumedReplySession] = useState(null); const showComposerRef = useRef(showComposer); showComposerRef.current = showComposer; // Fields a plugin filled in through onBeforeCompose for the composer about @@ -2873,7 +2875,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } : null; } if (!found) return false; - await handleEditDraft(found.draft, found.source); + await handleEditDraft(found.draft, found.source, { underMessage: true }); return true; }; @@ -2912,7 +2914,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { * undone or cancelled - the account it was sent from (a group or shared one * for mail sent from its identity) and the login that sent it. */ - const handleEditDraft = async (email?: Email, source?: DraftSource) => { + // `underMessage`: a reply draft resumed from the message it answers docks + // under that message, as a new reply does (compose-p2-6). + const handleEditDraft = async (email?: Email, source?: DraftSource, { underMessage = false }: { underMessage?: boolean } = {}) => { if (!client) return; const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string' ? email @@ -2961,7 +2965,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Increment session ID to force the composer to remount with fresh state, // even if it was already open (e.g. right-clicking a draft while composing). - setComposerSessionId(id => id + 1); + const nextSession = composerSessionRef.current + 1; + setComposerSessionId(nextSession); + setResumedReplySession(underMessage ? nextSession : null); setPendingDraft(composerDataFromDraft(draft, { accountId, localAccountId, @@ -5053,7 +5059,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Decided as it opens and kept for the session: moving it would mount it // anew when the message goes away (another folder opened meanwhile). const dockCandidate = !mvShell && !isMobile && showComposer && !isEmbedded && !floatingComposer - && (pendingDraft?.mode ?? composerMode) !== 'compose'; + && ((pendingDraft?.mode ?? composerMode) !== 'compose' || resumedReplySession === composerSessionId); const dockOnOpen = dockCandidate && Boolean(selectedEmail) && !selectedEmail?.keywords?.$draft; if (dockOnOpen && dockedSession !== composerSessionId) setDockedSession(composerSessionId); const dockedReply = dockCandidate && (dockOnOpen || dockedSession === composerSessionId); @@ -6355,7 +6361,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { replyComposerOpen={dockedReply} replyDraft={replyDraft && selectedEmail && replyDraft.emailId === selectedEmail.id ? { preview: replyDraft.draft.preview, - onEdit: () => void handleEditDraft(replyDraft.draft, replyDraft.source).catch(reportDraftOpenFailure), + onEdit: () => void handleEditDraft(replyDraft.draft, replyDraft.source, { underMessage: true }).catch(reportDraftOpenFailure), onDiscard: discardReplyDraft, } : null} onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined} From f05af5a5906e32eb152015b3b7a8450da53eab2e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:57:25 +0100 Subject: [PATCH 32/34] fix: keep toasts above a reply draft's card --- components/email/__tests__/viewer-reply-draft.test.tsx | 6 ++++++ components/email/email-viewer.tsx | 7 ++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/components/email/__tests__/viewer-reply-draft.test.tsx b/components/email/__tests__/viewer-reply-draft.test.tsx index 52fba0c2e..b3dd05f39 100644 --- a/components/email/__tests__/viewer-reply-draft.test.tsx +++ b/components/email/__tests__/viewer-reply-draft.test.tsx @@ -68,6 +68,12 @@ describe('a reply draft under its message', () => { expect(onDiscard).toHaveBeenCalledTimes(1); }); + // compose-p2-17: toasts lift over it as over the quick reply box. + it('counts as the quick reply box for the toasts', async () => { + await renderViewer({ preview: 'my answer', onEdit: vi.fn(), onDiscard: vi.fn() }); + expect(screen.getByTestId('reply-draft')).toHaveAttribute('data-quick-reply-box'); + }); + it('keeps the quick reply box when there is none', async () => { await renderViewer(null); expect(screen.queryByTestId('reply-draft')).toBeNull(); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 655ea042b..91e56c5da 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3527,8 +3527,9 @@ export function EmailViewer({ // 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(() => { + // The reply draft's card takes the box's place, and its ref. const box = quickReplyBoxRef.current; - if (!showQuickReply || !box || typeof ResizeObserver === 'undefined') return; + if (!(showQuickReply || showReplyDraft) || !box || typeof ResizeObserver === 'undefined') return; const root = document.documentElement; const update = () => root.style.setProperty('--quick-reply-height', `${box.offsetHeight}px`); update(); @@ -3538,7 +3539,7 @@ export function EmailViewer({ observer.disconnect(); root.style.removeProperty('--quick-reply-height'); }; - }, [showQuickReply]); + }, [showQuickReply, showReplyDraft]); // 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. @@ -6215,7 +6216,7 @@ ${printWatermark ? printWatermark.html : ''} {/* The reply already started on this message, as Gmail shows it. */} {showReplyDraft && replyDraft && ( -
+
From 6a2bedc442868d4b0fe6dd419074e09884276fcb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:10:26 +0100 Subject: [PATCH 33/34] fix: name Ctrl+N and Ctrl+K in the desktop app's welcome tips instead of single keys --- components/ui/__tests__/welcome-banner.test.tsx | 13 +++++++++++++ components/ui/welcome-banner.tsx | 7 ++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/components/ui/__tests__/welcome-banner.test.tsx b/components/ui/__tests__/welcome-banner.test.tsx index 2ff6b50fb..3af02db83 100644 --- a/components/ui/__tests__/welcome-banner.test.tsx +++ b/components/ui/__tests__/welcome-banner.test.tsx @@ -27,6 +27,19 @@ describe('WelcomeBanner tips', () => { expect(screen.getByText('tip_shortcuts')).toBeInTheDocument(); }); + it('in the desktop app, names the keys it answers: Ctrl+N and Ctrl+K, not c', () => { + device.isMobile = false; + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' }; + try { + render(); + expect(screen.getByText('tip_compose_keys')).toBeInTheDocument(); + expect(screen.getByText('tip_palette_keys')).toBeInTheDocument(); + expect(screen.queryByText('tip_compose')).toBeNull(); + } finally { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + } + }); + it('gives touch tips on a phone', () => { device.isMobile = true; render(); diff --git a/components/ui/welcome-banner.tsx b/components/ui/welcome-banner.tsx index 1b2a9b5cb..3c135ce11 100644 --- a/components/ui/welcome-banner.tsx +++ b/components/ui/welcome-banner.tsx @@ -9,6 +9,8 @@ import { useRouter } from "@/i18n/navigation"; import { useTour } from "@/components/tour/tour-provider"; import { useSettingsStore } from "@/stores/settings-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; +import { isDesktopApp } from "@/lib/desktop/bridge"; +import { formatShortcutCombo } from "@/lib/shortcut-keys"; const ONBOARDING_KEY = "onboarding_completed"; @@ -38,6 +40,7 @@ export function useWelcomeBannerDue(): boolean { */ export function WelcomeBanner({ variant = "list" }: { variant?: "list" | "pane" }) { const t = useTranslations("welcome"); + const tDesktop = useTranslations("uxp2_desktop"); const router = useRouter(); const { startTour } = useTour(); const onboardingCompleted = useSettingsStore((s) => s.onboardingCompleted); @@ -113,7 +116,9 @@ export function WelcomeBanner({ variant = "list" }: { variant?: "list" | "pane" {t("title")}
    -
  • {isMobile ? t("tip_compose_touch") : t("tip_compose")}
  • + {/* The desktop app answers the keys Windows mail apps use, not single letters (desktop-p2-12). */} +
  • {isMobile ? t("tip_compose_touch") : isDesktopApp() ? tDesktop("tip_compose_keys", { keys: formatShortcutCombo("Mod + N") }) : t("tip_compose")}
  • + {!isMobile && isDesktopApp() &&
  • {tDesktop("tip_palette_keys", { keys: formatShortcutCombo("Mod + K") })}
  • } {!isMobile &&
  • {t("tip_shortcuts")}
  • }
  • {isMobile ? t("tip_sidebar_touch") : t("tip_sidebar")}
  • {t("tip_settings")}
  • From 6ec867b4e08e11e438d819a6dd4159909acad086 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:16:44 +0100 Subject: [PATCH 34/34] fix: count a pop-out's undo send from the delay the user picked --- .../popout/__tests__/popout-compose-app.test.tsx | 10 ++++++++++ components/popout/popout-compose-app.tsx | 11 +++++++---- lib/outgoing/send-progress-toast.ts | 15 +++++++++++---- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 737e95948..f1bfe6f70 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -7,6 +7,7 @@ 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 { useSettingsStore } from '@/stores/settings-store'; import { emailHooks } from '@/lib/plugin-hooks'; // The composer's window: when it tells the opening window to let go, and what @@ -614,6 +615,15 @@ describe('a send held for undo', () => { expect(sent?.notice.releasesAt).toBe(sendAt); }); + // The server's time runs past the delay: "Sending in 6 seconds" for 5 s. + it('never counts from more than the delay the user picked', async () => { + const before = useSettingsStore.getState().sendDelaySeconds; + useSettingsStore.setState({ sendDelaySeconds: 5 } as never); + await sendFromWindow(5.8); + expect(screen.getByTestId('popout-sending')).toHaveAttribute('data-seconds', '5'); + useSettingsStore.setState({ sendDelaySeconds: before } as never); + }); + // 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; diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index c8145e2d2..36e65f7c6 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -10,6 +10,8 @@ import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { usePluginStore } from "@/stores/plugin-store"; import { toast } from "@/stores/toast-store"; +import { useSettingsStore } from "@/stores/settings-store"; +import { secondsUntilSend } from "@/lib/outgoing/send-progress-toast"; import { formatRejectedRecipients } from "@/lib/jmap/client"; import { debug } from "@/lib/debug"; import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; @@ -68,9 +70,9 @@ interface ReopenTarget { } /** "Sending…" or "Sent", where the composer was, with the send icon. */ -function SendStatus({ text, children }: { text: string; children?: React.ReactNode }) { +function SendStatus({ text, seconds, children }: { text: string; seconds?: number; children?: React.ReactNode }) { return ( -
    +