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/14] 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 b17f36d7bae481f821e07810f37ae78487e0bc96 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:26:58 +0100 Subject: [PATCH 02/14] fix: show the tray's next event in the app's time format --- lib/desktop/__tests__/os-window.test.tsx | 9 +++++++++ lib/desktop/os-summary.ts | 11 +++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index 84c346fcb..caac76876 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -290,6 +290,15 @@ describe('the summary', () => { expect(nextEventOf([event('past', '2026-11-03T09:00:00Z')], now)).toBeNull(); }); + it('the event\'s time follows the app\'s time format, not the language\'s', async () => { + const { formatEventTime } = await import('../os-summary'); + const at = new Date(2026, 10, 3, 11, 0).getTime(); + const morning = new Date(2026, 10, 3, 8, 0).getTime(); + expect(formatEventTime(at, morning, 'en-US', '24h')).toBe('11:00'); + expect(formatEventTime(at, morning, 'en-US', '12h')).toMatch(/^11:00\sAM$/); + expect(formatEventTime(new Date(2026, 10, 3, 9, 5).getTime(), morning, 'en-US', '24h')).toBe('09:05'); + }); + describe('with a login', () => { const row = { id: 'e1', threadId: 't1', subject: 'Lunch?', from: [{ name: 'Alice', email: 'a@example.org' }] }; const getEmails = vi.fn(async () => ({ emails: [row], hasMore: false, total: 1 })); diff --git a/lib/desktop/os-summary.ts b/lib/desktop/os-summary.ts index 9476a1d84..1f351ec84 100644 --- a/lib/desktop/os-summary.ts +++ b/lib/desktop/os-summary.ts @@ -16,6 +16,7 @@ import { useAuthStore } from '@/stores/auth-store'; import { useCalendarStore } from '@/stores/calendar-store'; import { useEmailStore } from '@/stores/email-store'; import { usePolicyStore } from '@/stores/policy-store'; +import { useSettingsStore, type TimeFormat } from '@/stores/settings-store'; const ROWS = 5; const REFRESH_DELAY_MS = 2000; @@ -36,12 +37,14 @@ export function nextEventOf(events: readonly CalendarEvent[], now: number): Cale return ahead[0] ?? null; } -export function formatEventTime(start: number, now: number, locale: string | undefined): string { +/** In the app's own time format (Settings > General), not the language's: 11:00, never 11:00 AM where the app shows 24 hours. */ +export function formatEventTime(start: number, now: number, locale: string | undefined, timeFormat: TimeFormat = '24h'): string { const date = new Date(start); const sameDay = date.toDateString() === new Date(now).toDateString(); + const hour12 = timeFormat === '12h'; const options: Intl.DateTimeFormatOptions = sameDay - ? { hour: 'numeric', minute: '2-digit' } - : { weekday: 'short', hour: 'numeric', minute: '2-digit' }; + ? { hour: hour12 ? 'numeric' : '2-digit', minute: '2-digit', hour12 } + : { weekday: 'short', hour: hour12 ? 'numeric' : '2-digit', minute: '2-digit', hour12 }; return new Intl.DateTimeFormat(locale, options).format(date); } @@ -79,7 +82,7 @@ export async function readSummary(locale: string | undefined, now = Date.now()): before: new Date(now + EVENT_WINDOW_MS).toISOString(), }).catch(() => [] as CalendarEvent[]); const next = nextEventOf(events, now); - if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale) }; + if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale, useSettingsStore.getState().timeFormat) }; } // The tray lists one account's inbox: with more than one, it says whose. const several = useAccountStore.getState().accounts.length > 1; From 4e662d8a60d2836414384cfc4c1e3314b7b7a66b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:29:34 +0100 Subject: [PATCH 03/14] fix: give "Show the app" a key the composer doesn't use, and refuse the app's own keys for global shortcuts --- components/settings/desktop-os-settings.tsx | 16 ++++++---------- lib/desktop/__tests__/os-window.test.tsx | 11 +++++++++++ lib/desktop/__tests__/shortcut-keys.test.ts | 15 ++++++++++++++- lib/desktop/shortcut-keys.ts | 17 ++++++++++++++++- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/components/settings/desktop-os-settings.tsx b/components/settings/desktop-os-settings.tsx index cd3851cca..9213d62e0 100644 --- a/components/settings/desktop-os-settings.tsx +++ b/components/settings/desktop-os-settings.tsx @@ -5,16 +5,10 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { getDesktopBridge } from "@/lib/desktop/bridge"; import type { DesktopOsDefaultAppStatus, DesktopOsSettings as Settings, DesktopOsStatus } from "@/lib/desktop/types"; +import { describeAccelerator, showShortcutDefault } from "@/lib/desktop/shortcut-keys"; import { DesktopQuickComposeSetting } from "./desktop-quick-compose-setting"; import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; -/** On Windows and Linux this is what a press of the keys shows; macOS maps it to Cmd. */ -const SHOW_SHORTCUT = "CommandOrControl+Shift+B"; - -function shortcutLabel(platform: string | undefined): string { - return platform === "darwin" ? "⇧⌘B" : "Ctrl+Shift+B"; -} - /** * The desktop app's own settings: only where there's an app to set. A browser never sees * them, and they render nothing until the bridge has answered. The General page has how @@ -46,6 +40,8 @@ export function DesktopOsSettings({ part = "app" }: { part?: "app" | "notificati if (!os || !status) return null; const { settings } = status; + const platform = bridge?.platform; + const showKeys = describeAccelerator(settings.showShortcut ?? showShortcutDefault(platform), platform); const change = (patch: Partial) => { void os.setSettings(patch).then(setStatus); }; @@ -103,12 +99,12 @@ export function DesktopOsSettings({ part = "app" }: { part?: "app" | "notificati change({ showShortcut: on ? SHOW_SHORTCUT : null })} + onChange={(on) => change({ showShortcut: on ? showShortcutDefault(platform) : null })} /> {status.quickCompose && ( diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index caac76876..d7f06d8c7 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -431,6 +431,17 @@ describe('the settings section', () => { await waitFor(() => expect(os.openDefaultAppSettings).toHaveBeenCalled()); }); + it('switching "Show the app" on takes a key the composer doesn\'t use', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + const os = bridgeFor(status()); + setBridge(os as never); + render(); + expect(await screen.findByText('show_shortcut_label')).toBeInTheDocument(); + // Close to tray, start with the computer, then this one. + fireEvent.click(screen.getAllByRole('switch')[2]); + await waitFor(() => expect(os.setSettings).toHaveBeenCalledWith({ showShortcut: 'CommandOrControl+Alt+F11' })); + }); + it('says when another app holds the shortcut', async () => { const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); setBridge(bridgeFor(status({ showShortcut: 'CommandOrControl+Shift+B' })) as never); diff --git a/lib/desktop/__tests__/shortcut-keys.test.ts b/lib/desktop/__tests__/shortcut-keys.test.ts index 423225010..678406f51 100644 --- a/lib/desktop/__tests__/shortcut-keys.test.ts +++ b/lib/desktop/__tests__/shortcut-keys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { describeAccelerator, readShortcutPress, shortcutProblem, type ShortcutKeyEvent } from '../shortcut-keys'; +import { describeAccelerator, readShortcutPress, shortcutProblem, showShortcutDefault, type ShortcutKeyEvent } from '../shortcut-keys'; const press = (key: string, code: string, modifiers: Partial = {}): ShortcutKeyEvent => ({ key, @@ -108,4 +108,17 @@ describe('shortcutProblem', () => { expect(shortcutProblem('CommandOrControl+Control+N', 'win32')).toBe('incomplete'); expect(shortcutProblem('CommandOrControl+Control+N', 'darwin')).toBeNull(); }); + + it('refuses the app\'s own keys: a global one would take them from the composer and the menus', () => { + for (const keys of ['CommandOrControl+Shift+B', 'CommandOrControl+Shift+C', 'CommandOrControl+Shift+Enter', 'CommandOrControl+Shift+M', 'CommandOrControl+Shift+N', 'CommandOrControl+Shift+S', 'CommandOrControl+Shift+R', 'CommandOrControl+Shift+F']) { + for (const platform of ['win32', 'darwin', 'linux']) expect(shortcutProblem(keys, platform), `${keys} on ${platform}`).toBe('reserved'); + } + expect(readShortcutPress(press('B', 'KeyB', { ctrlKey: true, shiftKey: true }), 'win32')).toEqual({ refused: 'reserved' }); + }); + + it('"Show the app" by default is none of them, and on Windows types nothing with AltGr', () => { + for (const platform of ['win32', 'darwin', 'linux']) expect(shortcutProblem(showShortcutDefault(platform), platform)).toBeNull(); + expect(showShortcutDefault('win32')).toBe('CommandOrControl+Alt+F11'); + expect(showShortcutDefault('linux')).toBe('CommandOrControl+Alt+B'); + }); }); diff --git a/lib/desktop/shortcut-keys.ts b/lib/desktop/shortcut-keys.ts index 323fa048d..c2a6f4b20 100644 --- a/lib/desktop/shortcut-keys.ts +++ b/lib/desktop/shortcut-keys.ts @@ -10,6 +10,15 @@ */ export const QUICK_COMPOSE_DEFAULT = 'CommandOrControl+Alt+N'; +/** + * What "Show the app with a keyboard shortcut" takes when switched on: Ctrl+Alt+B (⌥⌘B), and + * on Windows Ctrl+Alt+F11, since Ctrl+Alt is AltGr there and B types on many layouts. The + * shell's showShortcutBuiltIn, which also moves anyone still on the old Ctrl+Shift+B to it. + */ +export function showShortcutDefault(platform: string | undefined): string { + return platform === 'win32' ? 'CommandOrControl+Alt+F11' : 'CommandOrControl+Alt+B'; +} + const MAC_SYMBOLS: Record = { control: '⌃', ctrl: '⌃', @@ -67,6 +76,12 @@ export interface ShortcutKeyEvent { * shell's. `cmd` is Cmd on a Mac and Ctrl elsewhere. */ const RESERVED = new Set(['cmd+shift+A', 'cmd+shift+C', 'cmd+shift+V', 'cmd+shift+X', 'cmd+shift+Z']); +/** + * The app's own keys with two modifiers, which a global key would take from Bulwark itself: + * the composer's Cc, Bcc and schedule send, and the menus' new message, new window, Save as + * PDF, reply all and forward. Same list as APP_KEYS in the shell's os-rules.ts. + */ +const APP_KEYS = new Set(['cmd+shift+B', 'cmd+shift+C', 'cmd+shift+ENTER', 'cmd+shift+M', 'cmd+shift+N', 'cmd+shift+S', 'cmd+shift+R', 'cmd+shift+F']); const RESERVED_ON_MAC = new Set([ 'cmd+shift+3', 'cmd+shift+4', 'cmd+shift+5', 'cmd+shift+Q', 'cmd+ctrl+Q', 'cmd+ctrl+F', @@ -119,7 +134,7 @@ export function shortcutProblem(accelerator: string, platform: string | undefine if (modifiers.has('super')) return 'reserved'; if (modifiers.size < 2 || !(modifiers.has('cmd') || modifiers.has('ctrl'))) return 'incomplete'; const named = `${[...modifiers].sort().join('+')}+${key}`; - return RESERVED.has(named) || (mac && RESERVED_ON_MAC.has(named)) ? 'reserved' : null; + return RESERVED.has(named) || APP_KEYS.has(`${[...modifiers].sort().join('+')}+${key.toUpperCase()}`) || (mac && RESERVED_ON_MAC.has(named)) ? 'reserved' : null; } /** 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 04/14] =?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 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 05/14] 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 06/14] 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 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 07/14] 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 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 08/14] 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 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 09/14] 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 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 14/14] 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")}