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")}