feat: give the desktop app a native menu bar and native right-click menus built from the webmail's own

This commit is contained in:
Linus Rath
2026-11-03 10:44:03 +01:00
parent b2931ced3e
commit d9c9fc03f7
16 changed files with 1248 additions and 65 deletions
+2
View File
@@ -37,6 +37,7 @@ import { FreeBusyAutoShare } from "@/components/calendar/free-busy-auto-share";
import { MainWindowOnly } from "@/components/popout/main-window-only";
import { PopoutHost } from "@/components/popout/popout-host";
import { WindowSyncHost } from "@/components/popout/window-sync-host";
import { DesktopHost } from "@/components/desktop/desktop-host";
import { IS_LITE } from "@/lib/lite";
import { setRequestLocale } from "next-intl/server";
import { locales } from "@/i18n/routing";
@@ -89,6 +90,7 @@ export default async function LocaleLayout({
{!IS_LITE && <PopoutHost />}
</MainWindowOnly>
{!IS_LITE && <WindowSyncHost />}
{!IS_LITE && <DesktopHost />}
<SessionTimeoutGuard />
<AccountTemplateSync />
{children}
+5
View File
@@ -587,6 +587,11 @@ optgroup {
-webkit-app-region: drag;
}
.window-title-bar-item {
app-region: no-drag;
-webkit-app-region: no-drag;
}
/* Safe area insets for notched devices (iPhone X+, etc.) */
.safe-area-inset-top {
padding-top: env(safe-area-inset-top);
@@ -0,0 +1,151 @@
import { act, cleanup, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
import { registerDesktopSurface, resetDesktopCommands } from '@/lib/desktop/commands';
import { resetDesktopPrefsCache, setDesktopPref } from '@/lib/desktop/preferences';
import { useAuthStore } from '@/stores/auth-store';
import { useLocaleStore } from '@/stores/locale-store';
import { useOfflineStore } from '@/lib/offline/state';
const push = vi.fn();
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) }));
const canPrint = vi.fn(() => true);
const canDownload = vi.fn(() => true);
vi.mock('@/lib/device-policy', () => ({ canPrintAnywhere: () => canPrint(), canDownloadAnywhere: () => canDownload() }));
const { DesktopHost } = await import('../desktop-host');
function fakeBridge() {
let command: ((id: string) => void) | null = null;
let flush: (() => Promise<unknown>) | null = null;
const parts = {
setState: vi.fn(),
setLanguages: vi.fn(),
sessionReady: vi.fn(),
setRestoreEnabled: vi.fn(),
setTheme: vi.fn(),
};
const bridge = {
version: 1,
platform: 'win32',
menu: { setState: parts.setState, onCommand: (listener: (id: string) => void) => { command = listener; return () => {}; } },
shell: { openExternal: () => {}, setLanguages: parts.setLanguages },
windows: { sessionReady: parts.sessionReady, setRestoreEnabled: parts.setRestoreEnabled },
theme: { set: parts.setTheme, system: async () => ({ dark: false, highContrast: false }) },
lifecycle: { onFlushRequest: (handler: () => Promise<unknown>) => { flush = handler; return () => {}; } },
};
window.bulwarkDesktop = bridge as unknown as BulwarkDesktopBridge;
return { ...parts, run: (id: string) => act(() => command!(id)), flush: () => flush!() };
}
const original = useAuthStore.getState();
beforeEach(() => {
push.mockClear();
canPrint.mockReturnValue(true);
canDownload.mockReturnValue(true);
localStorage.clear();
resetDesktopPrefsCache();
useAuthStore.setState({ isAuthenticated: true, getAllConnectedClients: () => new Map() } as never);
});
afterEach(() => {
cleanup();
window.bulwarkDesktop = undefined;
resetDesktopCommands();
useAuthStore.setState(original);
window.history.replaceState(null, '', '/');
});
describe('DesktopHost', () => {
it('does nothing in a browser', () => {
expect(() => render(<DesktopHost />)).not.toThrow();
});
it('runs a command from what\'s on the page first', () => {
const shell = fakeBridge();
render(<DesktopHost />);
const onPage = vi.fn();
registerDesktopSurface({ handlers: { 'go.inbox': onPage }, state: {} });
shell.run('go.inbox');
expect(onPage).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled();
});
it('with nothing there, the commands that work anywhere still do', () => {
const shell = fakeBridge();
render(<DesktopHost />);
shell.run('go.calendar');
expect(push).toHaveBeenLastCalledWith('/calendar');
shell.run('go.contacts');
expect(push).toHaveBeenLastCalledWith('/contacts');
});
it('a link to a folder goes through the router, encoded', () => {
const shell = fakeBridge();
render(<DesktopHost />);
shell.run('link.folder:sent');
expect(push).toHaveBeenLastCalledWith('/mail/folder/sent');
});
it('an unknown command does nothing', () => {
const shell = fakeBridge();
render(<DesktopHost />);
expect(() => shell.run('nonsense')).not.toThrow();
expect(push).not.toHaveBeenCalled();
});
it('tells the shell what the organisation allows', async () => {
canPrint.mockReturnValue(false);
canDownload.mockReturnValue(false);
const shell = fakeBridge();
render(<DesktopHost />);
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'file.print': false, 'file.savePdf': false, 'policy.download': false });
});
it('signed in: it says so once, for windows from last time and waiting links', async () => {
const shell = fakeBridge();
render(<DesktopHost />);
await waitFor(() => expect(shell.sessionReady).toHaveBeenCalledTimes(1));
});
it('not signed in, or in a pop-out with a borrowed session: it doesn\'t', async () => {
const shell = fakeBridge();
useAuthStore.setState({ isAuthenticated: false } as never);
const first = render(<DesktopHost />);
await act(async () => {});
expect(shell.sessionReady).not.toHaveBeenCalled();
first.unmount();
useAuthStore.setState({ isAuthenticated: true } as never);
window.history.replaceState(null, '', '/popout/compose');
render(<DesktopHost />);
await act(async () => {});
expect(shell.sessionReady).not.toHaveBeenCalled();
});
it('passes the restore setting on', async () => {
const shell = fakeBridge();
render(<DesktopHost />);
await waitFor(() => expect(shell.setRestoreEnabled).toHaveBeenLastCalledWith(true));
act(() => setDesktopPref('restoreWindows', false));
await waitFor(() => expect(shell.setRestoreEnabled).toHaveBeenLastCalledWith(false));
});
it('says which languages are in use', async () => {
const shell = fakeBridge();
act(() => useLocaleStore.setState({ locale: 'de' }));
render(<DesktopHost />);
await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled());
expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'de' });
});
it('a quit is told how much mail is waiting', async () => {
const shell = fakeBridge();
render(<DesktopHost />);
act(() => useOfflineStore.setState({ outbox: [{ id: 1 }, { id: 2 }] } as never));
await expect(shell.flush()).resolves.toEqual({ unsent: 2 });
act(() => useOfflineStore.setState({ outbox: [] } as never));
});
});
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "@/i18n/navigation";
import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { runDesktopCommand, setDesktopBaseState } from "@/lib/desktop/commands";
import { useDesktopPrefs } from "@/lib/desktop/preferences";
import { applySystemAccent } from "@/lib/desktop/system-accent";
import type { DesktopSystemTheme } from "@/lib/desktop/types";
import { canDownloadAnywhere, canPrintAnywhere } from "@/lib/device-policy";
import { useOfflineStore } from "@/lib/offline/state";
import { openNewComposerWindow } from "@/lib/popout/open";
import { isPopoutWindow } from "@/lib/popout/window-role";
import { useAuthStore } from "@/stores/auth-store";
import { useLocaleStore } from "@/stores/locale-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useThemeStore } from "@/stores/theme-store";
/**
* The webmail's side of what every desktop window needs: runs menu commands nothing
* else on the page took, tells the shell the language and the device policy, and
* says how much mail is waiting when the app quits. Renders nothing and does nothing
* in a browser, where there's no bridge.
*/
export function DesktopHost() {
const router = useRouter();
const routerRef = useRef(router);
routerRef.current = router;
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts);
const writingLanguage = useSettingsStore((s) => s.writingLanguage);
const uiLocale = useLocaleStore((s) => s.locale);
const policyLoaded = usePolicyStore((s) => s.loaded);
const accounts = useAuthStore((s) => s.getAllConnectedClients().size);
const { restoreWindows } = useDesktopPrefs();
const theme = useThemeStore((s) => s.theme);
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const activeThemeId = useThemeStore((s) => s.activeThemeId);
// Commands no surface on the page answers: those work from anywhere in the app.
useEffect(() => {
const menu = getDesktopBridge()?.menu;
if (!menu?.onCommand) return;
const toMail = (command: AppCommand) => {
if (requestAppCommand(command)) return;
parkAppCommand(command);
routerRef.current.push("/");
};
const fallback: Record<string, () => void> = {
"file.newMessage": () => {
if (isPopoutWindow()) openNewComposerWindow(useAuthStore.getState().activeAccountId);
else toMail("compose");
},
"edit.search": () => toMail("search"),
"go.inbox": () => routerRef.current.push("/"),
"go.mail": () => routerRef.current.push("/"),
"go.calendar": () => routerRef.current.push("/calendar"),
"go.contacts": () => routerRef.current.push("/contacts"),
"go.files": () => routerRef.current.push("/files"),
"help.shortcuts": () => openKeyboardShortcuts(),
};
return menu.onCommand((id) => {
// A bulwark:// link to a folder.
if (id.startsWith("link.folder:")) {
routerRef.current.push(`/mail/folder/${encodeURIComponent(id.slice("link.folder:".length))}`);
return;
}
if (!runDesktopCommand(id)) fallback[id]?.();
});
}, []);
// What's on offer anywhere, and what the organisation allows.
useEffect(() => {
if (!getDesktopBridge()?.menu) return;
const canPrint = canPrintAnywhere();
const signedIn = isAuthenticated;
const popout = isPopoutWindow();
setDesktopBaseState({
"shortcuts.singleKeys": singleKeys,
"policy.download": canDownloadAnywhere(),
"file.print": canPrint,
"file.savePdf": canPrint,
"file.newMessage": signedIn,
"edit.search": signedIn && !popout,
"go.inbox": signedIn,
"go.mail": signedIn,
"go.calendar": signedIn,
"go.contacts": signedIn,
"go.files": signedIn,
"help.shortcuts": true,
});
}, [isAuthenticated, singleKeys, policyLoaded, accounts]);
// Menus and spellcheck follow the interface, and the composer's language too.
useEffect(() => {
const shell = getDesktopBridge()?.shell;
if (!shell?.setLanguages) return;
const ui = uiLocale || document.documentElement.lang;
if (!ui) return;
const composer = writingLanguage && writingLanguage !== "auto" ? [writingLanguage] : [];
shell.setLanguages({ ui, composer });
}, [uiLocale, writingLanguage]);
// The windows' behaviour lives here, the shell only needs its copy.
useEffect(() => {
getDesktopBridge()?.windows?.setRestoreEnabled?.(restoreWindows);
}, [restoreWindows]);
// Signed in: this window has a session to lend, so pop-outs from last time can open.
useEffect(() => {
if (!isAuthenticated || isPopoutWindow()) return;
getDesktopBridge()?.windows?.sessionReady?.();
}, [isAuthenticated]);
// A quit asks for this: mail that hasn't left is worth a question.
useEffect(() => {
const lifecycle = getDesktopBridge()?.lifecycle;
if (!lifecycle || isPopoutWindow()) return;
return lifecycle.onFlushRequest(async () => ({ unsent: useOfflineStore.getState().outbox.length }));
}, []);
// The OS accent colour, while the theme follows the OS.
useEffect(() => {
const bridge = getDesktopBridge()?.theme;
if (!bridge) return;
const followsSystem = theme === "system" && !activeThemeId;
const apply = (system: DesktopSystemTheme) => {
applySystemAccent({
accent: system.accent,
highContrast: system.highContrast,
dark: document.documentElement.classList.contains("dark"),
followsSystem,
});
};
let cancelled = false;
void bridge.system?.().then((system) => {
if (!cancelled) apply(system);
});
const stop = bridge.onSystemChange?.(apply);
return () => {
cancelled = true;
stop?.();
};
}, [theme, resolvedTheme, activeThemeId]);
return null;
}
@@ -1,5 +1,5 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, it, expect, vi } from 'vitest';
import { ReaderAttachments, splitFileName, type ReaderAttachmentItem } from '../reader-attachments';
// The reader's attachment bar: equal tiles that keep the extension in view,
@@ -24,6 +24,47 @@ function renderBar(attachments: ReaderAttachmentItem[], overrides: Partial<Param
return props;
}
describe('the right-click menu in the desktop app', () => {
afterEach(() => {
window.bulwarkDesktop = undefined;
cleanup();
});
const openMenu = async (popupAnswer: (items: { id?: string; label?: string }[]) => string | null, overrides: Partial<Parameters<typeof ReaderAttachments>[0]> = {}) => {
const popup = vi.fn(async (items: { id?: string; label?: string }[]) => popupAnswer(items));
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as never;
const props = renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn(), ...overrides });
fireEvent.contextMenu(await screen.findByTestId('attachment'), { clientX: 5, clientY: 5 });
await waitFor(() => expect(popup).toHaveBeenCalled());
return { props, popup };
};
it('has Open, Save as and Copy, natively', async () => {
const { popup } = await openMenu(() => null);
expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open', 'save_as', 'copy']);
});
it.each([['open', 'onOpen'], ['save_as', 'onDownload'], ['copy', 'onCopyFile']] as const)('%s runs the action', async (label, handler) => {
const { props } = await openMenu((items) => items.find((item) => item.label === label)!.id!);
await waitFor(() => expect((props as unknown as Record<string, ReturnType<typeof vi.fn>>)[handler]).toHaveBeenCalledTimes(1));
expect((props as unknown as Record<string, ReturnType<typeof vi.fn>>)[handler].mock.calls[0][0]).toMatchObject({ id: '1' });
});
it('with downloads off there\'s nothing to save or copy', async () => {
const { popup } = await openMenu(() => null, { downloadsOff: true, opensPreview: () => true });
expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open']);
});
it('a browser keeps its own menu', async () => {
window.bulwarkDesktop = undefined;
renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn() });
const event = fireEvent.contextMenu(await screen.findByTestId('attachment'));
// Not prevented: the browser's menu shows, and no HTML one opens.
expect(event).toBe(true);
expect(screen.queryByRole('menu')).toBeNull();
});
});
describe('splitFileName', () => {
it('splits off a short extension', () => {
expect(splitFileName('prototype-v3.png')).toEqual(['prototype-v3', '.png']);
+10
View File
@@ -159,6 +159,7 @@ import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
import { openPreviewInNewWindow } from "@/lib/standalone-display";
import { downloadAllowed, saveBlobAs } from "@/lib/download-guard";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { canPickFolder, createUniqueNamer, isPickerCancelled, pickFolder, saveFilesToFolder, type FolderHandle } from "@/lib/attachments/save-to-folder";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { Watermark, useWatermarkText } from "@/components/device/watermark";
@@ -3109,6 +3110,8 @@ export function EmailViewer({
// What the attachment bar and the attachment cards share.
const attachmentCardsRef = useRef<HTMLDivElement>(null);
const desktopClipboard = getDesktopBridge()?.clipboard;
const tDesktopUi = useTranslations('desktopUi.attachment');
const readerAttachmentProps = {
attachments: effectiveAttachments,
thumbUrls: imageThumbUrls,
@@ -3117,6 +3120,13 @@ export function EmailViewer({
onDownload: handleEffectiveAttachmentDownload,
onDownloadAll: handleDownloadAllAttachments,
onSaveAllToFolder: canPickFolder() ? () => void handleSaveAllToFolder() : undefined,
onCopyFile: desktopClipboard && blobClient ? (attachment: EffectiveAttachment) => {
if (!attachment.blobId || !downloadAllowed('attachment', email)) return;
void blobClient.fetchBlobArrayBuffer(attachment.blobId, undefined, undefined, blobAccountId)
.then((data) => desktopClipboard.copyFile(resolveAttachmentName(attachment), data))
.then((copied) => { if (!copied) toast.error(tDesktopUi('copy_failed')); })
.catch(() => toast.error(tDesktopUi('copy_failed')));
} : undefined,
downloadingAll: isDownloadingAll,
downloadsOff,
// Save to Files would get around the policy: Files downloads are off too, but the copy would stay.
+29 -2
View File
@@ -1,9 +1,12 @@
"use client";
import { useState, type DragEvent, type ReactNode } from "react";
import { useEffect, useState, type DragEvent, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronUp, Download, FolderInput, Loader2, Paperclip } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { nativeMenusAvailable } from "@/lib/desktop/native-menu";
import { IconButton } from "@/components/ui/icon-button";
import { getAttachmentDisplayName } from "@/lib/attachment-name";
import { cn, formatFileSize } from "@/lib/utils";
@@ -47,6 +50,11 @@ interface ReaderAttachmentsProps<T extends ReaderAttachmentItem> {
layout?: ReaderAttachmentLayout;
/** Object URLs of image attachments, keyed by attachment id. */
thumbUrls: Record<string, string>;
/**
* Copies the file to the clipboard. Comes with the right-click menu the desktop app
* has on an attachment; a browser has its own menu there and never gets this one.
*/
onCopyFile?: (attachment: T) => void;
/** Whether a click on the attachment previews it instead of downloading it. */
opensPreview: (attachment: T) => boolean;
onOpen: (attachment: T) => void;
@@ -94,6 +102,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
onDownload,
onDownloadAll,
onSaveAllToFolder,
onCopyFile,
downloadingAll,
downloadsOff = false,
onSaveToFiles,
@@ -105,6 +114,11 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
const tFiles = useTranslations("files");
const tDevice = useTranslations("device_policy");
const tWindows = useTranslations("pwaWindows");
const tDesktop = useTranslations("desktopUi.attachment");
const menu = useContextMenu<T>();
const [desktopMenu, setDesktopMenu] = useState(false);
useEffect(() => setDesktopMenu(nativeMenusAvailable()), []);
const menuTarget = menu.contextMenu.data;
// Opens nothing: no preview, and downloads are off.
const locked = (attachment: T) => downloadsOff && !opensPreview(attachment);
const openLabel = (attachment: T, name: string) =>
@@ -292,7 +306,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
layout === "cards" ? "grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-3" : "grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2",
)}>
{shown.map((attachment) => (
<div key={attachment.id} className="flex min-w-0 flex-col gap-1">
<div key={attachment.id} className="flex min-w-0 flex-col gap-1" onContextMenu={desktopMenu ? (event) => menu.openContextMenu(event, attachment) : undefined}>
{renderItem
? renderItem(attachment, (drag) => draw(attachment, drag))
: draw(attachment, NO_DRAG)}
@@ -324,6 +338,19 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
</button>
)}
</div>
{desktopMenu && menuTarget && (
<ContextMenu ref={menu.menuRef} isOpen={menu.contextMenu.isOpen} position={menu.contextMenu.position} onClose={menu.closeContextMenu}>
{!locked(menuTarget) && (
<ContextMenuItem label={tDesktop("open")} onClick={() => { menu.closeContextMenu(); onOpen(menuTarget); }} />
)}
{!downloadsOff && (
<ContextMenuItem label={tDesktop("save_as")} onClick={() => { menu.closeContextMenu(); onDownload(menuTarget); }} />
)}
{!downloadsOff && onCopyFile && (
<ContextMenuItem label={tDesktop("copy")} onClick={() => { menu.closeContextMenu(); onCopyFile(menuTarget); }} />
)}
</ContextMenu>
)}
</section>
);
}
+85 -1
View File
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopMenuBarEntry } from "@/lib/desktop/types";
import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/window-controls-overlay";
/**
@@ -12,10 +14,46 @@ import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/
* Renders nothing, and changes nothing on the page, without an overlay. It
* repeats the title for sighted users only, so it stays out of the
* accessibility tree and out of the tab order.
*
* In the desktop app on Windows, Alt swaps the title for the menu's names, since a
* window without a native frame has no native bar. Each name opens the app's menu
* as a native one (src/main/menu.ts in the desktop repo).
*/
export function WindowTitleBar() {
const [area, setArea] = useState<TitlebarArea | null>(null);
const [title, setTitle] = useState("");
const [entries, setEntries] = useState<DesktopMenuBarEntry[] | null>(null);
const stripRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
const openMenu = useCallback((id: string) => {
const button = stripRef.current?.querySelector<HTMLElement>(`[data-menu-id="${CSS.escape(id)}"]`);
if (!button) return;
const rect = button.getBoundingClientRect();
getDesktopBridge()?.menu?.bar?.open(id, { x: rect.left, y: rect.bottom });
}, []);
useEffect(() => {
const bar = getDesktopBridge()?.menu?.bar;
if (!bar) return;
return bar.onEvent((event) => {
if (!event.visible) {
setEntries(null);
previousFocus.current?.focus?.();
previousFocus.current = null;
return;
}
void bar.entries().then((list) => {
previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null;
setEntries(list);
// Once the names are on screen.
window.setTimeout(() => {
if (event.open) openMenu(event.open);
else stripRef.current?.querySelector<HTMLElement>("button")?.focus();
}, 0);
});
});
}, [openMenu]);
useEffect(() => watchTitlebarArea((next) => {
applyTitlebarInset(next);
@@ -35,6 +73,52 @@ export function WindowTitleBar() {
if (!area) return null;
const onStripKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
const buttons = Array.from(stripRef.current?.querySelectorAll<HTMLElement>("button") ?? []);
const at = buttons.indexOf(document.activeElement as HTMLElement);
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
event.preventDefault();
const delta = event.key === "ArrowRight" ? 1 : -1;
buttons[(at + delta + buttons.length) % buttons.length]?.focus();
} else if (event.key === "ArrowDown") {
event.preventDefault();
buttons[at]?.click();
} else if (event.key === "Escape") {
event.preventDefault();
setEntries(null);
previousFocus.current?.focus?.();
previousFocus.current = null;
}
};
if (entries) {
return (
<div
ref={stripRef}
role="menubar"
data-window-title-bar=""
data-window-menu-bar=""
onKeyDown={onStripKey}
className="window-title-bar fixed z-[100002] flex items-center gap-0.5 bg-background ps-1 pe-3 text-xs text-foreground select-none"
style={{ top: area.y, left: area.x, width: area.width, height: area.height }}
>
{entries.map((entry) => (
<button
key={entry.id}
type="button"
role="menuitem"
aria-haspopup="menu"
data-menu-id={entry.id}
onClick={() => openMenu(entry.id)}
className="window-title-bar-item rounded px-2 py-1 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
>
{entry.label}
</button>
))}
</div>
);
}
return (
<div
aria-hidden="true"
+73 -50
View File
@@ -62,6 +62,10 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePaneSize } from "@/hooks/use-pane-size";
import { usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
import { useDesktopCommands } from "@/lib/desktop/commands";
import { useDesktopPrefs } from "@/lib/desktop/preferences";
import { hasDesktopWindows } from "@/lib/popout/support";
import { composerDataFromDraft } from "@/lib/draft-composer-data";
import { useUndoShortcut } from "@/hooks/use-undo-shortcut";
import { confirmMailRemoval } from "@/lib/mail-removal-confirm";
import { findNextUnreadIndex } from "@/lib/next-unread-email";
@@ -1215,6 +1219,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Mountain View keeps the ticked rows while a message is read (#1231). The
// shortcuts then act on the message on screen, as its toolbar does, and
// reach the selection again back in the list.
const openEmailInWindow = useOpenEmailInWindow();
const shortcutsTargetSelection = selectedEmailKeys.size > 0 && !(mvShell && !cursorOnly && selectedEmail);
const singleKeyShortcuts = useSettingsStore((s) => s.singleKeyShortcuts);
useKeyboardShortcuts({
@@ -1258,6 +1263,59 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
}, isAuthenticated && !!client);
// 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;
const mailLayoutSetting = useSettingsStore((s) => s.mailLayout);
const layoutFixed = interfaceLayout === 'mountain-view';
useDesktopCommands({
'file.newMessage': keyboardHandlers.onCompose,
'edit.search': keyboardHandlers.onFocusSearch,
'view.refresh': () => void handleManualRefresh(),
'view.layout.split': () => useSettingsStore.getState().updateSetting('mailLayout', 'split'),
'view.layout.horizontal': () => useSettingsStore.getState().updateSetting('mailLayout', 'horizontal'),
'view.layout.focus': () => useSettingsStore.getState().updateSetting('mailLayout', 'focus'),
'message.reply': keyboardHandlers.onReply,
'message.replyAll': keyboardHandlers.onReplyAll,
'message.forward': keyboardHandlers.onForward,
'message.markRead': () => void keyboardHandlers.onMarkAsRead?.(),
'message.markUnread': () => void keyboardHandlers.onMarkAsUnread?.(),
'message.star': () => void keyboardHandlers.onToggleStar?.(),
'message.move': () => { if (selectedEmail) setMoveDialogEmail(selectedEmail); },
'message.archive': keyboardHandlers.onArchive ? () => void keyboardHandlers.onArchive?.() : undefined,
'message.spam': () => void keyboardHandlers.onToggleSpam?.(),
'message.delete': () => void keyboardHandlers.onDelete?.(),
'message.openWindow': () => { if (selectedEmail) openEmailInWindow?.(selectedEmail); },
'go.inbox': () => keyboardHandlers.onGoToMailbox('inbox'),
'go.sent': () => keyboardHandlers.onGoToMailbox('sent'),
'go.drafts': () => keyboardHandlers.onGoToMailbox('drafts'),
'go.nextMessage': keyboardHandlers.onNextEmail,
'go.previousMessage': keyboardHandlers.onPreviousEmail,
'go.nextUnread': keyboardHandlers.onNextUnread,
}, {
'view.refresh': true,
'view.layout.split': { enabled: !layoutFixed, checked: mailLayoutSetting === 'split' },
'view.layout.horizontal': { enabled: !layoutFixed, checked: mailLayoutSetting === 'horizontal' },
'view.layout.focus': { enabled: !layoutFixed, checked: mailLayoutSetting === 'focus' },
'message.reply': desktopSingle,
'message.replyAll': desktopSingle,
'message.forward': desktopSingle,
'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,
'message.archive': desktopTarget && archiveActionEnabled,
'message.spam': desktopTarget,
'message.delete': desktopTarget,
'message.openWindow': desktopSingle && !!openEmailInWindow,
'go.sent': true,
'go.drafts': true,
'go.nextMessage': !showComposer,
'go.previousMessage': !showComposer,
'go.nextUnread': !showComposer,
}, isAuthenticated && !isEmbedded);
// Shared mail refresh: re-fetch mailboxes (unread counts) + the active list
// via JMAP. Used by both the browser refresh gestures and the toolbar button.
const [isManualRefreshing, setIsManualRefreshing] = useState(false);
@@ -2265,7 +2323,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const composerSource = usePinnedPerSession(composerSessionId, selectedEmail ? emailWriteSource(selectedEmail, client) : undefined);
const popoutsOn = usePopoutsAvailable();
const openEmailInWindow = useOpenEmailInWindow();
const desktopDoubleClick = useDesktopPrefs().openOnDoubleClick && hasDesktopWindows() && !isMobile && !!openEmailInWindow;
// Text from a closing pop-out that couldn't be saved: the "Continue draft"
// slot, once it is free.
useEffect(() => {
@@ -2603,17 +2661,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
draft = await emailHooks.onBeforeEditDraft.transform(draft);
const { body: draftBody, bodyText, plainTextMode: draftPlainTextMode } = getDraftComposerBody(draft);
const { bodyText } = getDraftComposerBody(draft);
// Restore the identity the draft was composed with. Match the saved From
// (name + address) against the same list the composer renders — the flat
// cross-account list when multi-account is on — so a draft from a non-active
// account, or one of two identities sharing an address, is restored rather
// than reset to the default.
// The list the composer renders: the flat cross-account one when multi-account is on.
const composerIdentities = multiAccountIdentities.enabled
? multiAccountIdentities.allIdentities
: identities;
const matchedIdentityId = findDraftIdentityIdForLogin(composerIdentities, draft.from?.[0], localAccountId);
const draftRecipients = (list: typeof draft.to) => (list ?? []).map(a => a.email).filter(Boolean);
if (!(await withComposeGate('compose', {
@@ -2626,46 +2679,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Increment session ID to force the composer to remount with fresh state,
// even if it was already open (e.g. right-clicking a draft while composing).
setComposerSessionId(id => id + 1);
setPendingDraft({
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
bcc: draft.bcc?.map(a => a.email).filter(Boolean).join(', ') || '',
// Drafts saved before #1189 stored an empty subject as the composer's
// "(No Subject)" placeholder; reopen those with an empty field.
subject: draft.subject && draft.subject !== t('email_composer.no_subject') ? draft.subject : '',
body: draftBody,
// Re-open in the format the draft was written in (#1022).
plainTextMode: draftPlainTextMode,
showCc: (draft.cc?.length || 0) > 0,
showBcc: (draft.bcc?.length || 0) > 0,
selectedIdentityId: matchedIdentityId,
subAddressTag: '',
mode: 'compose',
draftId: draft.id,
draftAccountId: accountId,
draftLocalAccountId: localAccountId,
// A reply draft re-opens in compose mode; its threading headers must
// come along or the reply leaves its thread.
inReplyTo: draft.inReplyTo ?? undefined,
references: draft.references ?? undefined,
priority: messagePriorityFromHeaders(draft.headers),
// A sent message brought back (Undo, Cancel and edit) has no draft
// marker: its SIO-Label says which label it went out with.
classification: reopenedLabelChoice(draft.headers, localAccountId),
// Existing server-side attachments must ride along, or the composer
// starts empty and the next save/send silently rebuilds the draft
// without them (#849).
attachments: (draft.attachments ?? [])
.filter(a => !!a.blobId)
.map(a => ({
blobId: a.blobId,
name: a.name,
type: a.type,
size: a.size,
cid: a.cid,
disposition: a.disposition,
})),
});
setPendingDraft(composerDataFromDraft(draft, {
accountId,
localAccountId,
identities: composerIdentities,
noSubject: t('email_composer.no_subject'),
}));
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -5525,7 +5544,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onEmailSelect={handleEmailSelect}
onOpenSearchResult={handleEmailSelect}
onEmailPrefetch={handleEmailPrefetch}
onEmailDoubleClick={isEmbedded ? ((email) => {
onEmailDoubleClick={!isEmbedded ? (desktopDoubleClick ? (email) => {
// A draft goes back in the composer, as it does from a single click.
if (email.keywords?.$draft) void handleEditDraft(email);
else openEmailInWindow?.(email);
} : undefined) : ((email) => {
const store = useProTabStore.getState();
const hasSplit = store.tabs.some((tab) => tab.paneId === 'split');
// In a split, open on the *other* side so this list stays
@@ -5556,7 +5579,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
mailboxId: selectedMailbox,
title: email.subject?.trim() || t('email_composer.new_message'),
}, { pane: targetPane, reuseReader: hasSplit });
}) : undefined}
})}
onOpenConversation={handleOpenConversation}
// Context menu handlers
onReply={(email) => {
+25
View File
@@ -34,6 +34,8 @@ import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
import { canDownload } from '@/lib/device-policy';
import { useOpenEmailInWindow } from '@/hooks/use-popouts-available';
import { isFilePreviewable } from '@/lib/file-preview';
import { useDesktopCommands } from '@/lib/desktop/commands';
import { isPopoutWindow } from '@/lib/popout/window-role';
import dynamic from 'next/dynamic';
// Only needed when the device policy turns downloads off.
@@ -450,6 +452,29 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
setEmail({ ...email, keywords });
}, [email, setEmailKeywordsLocal]);
// In a pop-out this message is all there is, so the menu bar's Message menu means it.
const inOwnWindow = typeof window !== 'undefined' && isPopoutWindow();
const seen = !!email?.keywords?.$seen;
useDesktopCommands({
'message.reply': () => handleReply(),
'message.replyAll': handleReplyAll,
'message.forward': handleForward,
'message.markRead': () => { if (email) void handleMarkAsRead(email.id, true); },
'message.markUnread': () => { if (email) void handleMarkAsRead(email.id, false); },
'message.star': () => void handleToggleStar(),
'message.archive': archiveActionEnabled ? () => void handleArchive() : undefined,
'message.delete': () => void handleDelete(),
}, {
'message.reply': !!email,
'message.replyAll': !!email,
'message.forward': !!email,
'message.markRead': !!email && !seen,
'message.markUnread': !!email && seen,
'message.star': !!email,
'message.archive': !!email && archiveActionEnabled,
'message.delete': !!email,
}, inOwnWindow);
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
if (!client || !email) return;
try {
+56 -10
View File
@@ -17,6 +17,7 @@ import {
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { ChevronRight } from "@/components/icons";
import { nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu";
interface Position {
x: number;
@@ -199,6 +200,9 @@ interface MenuLevel {
const MenuLevelContext = createContext<MenuLevel | null>(null);
const neverTouched = () => false;
/** The desktop app shows this menu natively: it renders hidden, submenus and all, to be read. */
const NativeMenuContext = createContext(false);
function useMenuLevel(recentTouch: () => boolean): MenuLevel {
const [openId, setOpenId] = useState<string | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -245,11 +249,45 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
const recentTouch = useCallback(() => Date.now() - lastTouchAt.current < 800, []);
const level = useMenuLevel(recentTouch);
const { request: requestLevel } = level;
const [nativeRefused, setNativeRefused] = useState(false);
const nativeMode = isOpen && mounted && !nativeRefused && nativeMenusAvailable();
const positionRef = useRef(position);
positionRef.current = position;
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!isOpen) setNativeRefused(false);
}, [isOpen]);
// In the desktop app: read what rendered and show that natively. See lib/desktop/native-menu.ts.
useEffect(() => {
if (!nativeMode) return;
const root = localRef.current;
if (!root) return;
let cancelled = false;
void (async () => {
const read = await readSettledMenu(root, () => cancelled);
if (cancelled) return;
if (read.unsupported) {
setNativeRefused(true);
return;
}
const { anchor: at } = positionRef.current;
// From a right-click it opens at the pointer, which the OS knows best.
const place = at
? { x: at.align === "end" ? Math.max(0, at.right - root.getBoundingClientRect().width) : at.left, y: at.bottom + ANCHOR_GAP }
: undefined;
await showNativeMenu(read, place);
if (!cancelled) onCloseRef.current();
})();
return () => {
cancelled = true;
};
}, [nativeMode]);
const anchor = position.anchor;
const anchorKey = anchor ? `${anchor.left},${anchor.top},${anchor.right},${anchor.bottom},${anchor.align ?? ""}` : "";
@@ -443,11 +481,11 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
// Once placed (it is invisible, and can't take the focus, before), the
// first item gets the focus.
useEffect(() => {
if (!isOpen || !isPositioned) return;
if (!isOpen || !isPositioned || nativeMode) return;
const root = localRef.current;
if (!root || root.contains(document.activeElement)) return;
return focusFirstItem(root);
}, [isOpen, isPositioned]);
}, [isOpen, isPositioned, nativeMode]);
const setRefs = (node: HTMLDivElement | null) => {
localRef.current = node;
@@ -463,6 +501,7 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
const renderPosition = adjustedPosition ?? position;
return createPortal(
<NativeMenuContext.Provider value={nativeMode}>
<MenuLevelContext.Provider value={level}>
<div
ref={setRefs}
@@ -478,7 +517,7 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
left: renderPosition.x,
top: renderPosition.y,
maxWidth: `calc(100vw - ${2 * VIEWPORT_MARGIN}px)`,
visibility: isPositioned ? "visible" : "hidden",
visibility: isPositioned && !nativeMode ? "visible" : "hidden",
}}
role="menu"
aria-label={label}
@@ -493,7 +532,8 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
{children}
</div>
</div>
</MenuLevelContext.Provider>,
</MenuLevelContext.Provider>
</NativeMenuContext.Provider>,
document.body
);
}
@@ -578,11 +618,11 @@ export function ContextMenuItem({
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
{hint ? (
<span className="flex-1 min-w-0">
<span className="block">{label}</span>
<span className="block" data-menu-label="">{label}</span>
<span className="block text-xs text-muted-foreground">{hint}</span>
</span>
) : (
<span className="flex-1">{label}</span>
<span className="flex-1" data-menu-label="">{label}</span>
)}
{/* Key hints mean nothing without a keyboard: hidden on touch (R8). */}
{shortcut && (
@@ -625,6 +665,7 @@ export function ContextMenuSubMenu({
hint,
}: ContextMenuSubMenuProps) {
const parent = useContext(MenuLevelContext);
const native = useContext(NativeMenuContext);
const id = useId();
// Outside a ContextMenu (a host's own panel) it keeps its own state.
const [localOpen, setLocalOpen] = useState(false);
@@ -704,16 +745,21 @@ export function ContextMenuSubMenu({
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
{hint ? (
<span className="flex-1 min-w-0">
<span className="block">{label}</span>
<span className="block" data-menu-label="">{label}</span>
<span className="block text-xs text-muted-foreground">{hint}</span>
</span>
) : (
<span className="flex-1">{label}</span>
<span className="flex-1" data-menu-label="">{label}</span>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
{isOpen && !disabled && (
{native && !disabled && (
<div data-submenu-panel="" role="menu" aria-label={label} hidden={false}>
{children}
</div>
)}
{!native && isOpen && !disabled && (
<MenuLevelContext.Provider value={ownLevel}>
<SubMenuPanel
ref={panelRef}
@@ -822,7 +868,7 @@ interface ContextMenuHeaderProps {
export function ContextMenuHeader({ children }: ContextMenuHeaderProps) {
return (
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border">
<div data-context-menu-header="" className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border">
{children}
</div>
);
+5
View File
@@ -2,6 +2,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import type { ContextMenuPosition } from "@/components/ui/context-menu";
import { nativeMenuIsOpen } from "@/lib/desktop/native-menu";
interface ContextMenuState<T> {
isOpen: boolean;
@@ -134,12 +135,14 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
};
const handleClickOutside = (e: MouseEvent) => {
if (nativeMenuIsOpen()) return;
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeContextMenu();
}
};
const handleScroll = (e: Event) => {
if (nativeMenuIsOpen()) return;
if (menuRef.current && e.target instanceof Node && menuRef.current.contains(e.target)) {
return;
}
@@ -150,6 +153,8 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
// A focused control in the menu may have opened a system dialog that
// took the focus from the window, such as a color input's (#1366).
if (menuRef.current?.contains(document.activeElement)) return;
// In the desktop app the menu is native: it closes itself, and says what was picked.
if (nativeMenuIsOpen()) return;
closeContextMenu();
};
+301
View File
@@ -0,0 +1,301 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ContextMenu, ContextMenuHeader, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from '@/components/ui/context-menu';
import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '../types';
import { accentColours, applySystemAccent } from '../system-accent';
import { mergedDesktopState, registerDesktopSurface, resetDesktopCommands, runDesktopCommand, setDesktopBaseState } from '../commands';
import { readMenu } from '../native-menu';
import { noteOpenDraft, resetOpenDrafts, restoreUrlFor } from '../window-restore';
import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '../preferences';
import { closePopoutWindow, isPopoutPath } from '@/lib/popout/window-role';
function setBridge(bridge: Partial<BulwarkDesktopBridge> | undefined) {
window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
}
afterEach(() => {
setBridge(undefined);
resetDesktopCommands();
resetOpenDrafts();
localStorage.clear();
resetDesktopPrefsCache();
vi.useRealTimers();
});
function testMenu(extra?: React.ReactNode, handlers: { reply?: () => void; inbox?: () => void } = {}) {
return (
<>
<ContextMenuHeader>Three messages</ContextMenuHeader>
<ContextMenuItem label="Reply" onClick={handlers.reply ?? (() => {})} shortcut="R" hint="to the sender" />
<ContextMenuItem label="Forward" onClick={() => {}} disabled />
<ContextMenuSeparator />
<ContextMenuSubMenu label="Move to">
<ContextMenuItem label="Inbox" onClick={handlers.inbox ?? (() => {})} />
<ContextMenuItem label="R&D" onClick={() => {}} />
</ContextMenuSubMenu>
<ContextMenuSubMenu label="Rules" disabled>
<ContextMenuItem label="Never shown" onClick={() => {}} />
</ContextMenuSubMenu>
{extra}
</>
);
}
describe('an HTML menu shown natively', () => {
let popup: ReturnType<typeof vi.fn>;
let answer: (id: string | null) => void;
beforeEach(() => {
popup = vi.fn(() => new Promise<string | null>((resolve) => { answer = resolve; }));
setBridge({ version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } });
});
async function openMenu(children: React.ReactNode, onClose = vi.fn()) {
render(<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={onClose}>{children}</ContextMenu>);
await waitFor(() => expect(popup).toHaveBeenCalled());
return { items: popup.mock.calls[0][0] as DesktopNativeMenuItem[], at: popup.mock.calls[0][1], onClose };
}
it('reads every kind of row, in order, with submenus', async () => {
const { items } = await openMenu(testMenu());
expect(items.map((item) => item.label ?? item.type)).toEqual(['Three messages', 'Reply', 'Forward', 'separator', 'Move to', 'Rules']);
expect(items[0].enabled).toBe(false);
expect(items[2].enabled).toBe(false);
expect(items[4].submenu?.map((item) => item.label)).toEqual(['Inbox', 'R&D']);
// A disabled submenu shows, and has nothing in it.
expect(items[5].enabled).toBe(false);
expect(items[5].submenu).toBeUndefined();
});
it('takes the label without the shortcut or the hint', async () => {
const { items } = await openMenu(testMenu());
expect(items[1].label).toBe('Reply');
});
it('at the pointer from a right-click, under the button from a button', async () => {
const { at } = await openMenu(testMenu());
expect(at).toBeUndefined();
popup.mockClear();
render(
<ContextMenu isOpen position={{ x: 0, y: 0, anchor: { left: 40, top: 50, right: 90, bottom: 70 } }} onClose={() => {}}>
<ContextMenuItem label="One" onClick={() => {}} />
</ContextMenu>,
);
await waitFor(() => expect(popup).toHaveBeenCalled());
expect(popup.mock.calls[0][1]).toEqual({ x: 40, y: 74 });
});
it('a pick runs the HTML row, the one a browser would run, and closes the menu', async () => {
const reply = vi.fn();
const inbox = vi.fn();
const { items, onClose } = await openMenu(testMenu(undefined, { reply, inbox }));
await act(async () => {
answer(items[4].submenu![0].id!);
});
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(inbox).toHaveBeenCalledTimes(1);
expect(reply).not.toHaveBeenCalled();
});
it('dismissing it runs nothing and closes the menu', async () => {
const reply = vi.fn();
const { onClose } = await openMenu(testMenu(undefined, { reply }));
await act(async () => {
answer(null);
});
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(reply).not.toHaveBeenCalled();
});
it('the hidden copy is never visible', async () => {
await openMenu(testMenu());
const menu = document.body.querySelector<HTMLElement>('[role="menu"]');
expect(menu?.style.visibility).toBe('hidden');
});
it('stays HTML when something in it can\'t be shown natively', async () => {
render(
<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>
{testMenu(<input type="color" aria-label="Colour" />)}
</ContextMenu>,
);
await waitFor(() => expect(document.body.querySelector<HTMLElement>('[role="menu"]')?.style.visibility).toBe('visible'));
expect(popup).not.toHaveBeenCalled();
expect(screen.getByRole('menuitem', { name: /Reply/ })).toBeTruthy();
});
it('a row with no words stays HTML too: a colour swatch says nothing in a native menu', () => {
const root = document.createElement('div');
root.innerHTML = '<button role="menuitem"><svg></svg></button>';
expect(readMenu(root).unsupported).toBe(true);
});
it('a search field over a list is left out, and what it searches stays', () => {
const root = document.createElement('div');
root.innerHTML = '<input type="search" /><button role="menuitem">Inbox</button>';
const read = readMenu(root);
expect(read.unsupported).toBe(false);
expect(read.items.map((item) => item.label)).toEqual(['Inbox']);
});
it('reads check and radio rows', () => {
const root = document.createElement('div');
root.innerHTML = '<button role="menuitemcheckbox" aria-checked="true">Show</button><button role="menuitemradio" aria-checked="false">Other</button>';
expect(readMenu(root).items).toMatchObject([
{ type: 'checkbox', checked: true, label: 'Show' },
{ type: 'radio', checked: false, label: 'Other' },
]);
});
});
describe('the same menu in a browser', () => {
it('is the HTML menu, on screen, and no bridge is asked', async () => {
render(<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>{testMenu()}</ContextMenu>);
await waitFor(() => expect(document.body.querySelector<HTMLElement>('[role="menu"]')?.style.visibility).toBe('visible'));
// A closed submenu's rows aren't in the page.
expect(screen.queryByRole('menuitem', { name: 'Inbox' })).toBeNull();
});
});
describe('menu commands', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('the newest surface answers, and an older one is back when it goes', () => {
const older = vi.fn();
const newer = vi.fn();
registerDesktopSurface({ handlers: { 'message.reply': older }, state: {} });
const stop = registerDesktopSurface({ handlers: { 'message.reply': newer }, state: {} });
expect(runDesktopCommand('message.reply')).toBe(true);
expect(newer).toHaveBeenCalledTimes(1);
expect(older).not.toHaveBeenCalled();
stop();
runDesktopCommand('message.reply');
expect(older).toHaveBeenCalledTimes(1);
});
it('says when nothing answered, so the host can take it', () => {
expect(runDesktopCommand('go.calendar')).toBe(false);
});
it('the shell gets the state, surfaces over the base', () => {
const setState = vi.fn();
setBridge({ version: 1, platform: 'win32', menu: { setState } });
setDesktopBaseState({ 'message.reply': false, 'file.print': true });
registerDesktopSurface({ handlers: {}, state: { 'message.reply': true } });
vi.advanceTimersByTime(50);
expect(setState).toHaveBeenLastCalledWith({ 'message.reply': true, 'file.print': true });
expect(mergedDesktopState()['file.print']).toBe(true);
});
});
describe('composers that come back', () => {
it('tell the shell which draft they hold, and forget it when they go', () => {
const setRestore = vi.fn();
setBridge({ version: 1, platform: 'win32', windows: { setRestore } });
noteOpenDraft('a', { draftId: 'd1', localAccountId: 'login', ownerAccountId: 'group' });
expect(setRestore).toHaveBeenLastCalledWith([expect.stringContaining('draft=d1')]);
expect(setRestore.mock.lastCall![0][0]).toContain('account=login');
expect(setRestore.mock.lastCall![0][0]).toContain('owner=group');
noteOpenDraft('a', { draftId: 'd2' });
expect(setRestore.mock.lastCall![0]).toHaveLength(1);
expect(setRestore.mock.lastCall![0][0]).toContain('draft=d2');
noteOpenDraft('a', null);
expect(setRestore).toHaveBeenLastCalledWith([]);
});
it('are pop-out addresses the window type check recognises', () => {
expect(restoreUrlFor({ draftId: 'x' })).toMatch(/\/popout\/compose\?draft=x$/);
});
it('do nothing in a browser', () => {
expect(() => noteOpenDraft('a', { draftId: 'd1' })).not.toThrow();
});
});
describe('a pop-out closing itself', () => {
it('goes through the shell in the desktop app, not window.close', () => {
const closeSelf = vi.fn();
const close = vi.spyOn(window, 'close').mockImplementation(() => {});
setBridge({ version: 1, platform: 'win32', windows: { closeSelf } });
closePopoutWindow();
expect(closeSelf).toHaveBeenCalledTimes(1);
expect(close).not.toHaveBeenCalled();
close.mockRestore();
});
it('uses window.close in a browser', () => {
vi.useFakeTimers();
const close = vi.spyOn(window, 'close').mockImplementation(() => {});
closePopoutWindow();
expect(close).toHaveBeenCalledTimes(1);
close.mockRestore();
});
it('counts the warm spare as a pop-out, which is a guest of the main window', () => {
expect(isPopoutPath('/popout/spare')).toBe(true);
expect(isPopoutPath('/de/popout/spare')).toBe(true);
expect(isPopoutPath('/popout/spare/x')).toBe(false);
});
});
describe('window settings', () => {
it('both default to on, and stay on this computer', () => {
expect(readDesktopPrefs()).toEqual({ openOnDoubleClick: true, restoreWindows: true });
setDesktopPref('restoreWindows', false);
resetDesktopPrefsCache();
expect(readDesktopPrefs().restoreWindows).toBe(false);
expect(readDesktopPrefs().openOnDoubleClick).toBe(true);
});
it('a damaged value is the default', () => {
localStorage.setItem('bulwark-desktop-window-prefs', '{nope');
expect(readDesktopPrefs().restoreWindows).toBe(true);
});
});
describe('the OS accent colour', () => {
beforeEach(() => setBridge({ version: 1, platform: 'win32' }));
const apply = (input: Partial<Parameters<typeof applySystemAccent>[0]>) => {
const root = document.createElement('div');
applySystemAccent({ accent: '#0078d4', dark: false, followsSystem: true, ...input }, root);
return root.style.getPropertyValue('--color-primary');
};
it('is used while the theme follows the system', () => {
expect(apply({})).toBe('#0078d4');
});
it('not under a theme the user picked', () => {
expect(apply({ followsSystem: false })).toBe('');
});
it('not in a contrast theme', () => {
expect(apply({ highContrast: true })).toBe('');
});
it('not where it would vanish against the page', () => {
expect(accentColours('#fefefe', false)).toBeNull();
expect(accentColours('#050505', true)).toBeNull();
});
it('text on it is what reads', () => {
expect(accentColours('#0078d4', false)?.foreground).toBe('#ffffff');
expect(accentColours('#ffd400', true)?.foreground).toBe('#000000');
});
it('a removed accent takes its colours off again', () => {
const root = document.createElement('div');
applySystemAccent({ accent: '#0078d4', dark: false, followsSystem: true }, root);
applySystemAccent({ dark: false, followsSystem: true }, root);
expect(root.style.getPropertyValue('--color-primary')).toBe('');
expect(root.style.getPropertyValue('--color-primary-foreground')).toBe('');
});
it('does nothing in a browser', () => {
setBridge(undefined);
expect(apply({})).toBe('');
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* The desktop app's menu bar, on the webmail's side. The shell shows the menu and
* sends an id; what runs is the same handler the shortcut or button runs, from
* whichever part of the app registered it. What's on screen decides what's enabled:
* a surface reports that here, and the newest surface wins per command.
*
* Nothing happens in a browser: there's no bridge to publish to, and nobody sends
* an id.
*/
import { useEffect, useRef } from 'react';
import { getDesktopBridge } from './bridge';
import type { DesktopMenuState } from './types';
export type DesktopCommandHandlers = Record<string, (() => void) | undefined>;
interface Surface {
handlers: DesktopCommandHandlers;
state: DesktopMenuState;
}
const surfaces = new Set<Surface>();
let base: DesktopMenuState = {};
let timer: ReturnType<typeof setTimeout> | null = null;
/** What the shell should show now: the base, with each surface over it, newest last. */
export function mergedDesktopState(): DesktopMenuState {
const merged: DesktopMenuState = { ...base };
for (const surface of surfaces) Object.assign(merged, surface.state);
return merged;
}
function publish(): void {
if (timer) return;
timer = setTimeout(() => {
timer = null;
getDesktopBridge()?.menu?.setState(mergedDesktopState());
}, 30);
}
/** What holds whatever is on screen: signed in or not, the device policy. */
export function setDesktopBaseState(state: DesktopMenuState): void {
base = state;
publish();
}
/** Runs a command from the newest surface that has it. False when none does. */
export function runDesktopCommand(id: string): boolean {
for (const surface of [...surfaces].reverse()) {
const handler = surface.handlers[id];
if (handler) {
handler();
return true;
}
}
return false;
}
export function registerDesktopSurface(surface: Surface): () => void {
surfaces.add(surface);
publish();
return () => {
surfaces.delete(surface);
publish();
};
}
function sameState(a: DesktopMenuState, b: DesktopMenuState): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
/**
* This part of the app answers these commands while `enabled`. Handlers can be new
* on every render: the newest ones run, and only a changed state goes to the shell.
*/
export function useDesktopCommands(handlers: DesktopCommandHandlers, state: DesktopMenuState, enabled = true): void {
const surfaceRef = useRef<Surface>({ handlers, state });
surfaceRef.current.handlers = handlers;
useEffect(() => {
if (!enabled || !getDesktopBridge()?.menu) return;
return registerDesktopSurface(surfaceRef.current);
}, [enabled]);
useEffect(() => {
if (sameState(surfaceRef.current.state, state)) return;
surfaceRef.current.state = state;
publish();
});
}
/** Test helper. */
export function resetDesktopCommands(): void {
surfaces.clear();
base = {};
if (timer) clearTimeout(timer);
timer = null;
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Turns an HTML menu into a native one. The ContextMenu component renders as it does
* in a browser, hidden, with its submenus open. This reads the result and shows that
* natively. A pick clicks the row the HTML menu has for it, so a menu has one list of
* entries with one set of handlers and the two can't drift.
*
* A menu with something a native one can't show (a plugin's frame, a colour picker,
* a row with no text) stays HTML. So does anything marked data-native-menu="html".
*/
import { getDesktopBridge } from './bridge';
import type { DesktopNativeMenuItem } from './types';
export function nativeMenusAvailable(): boolean {
return !!getDesktopBridge()?.menu?.popup;
}
export interface ReadMenu {
items: DesktopNativeMenuItem[];
/** The HTML row behind each native item's id. */
elements: Map<string, HTMLElement>;
/** There's something here a native menu can't show. */
unsupported: boolean;
}
const ROW = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, a[href]';
function isDisabled(el: HTMLElement): boolean {
return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true';
}
function labelOf(el: HTMLElement): string {
const named = el.getAttribute('aria-label') ?? el.querySelector<HTMLElement>('[data-menu-label]')?.textContent ?? el.textContent ?? '';
return named.replace(/\s+/g, ' ').trim();
}
function isHidden(el: Element): boolean {
return typeof window.getComputedStyle === 'function' && window.getComputedStyle(el).display === 'none';
}
export function readMenu(root: HTMLElement): ReadMenu {
const elements = new Map<string, HTMLElement>();
let unsupported = root.querySelector('[data-native-menu="html"], iframe, select, textarea, input[type="color"]') !== null;
let next = 1;
const walk = (container: Element, into: DesktopNativeMenuItem[]): void => {
for (const child of Array.from(container.children)) {
if (!(child instanceof HTMLElement) || isHidden(child)) continue;
if (child.getAttribute('role') === 'separator') {
into.push({ type: 'separator' });
continue;
}
if (child.dataset.submenuPanel !== undefined) continue;
if (child.dataset.contextMenuHeader !== undefined) {
const label = labelOf(child);
if (label) into.push({ id: `h${next++}`, label, enabled: false });
continue;
}
if (child.dataset.submenuTrigger !== undefined) {
const label = labelOf(child);
if (isDisabled(child)) {
into.push({ id: `s${next++}`, label, enabled: false });
continue;
}
const panel = child.parentElement?.querySelector<HTMLElement>(':scope > [data-submenu-panel]');
if (!panel) {
unsupported = true;
continue;
}
const submenu: DesktopNativeMenuItem[] = [];
walk(panel, submenu);
into.push({ label, submenu });
continue;
}
if (child.matches(ROW)) {
// A row that opens its own popup: there's nothing to click through to.
if (child.hasAttribute('aria-haspopup')) {
unsupported = true;
continue;
}
const label = labelOf(child);
if (!label) {
// A swatch or an icon says nothing in a native menu.
unsupported = true;
continue;
}
const id = `i${next++}`;
elements.set(id, child);
const role = child.getAttribute('role');
into.push({
id,
label,
enabled: !isDisabled(child),
...(role === 'menuitemcheckbox' || role === 'menuitemradio'
? { type: role === 'menuitemcheckbox' ? 'checkbox' as const : 'radio' as const, checked: child.getAttribute('aria-checked') === 'true' }
: {}),
});
continue;
}
// A search field over a list is no use where you can't type.
if (child instanceof HTMLInputElement) continue;
walk(child, into);
}
};
const items: DesktopNativeMenuItem[] = [];
walk(root, items);
return { items, elements, unsupported };
}
const SETTLE_MS = 30;
const SETTLE_LIMIT_MS = 400;
/**
* The menu once it stopped changing. Entries can render a moment after the menu
* (the mail menu waits for its data), and a native menu can't grow once it's up.
*/
export async function readSettledMenu(root: HTMLElement, cancelled: () => boolean): Promise<ReadMenu> {
const started = Date.now();
let previous = readMenu(root);
while (!cancelled() && Date.now() - started < SETTLE_LIMIT_MS) {
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
const current = readMenu(root);
if (current.items.length > 0 && JSON.stringify(current.items) === JSON.stringify(previous.items)) return current;
previous = current;
}
return previous;
}
/** Shows it and clicks the row picked. Resolves when the menu has gone. */
export async function showNativeMenu(read: ReadMenu, at?: { x: number; y: number }): Promise<void> {
const popup = getDesktopBridge()?.menu?.popup;
if (!popup) return;
document.documentElement.dataset.nativeMenuOpen = '';
try {
const picked = await popup(read.items, at);
if (picked) read.elements.get(picked)?.click();
} finally {
delete document.documentElement.dataset.nativeMenuOpen;
}
}
/** A native menu is up: the window's blur or a stray event says nothing about it. */
export function nativeMenuIsOpen(): boolean {
return typeof document !== 'undefined' && document.documentElement.dataset.nativeMenuOpen !== undefined;
}
+69
View File
@@ -0,0 +1,69 @@
/**
* The OS accent colour, when the user's theme is "system". Buttons and selection take
* it the way native controls do. Not under a theme the user chose, and not when the
* colour would be hard to see: the app's own stays then.
*/
import { getDesktopBridge } from './bridge';
const VARIABLES = ['--color-primary', '--color-ring', '--color-primary-foreground'] as const;
function channels(hex: string): [number, number, number] | null {
const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
if (!match) return null;
return [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)];
}
function luminance([r, g, b]: [number, number, number]): number {
const [lr, lg, lb] = [r, g, b].map((value) => {
const c = value / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
}
function contrast(a: number, b: number): number {
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
/** Non-text contrast, WCAG 1.4.11. */
const MIN_AGAINST_BACKGROUND = 3;
const MIN_TEXT = 4.5;
/**
* The colours to use, or null for the app's own: the accent has to stand out from the
* page, and text on it has to be readable in either black or white.
*/
export function accentColours(accent: string, dark: boolean): { primary: string; foreground: string } | null {
const rgb = channels(accent);
if (!rgb) return null;
const own = luminance(rgb);
// The standard theme's page backgrounds.
const page = dark ? 0.0091 : 1;
if (contrast(own, page) < MIN_AGAINST_BACKGROUND) return null;
const onWhite = contrast(own, 1);
const onBlack = contrast(own, 0);
if (Math.max(onWhite, onBlack) < MIN_TEXT) return null;
return { primary: accent, foreground: onWhite >= MIN_TEXT ? '#ffffff' : '#000000' };
}
export interface AccentInput {
accent?: string;
/** Whether the page is dark now. */
dark: boolean;
/** The user's theme setting is "system" and no installed theme is active. */
followsSystem: boolean;
/** A contrast theme has its own colours and they win. */
highContrast?: boolean;
}
export function applySystemAccent({ accent, dark, followsSystem, highContrast }: AccentInput, root: HTMLElement = document.documentElement): void {
const colours = accent && followsSystem && !highContrast && getDesktopBridge() ? accentColours(accent, dark) : null;
if (!colours) {
for (const name of VARIABLES) root.style.removeProperty(name);
return;
}
root.style.setProperty('--color-primary', colours.primary);
root.style.setProperty('--color-ring', colours.primary);
root.style.setProperty('--color-primary-foreground', colours.foreground);
}