From d9c9fc03f7d2baf245ffb343e573ccf1d097d6ea Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:44:03 +0100 Subject: [PATCH] feat: give the desktop app a native menu bar and native right-click menus built from the webmail's own --- app/(main)/[locale]/layout.tsx | 2 + app/globals.css | 5 + .../desktop/__tests__/desktop-host.test.tsx | 151 +++++++++ components/desktop/desktop-host.tsx | 150 +++++++++ .../__tests__/reader-attachments.test.tsx | 45 ++- components/email/email-viewer.tsx | 10 + components/email/reader-attachments.tsx | 31 +- components/layout/window-title-bar.tsx | 86 ++++- components/mail/mail-app.tsx | 123 ++++--- components/pro/pro-email-tab-body.tsx | 25 ++ components/ui/context-menu.tsx | 66 +++- hooks/use-context-menu.ts | 5 + lib/desktop/__tests__/native-ui.test.tsx | 301 ++++++++++++++++++ lib/desktop/commands.ts | 98 ++++++ lib/desktop/native-menu.ts | 146 +++++++++ lib/desktop/system-accent.ts | 69 ++++ 16 files changed, 1248 insertions(+), 65 deletions(-) create mode 100644 components/desktop/__tests__/desktop-host.test.tsx create mode 100644 components/desktop/desktop-host.tsx create mode 100644 lib/desktop/__tests__/native-ui.test.tsx create mode 100644 lib/desktop/commands.ts create mode 100644 lib/desktop/native-menu.ts create mode 100644 lib/desktop/system-accent.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 992bef2ec..762f30ab9 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -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 && } {!IS_LITE && } + {!IS_LITE && } {children} diff --git a/app/globals.css b/app/globals.css index 212b080c2..5d79028fd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx new file mode 100644 index 000000000..b44514e6f --- /dev/null +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -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) | 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) => { 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()).not.toThrow(); + }); + + it('runs a command from what\'s on the page first', () => { + const shell = fakeBridge(); + render(); + 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(); + 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(); + shell.run('link.folder:sent'); + expect(push).toHaveBeenLastCalledWith('/mail/folder/sent'); + }); + + it('an unknown command does nothing', () => { + const shell = fakeBridge(); + render(); + 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(); + 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(); + 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(); + await act(async () => {}); + expect(shell.sessionReady).not.toHaveBeenCalled(); + first.unmount(); + useAuthStore.setState({ isAuthenticated: true } as never); + window.history.replaceState(null, '', '/popout/compose'); + render(); + await act(async () => {}); + expect(shell.sessionReady).not.toHaveBeenCalled(); + }); + + it('passes the restore setting on', async () => { + const shell = fakeBridge(); + render(); + 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(); + 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(); + act(() => useOfflineStore.setState({ outbox: [{ id: 1 }, { id: 2 }] } as never)); + await expect(shell.flush()).resolves.toEqual({ unsent: 2 }); + act(() => useOfflineStore.setState({ outbox: [] } as never)); + }); +}); diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx new file mode 100644 index 000000000..83c6722e3 --- /dev/null +++ b/components/desktop/desktop-host.tsx @@ -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 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; +} diff --git a/components/email/__tests__/reader-attachments.test.tsx b/components/email/__tests__/reader-attachments.test.tsx index 8e0f1fae4..67c6913e7 100644 --- a/components/email/__tests__/reader-attachments.test.tsx +++ b/components/email/__tests__/reader-attachments.test.tsx @@ -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 { + afterEach(() => { + window.bulwarkDesktop = undefined; + cleanup(); + }); + + const openMenu = async (popupAnswer: (items: { id?: string; label?: string }[]) => string | null, overrides: Partial[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>)[handler]).toHaveBeenCalledTimes(1)); + expect((props as unknown as Record>)[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']); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e75a260ca..cd85eacd2 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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(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. diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx index 0e3aa1fbd..d04e16c09 100644 --- a/components/email/reader-attachments.tsx +++ b/components/email/reader-attachments.tsx @@ -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 { layout?: ReaderAttachmentLayout; /** Object URLs of image attachments, keyed by attachment id. */ thumbUrls: Record; + /** + * 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({ onDownload, onDownloadAll, onSaveAllToFolder, + onCopyFile, downloadingAll, downloadsOff = false, onSaveToFiles, @@ -105,6 +114,11 @@ export function ReaderAttachments({ const tFiles = useTranslations("files"); const tDevice = useTranslations("device_policy"); const tWindows = useTranslations("pwaWindows"); + const tDesktop = useTranslations("desktopUi.attachment"); + const menu = useContextMenu(); + 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({ 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) => ( -
+
menu.openContextMenu(event, attachment) : undefined}> {renderItem ? renderItem(attachment, (drag) => draw(attachment, drag)) : draw(attachment, NO_DRAG)} @@ -324,6 +338,19 @@ export function ReaderAttachments({ )}
+ {desktopMenu && menuTarget && ( + + {!locked(menuTarget) && ( + { menu.closeContextMenu(); onOpen(menuTarget); }} /> + )} + {!downloadsOff && ( + { menu.closeContextMenu(); onDownload(menuTarget); }} /> + )} + {!downloadsOff && onCopyFile && ( + { menu.closeContextMenu(); onCopyFile(menuTarget); }} /> + )} + + )} ); } diff --git a/components/layout/window-title-bar.tsx b/components/layout/window-title-bar.tsx index 7f4e306fa..df2f7d335 100644 --- a/components/layout/window-title-bar.tsx +++ b/components/layout/window-title-bar.tsx @@ -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(null); const [title, setTitle] = useState(""); + const [entries, setEntries] = useState(null); + const stripRef = useRef(null); + const previousFocus = useRef(null); + + const openMenu = useCallback((id: string) => { + const button = stripRef.current?.querySelector(`[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("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) => { + const buttons = Array.from(stripRef.current?.querySelectorAll("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 ( +
+ {entries.map((entry) => ( + + ))} +
+ ); + } + return ( - , + + , document.body ); } @@ -578,11 +618,11 @@ export function ContextMenuItem({ {Icon && } {hint ? ( - {label} + {label} {hint} ) : ( - {label} + {label} )} {/* 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 && } {hint ? ( - {label} + {label} {hint} ) : ( - {label} + {label} )}
- {isOpen && !disabled && ( + {native && !disabled && ( + + )} + {!native && isOpen && !disabled && ( +
{children}
); diff --git a/hooks/use-context-menu.ts b/hooks/use-context-menu.ts index dfdc02348..eb412b42d 100644 --- a/hooks/use-context-menu.ts +++ b/hooks/use-context-menu.ts @@ -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 { isOpen: boolean; @@ -134,12 +135,14 @@ export function useContextMenu(): UseContextMenuReturn { }; 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(): UseContextMenuReturn { // 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(); }; diff --git a/lib/desktop/__tests__/native-ui.test.tsx b/lib/desktop/__tests__/native-ui.test.tsx new file mode 100644 index 000000000..2bab20eb1 --- /dev/null +++ b/lib/desktop/__tests__/native-ui.test.tsx @@ -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 | 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 ( + <> + Three messages + {})} shortcut="R" hint="to the sender" /> + {}} disabled /> + + + {})} /> + {}} /> + + + {}} /> + + {extra} + + ); +} + +describe('an HTML menu shown natively', () => { + let popup: ReturnType; + let answer: (id: string | null) => void; + + beforeEach(() => { + popup = vi.fn(() => new Promise((resolve) => { answer = resolve; })); + setBridge({ version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } }); + }); + + async function openMenu(children: React.ReactNode, onClose = vi.fn()) { + render({children}); + 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( + {}}> + {}} /> + , + ); + 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('[role="menu"]'); + expect(menu?.style.visibility).toBe('hidden'); + }); + + it('stays HTML when something in it can\'t be shown natively', async () => { + render( + {}}> + {testMenu()} + , + ); + await waitFor(() => expect(document.body.querySelector('[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 = ''; + 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 = ''; + 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 = ''; + 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( {}}>{testMenu()}); + await waitFor(() => expect(document.body.querySelector('[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[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(''); + }); +}); diff --git a/lib/desktop/commands.ts b/lib/desktop/commands.ts new file mode 100644 index 000000000..5013eeb66 --- /dev/null +++ b/lib/desktop/commands.ts @@ -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 void) | undefined>; + +interface Surface { + handlers: DesktopCommandHandlers; + state: DesktopMenuState; +} + +const surfaces = new Set(); +let base: DesktopMenuState = {}; +let timer: ReturnType | 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({ 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; +} diff --git a/lib/desktop/native-menu.ts b/lib/desktop/native-menu.ts new file mode 100644 index 000000000..ae8eca8a5 --- /dev/null +++ b/lib/desktop/native-menu.ts @@ -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; + /** 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('[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(); + 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(':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 { + 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 { + 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; +} diff --git a/lib/desktop/system-accent.ts b/lib/desktop/system-accent.ts new file mode 100644 index 000000000..49f575429 --- /dev/null +++ b/lib/desktop/system-accent.ts @@ -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); +}