From b2931ced3ed784204d9d6d15196111a5eea23e7c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:44:02 +0100 Subject: [PATCH 1/3] feat: bring desktop windows back at the next start, open pop-outs from a warm spare and ask natively before discarding a draft --- app/(main)/[locale]/popout/spare/page.tsx | 7 ++ .../__tests__/composer-close-guard.test.tsx | 35 +++++- components/email/email-composer.tsx | 41 ++++++- .../__tests__/popout-compose-app.test.tsx | 45 ++++++++ components/popout/popout-compose-app.tsx | 64 ++++++++++- components/popout/popout-spare-app.tsx | 45 ++++++++ .../settings/desktop-window-settings.tsx | 26 +++++ components/settings/general-settings.tsx | 2 + lib/desktop/preferences.ts | 71 +++++++++++++ lib/desktop/types.ts | 100 +++++++++++++++++- lib/desktop/window-restore.ts | 40 +++++++ lib/draft-composer-data.ts | 60 +++++++++++ lib/popout/open.ts | 6 ++ lib/popout/window-role.ts | 10 +- locales/ar/common.json | 15 +++ locales/ca/common.json | 15 +++ locales/cs/common.json | 15 +++ locales/da/common.json | 15 +++ locales/de/common.json | 15 +++ locales/en/common.json | 15 +++ locales/es/common.json | 15 +++ locales/et/common.json | 15 +++ locales/fa/common.json | 15 +++ locales/fr/common.json | 15 +++ locales/he/common.json | 15 +++ locales/hu/common.json | 15 +++ locales/it/common.json | 15 +++ locales/ja/common.json | 15 +++ locales/ko/common.json | 15 +++ locales/lv/common.json | 15 +++ locales/mn/common.json | 15 +++ locales/nb/common.json | 15 +++ locales/nl/common.json | 15 +++ locales/pl/common.json | 15 +++ locales/pt/common.json | 15 +++ locales/ro/common.json | 15 +++ locales/ru/common.json | 15 +++ locales/sk/common.json | 15 +++ locales/th/common.json | 15 +++ locales/tr/common.json | 15 +++ locales/uk/common.json | 15 +++ locales/vi/common.json | 15 +++ locales/zh-TW/common.json | 15 +++ locales/zh/common.json | 15 +++ 44 files changed, 987 insertions(+), 15 deletions(-) create mode 100644 app/(main)/[locale]/popout/spare/page.tsx create mode 100644 components/popout/popout-spare-app.tsx create mode 100644 components/settings/desktop-window-settings.tsx create mode 100644 lib/desktop/preferences.ts create mode 100644 lib/desktop/window-restore.ts create mode 100644 lib/draft-composer-data.ts diff --git a/app/(main)/[locale]/popout/spare/page.tsx b/app/(main)/[locale]/popout/spare/page.tsx new file mode 100644 index 000000000..1eafe6639 --- /dev/null +++ b/app/(main)/[locale]/popout/spare/page.tsx @@ -0,0 +1,7 @@ +import { PopoutSpareApp } from "@/components/popout/popout-spare-app"; + +// The desktop app's warm spare (components/popout/popout-spare-app.tsx). Not in +// Lite: scripts/lite removes this tree. +export default function PopoutSparePage() { + return ; +} diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index 8ac5e5b82..9ef9370a7 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; -import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; import { useAuthStore } from '@/stores/auth-store'; @@ -235,6 +235,39 @@ describe('composer close guard with a follow-up', () => { expect(afterClose).not.toHaveBeenCalled(); }); + describe('in the desktop app', () => { + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + const nativeBridge = (answer: number) => { + const confirm = vi.fn(async () => answer); + window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm } } as never; + return confirm; + }; + + it('asks through the OS, and shows no dialog of its own', async () => { + const confirm = nativeBridge(2); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)); + expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', buttons: ['save', 'discard', 'cancel'], defaultId: 0 }]); + expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument(); + // The last button is Cancel: nothing happens. + await act(async () => {}); + expect(onClose).not.toHaveBeenCalled(); + expect(afterClose).not.toHaveBeenCalled(); + }); + + it('Discard discards, and the follow-up runs', async () => { + nativeBridge(1); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); + }); + }); + it('runs the follow-up after discarding', async () => { const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d4ff3a9a8..2e25fdb4d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -166,6 +166,8 @@ import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; import { fileStorage } from "@/lib/plugin-storage"; import { usePolicyStore } from "@/stores/policy-store"; import { registerComposeFlush } from "@/lib/compose-flush-registry"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { noteOpenDraft } from "@/lib/desktop/window-restore"; import type { RescuedDraft } from "@/lib/popout/channel"; import { toUnicodeEmail } from "@/lib/idn"; @@ -1936,6 +1938,14 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // A composer that comes back with the app: a draft it opened on counts from the start. + // Gone with the composer: sent, discarded or closed. + useEffect(() => { + if (draftIdRef.current) noteOpenDraft(effectiveSessionKey, { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current, ownerAccountId: draftAccountIdRef.current }); + return () => noteOpenDraft(effectiveSessionKey, null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveSessionKey]); + // Auto-save draft to server on page close (best-effort) const ownWindowRef = useRef(ownWindow); ownWindowRef.current = ownWindow; @@ -3005,6 +3015,8 @@ export function EmailComposer({ draftIdRef.current = savedDraftId; draftAccountIdRef.current = draftAccountId; draftLocalAccountIdRef.current = savingLogin; + // Before a save's promise settles: quitting waits for it, then keeps this list. + noteOpenDraft(effectiveSessionKey, { draftId: savedDraftId, localAccountId: savingLogin, ownerAccountId: draftAccountId }); setDraftId(savedDraftId); setDraftAccountId(draftAccountId); setDraftLocalAccountId(savingLogin); @@ -4418,6 +4430,29 @@ export function EmailComposer({ emitClose(); }; + /** The question before closing or discarding: the OS's own in the desktop app. */ + const openCloseDialog = (kind: 'discard' | 'close') => { + const dialogs = getDesktopBridge()?.dialogs; + if (!dialogs) { + setCloseDialogKind(kind); + setShowCloseDialog(true); + return; + } + const discarding = kind === 'discard'; + const buttons = discarding ? [t('discard'), t('cancel')] : [tCommon('save'), t('discard'), t('cancel')]; + void dialogs.confirm({ + type: 'warning', + message: discarding ? t('discard_draft_title') : t('close_draft_title'), + detail: discarding ? t('discard_draft_message') : t('close_draft_message'), + buttons, + defaultId: discarding ? 1 : 0, + }).then((choice) => { + if (buttons[choice] === t('discard')) handleDiscardAndClose(); + else if (!discarding && choice === 0) void handleSaveDraftAndClose(); + else afterCloseRef.current = null; + }); + }; + // Discard deletes the draft, also one that has no unsaved changes, such as // a scheduled message reopened with "Cancel and edit". It used to share the // X button's path, which closed such a composer and kept the draft. @@ -4429,8 +4464,7 @@ export function EmailComposer({ return; } afterCloseRef.current = null; - setCloseDialogKind('discard'); - setShowCloseDialog(true); + openCloseDialog('discard'); }; const handleClose = (afterClose?: () => void) => { @@ -4442,8 +4476,7 @@ export function EmailComposer({ && !saveFailedRef.current && !attachmentsRef.current.some(att => att.uploading || att.error); if (isDirtyRef.current && !savedAlready) { - setCloseDialogKind('close'); - setShowCloseDialog(true); + openCloseDialog('close'); } else { cleanClose(); } diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 3ff5cf883..2a02faf0f 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -67,6 +67,51 @@ function openAt(handoffId: string) { render(); } +describe('a composer coming back on its draft', () => { + const closeSelf = vi.fn(); + + beforeEach(() => { + closeSelf.mockClear(); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf } } as never; + }); + + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + const draft = { + id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ email: 'me@example.org' }], + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [{ blobId: 'b1', name: 'a.pdf', type: 'application/pdf', size: 3 }], + }; + + const openDraft = (getEmail: ReturnType, search = 'draft=d1&account=alice&owner=group') => { + useAuthStore.setState({ client: { getEmail } as never, getClientForAccount: () => undefined } as never); + window.history.replaceState(null, '', `/en/popout/compose?${search}`); + render(); + }; + + it('reads the draft from where it lives, and opens the composer on it, id and all', async () => { + const getEmail = vi.fn(async () => draft); + openDraft(getEmail); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Half-written')); + expect(getEmail).toHaveBeenCalledWith('d1', 'group'); + // Saving goes over this draft, not next to it, and its files stay. + expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: 'alice', to: 'a@example.org' }); + expect((composer?.initialData as unknown as { attachments: unknown[] }).attachments).toHaveLength(1); + }); + + it('a draft that has gone since (sent elsewhere) closes the window', async () => { + openDraft(vi.fn(async () => null)); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + expect(screen.queryByTestId('composer')).toBeNull(); + }); + + it('a server that doesn\'t answer closes it too: the draft is safe on the server', async () => { + openDraft(vi.fn(async () => { throw new Error('offline'); })); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + }); +}); + describe('PopoutComposeApp', () => { it('tells the opening window to let go only with the composer on screen', async () => { const handoff = createComposeHandoff()!; diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 6f0bebeff..7aaa4f709 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -18,6 +18,8 @@ import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watch import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; import { closePopoutWindow } from "@/lib/popout/window-role"; +import { composerDataFromDraft } from "@/lib/draft-composer-data"; +import { useIdentityStore } from "@/stores/identity-store"; import { PopoutShell, PopoutSkeleton } from "./popout-shell"; /** A warning with no main window left to show it stays this long before the window goes. */ @@ -31,10 +33,60 @@ const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" }; /** How long a reloaded window waits to hear what became of the text it handed over on the way out. */ const RESCUE_ANSWER_MS = 2000; -function readLaunch(): { handoffId: string | null; accountId?: string } { +function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } { if (typeof window === "undefined") return { handoffId: null }; const search = new URLSearchParams(window.location.search); - return { handoffId: search.get("handoff"), accountId: search.get("account") ?? undefined }; + return { + handoffId: search.get("handoff"), + accountId: search.get("account") ?? undefined, + draftId: search.get("draft") ?? undefined, + ownerAccountId: search.get("owner") ?? undefined, + }; +} + +/** + * The composer that was open when the desktop app quit, back on its draft. A draft + * that has gone since (sent or deleted elsewhere) means there's nothing to show. + */ +function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: { + draftId: string; + accountId?: string; + ownerAccountId?: string; + onReady: (payload: ComposePopoutPayload) => void; +}) { + const t = useTranslations(); + const tWindows = useTranslations("pwaWindows"); + const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined) ?? s.client); + const identities = useIdentityStore((s) => s.identities); + useEffect(() => { + if (!client) return; + let cancelled = false; + void client.getEmail(draftId, ownerAccountId).then((draft) => { + if (cancelled) return; + if (!draft) { + closePopoutWindow(); + return; + } + onReady({ + mode: "compose", + initialData: composerDataFromDraft(draft, { + accountId: ownerAccountId, + localAccountId: accountId, + identities, + noSubject: t("email_composer.no_subject"), + }), + }); + }).catch((error) => { + debug.error("Failed to reopen a draft:", error); + if (!cancelled) closePopoutWindow(); + }); + return () => { + cancelled = true; + }; + // Once per client: the draft doesn't change under a window still opening. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client, draftId, ownerAccountId]); + return ; } /** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */ @@ -181,8 +233,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; /** `/popout/compose`: a draft handed over by another window, or a new message. */ export function PopoutComposeApp() { const t = useTranslations("pwaWindows"); - const [{ handoffId, accountId }] = useState(readLaunch); - const [payload, setPayload] = useState(handoffId ? null : EMPTY_PAYLOAD); + const [{ handoffId, accountId, draftId, ownerAccountId }] = useState(readLaunch); + const [payload, setPayload] = useState(handoffId || draftId ? null : EMPTY_PAYLOAD); useEffect(() => { if (!handoffId) return; @@ -216,7 +268,9 @@ export function PopoutComposeApp() { return ( - {payload ? : } + {payload ? + : draftId ? + : } ); } diff --git a/components/popout/popout-spare-app.tsx b/components/popout/popout-spare-app.tsx new file mode 100644 index 000000000..26f67104e --- /dev/null +++ b/components/popout/popout-spare-app.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { withBasePath } from "@/lib/browser-navigation"; +import { PopoutComposeApp } from "./popout-compose-app"; +import { PopoutMessageApp } from "./popout-message-app"; + +/** The message id a pop-out address names. */ +function messageIdOf(pathname: string): string { + const segments = pathname.split("/").filter(Boolean); + const at = segments.lastIndexOf("message"); + return at >= 0 && segments[at + 1] ? decodeURIComponent(segments[at + 1]) : ""; +} + +/** + * The desktop app's warm spare: a hidden window with the pop-out code loaded and + * nothing signed in. When a pop-out is wanted the shell gives it an address. The + * address bar changes without a load, which is where the pop-outs read their + * arguments from, and the right one renders in a window that's already up. + */ +export function PopoutSpareApp() { + const [target, setTarget] = useState(null); + + useEffect(() => { + const spare = getDesktopBridge()?.windows?.spare; + if (!spare) { + // Someone typed the address, or a browser followed a link. + window.location.replace(withBasePath("/")); + return; + } + const stop = spare.onActivate((url) => { + const next = new URL(url, window.location.href); + window.history.replaceState(null, "", `${next.pathname}${next.search}`); + setTarget(next); + spare.taken(); + }); + spare.ready(); + return stop; + }, []); + + if (!target) return
; + if (/\/popout\/compose\/?$/.test(target.pathname)) return ; + return ; +} diff --git a/components/settings/desktop-window-settings.tsx b/components/settings/desktop-window-settings.tsx new file mode 100644 index 000000000..3d604728c --- /dev/null +++ b/components/settings/desktop-window-settings.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { hasDesktopWindows } from "@/lib/popout/support"; +import { setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences"; +import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; + +/** How the desktop app's windows behave. Only there: a browser has nothing to set. */ +export function DesktopWindowSettings() { + const t = useTranslations("desktopUi.settings"); + const prefs = useDesktopPrefs(); + const [shown, setShown] = useState(false); + useEffect(() => setShown(hasDesktopWindows()), []); + if (!shown) return null; + return ( + + + setDesktopPref("openOnDoubleClick", checked)} /> + + + setDesktopPref("restoreWindows", checked)} /> + + + ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index 41d2ba072..7dc969209 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -9,6 +9,7 @@ import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; import { PlayCircle } from '@/components/icons'; import { IS_LITE } from '@/lib/lite'; +import { DesktopWindowSettings } from './desktop-window-settings'; /** * The General page: language and time, which links the browser hands to @@ -26,6 +27,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea {!IS_LITE && } + )}
+ {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); +} From fb0eeca9c50b5a9d49fe1f0d189436d316df78d6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:49:25 +0100 Subject: [PATCH 3/3] fix: put the focus on the desktop menu strip once its names are on screen --- components/layout/window-title-bar.tsx | 18 +++-- lib/__tests__/window-title-bar.test.tsx | 95 ++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/components/layout/window-title-bar.tsx b/components/layout/window-title-bar.tsx index df2f7d335..c2fd2ce84 100644 --- a/components/layout/window-title-bar.tsx +++ b/components/layout/window-title-bar.tsx @@ -25,6 +25,8 @@ export function WindowTitleBar() { const [entries, setEntries] = useState(null); const stripRef = useRef(null); const previousFocus = useRef(null); + // What to do once the names are on screen: open that menu, or null to focus the first name. + const afterShow = useRef(undefined); const openMenu = useCallback((id: string) => { const button = stripRef.current?.querySelector(`[data-menu-id="${CSS.escape(id)}"]`); @@ -45,15 +47,19 @@ export function WindowTitleBar() { } void bar.entries().then((list) => { previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null; + afterShow.current = event.open ?? 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(() => { + const next = afterShow.current; + if (!entries || next === undefined) return; + afterShow.current = undefined; + if (next) openMenu(next); + else stripRef.current?.querySelector("button")?.focus(); + }, [entries, openMenu]); useEffect(() => watchTitlebarArea((next) => { applyTitlebarInset(next); diff --git a/lib/__tests__/window-title-bar.test.tsx b/lib/__tests__/window-title-bar.test.tsx index b0d4bb11b..e785ffb33 100644 --- a/lib/__tests__/window-title-bar.test.tsx +++ b/lib/__tests__/window-title-bar.test.tsx @@ -1,7 +1,7 @@ -import { act, cleanup, render } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { WindowTitleBar } from '@/components/layout/window-title-bar'; import { TITLEBAR_INSET_VAR, readTitlebarArea } from '@/lib/window-controls-overlay'; @@ -30,6 +30,97 @@ afterEach(() => { document.documentElement.style.removeProperty(TITLEBAR_INSET_VAR); }); +describe('the menu strip in the desktop app', () => { + type BarEvent = { visible: boolean; open?: string }; + + function withBar() { + let send: (event: BarEvent) => void = () => {}; + const open = vi.fn(); + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + menu: { + setState: () => {}, + bar: { + entries: async () => [{ id: 'file', label: 'File', mnemonic: 'f' }, { id: 'edit', label: 'Edit', mnemonic: 'e' }], + open, + onEvent: (listener: (event: BarEvent) => void) => { + send = listener; + return () => {}; + }, + }, + }, + } as never; + return { open, send: (event: BarEvent) => send(event) }; + } + + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + const overlayOn = () => { + Object.defineProperty(navigator, 'windowControlsOverlay', { + configurable: true, + value: Object.assign(new EventTarget(), { visible: true, getTitlebarAreaRect: () => ({ x: 0, y: 0, width: 800, height: 36 }) }), + }); + }; + + afterEach(() => { + delete (navigator as unknown as { windowControlsOverlay?: unknown }).windowControlsOverlay; + }); + + it('Alt shows the names with the focus on the first, and Escape gives it back', async () => { + overlayOn(); + const bar = withBar(); + const before = document.createElement('button'); + document.body.appendChild(before); + before.focus(); + render(); + await act(async () => bar.send({ visible: true })); + const file = await screen.findByRole('menuitem', { name: 'File' }); + await waitFor(() => expect(document.activeElement).toBe(file)); + fireEvent.keyDown(file, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Edit' })); + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + expect(screen.queryByRole('menubar')).toBeNull(); + expect(document.activeElement).toBe(before); + before.remove(); + }); + + it('a name opens that menu natively', async () => { + overlayOn(); + const bar = withBar(); + render(); + await act(async () => bar.send({ visible: true })); + fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit' })); + expect(bar.open).toHaveBeenCalledWith('edit', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) })); + }); + + it('Alt and a letter opens that one right away', async () => { + overlayOn(); + const bar = withBar(); + render(); + await act(async () => bar.send({ visible: true, open: 'file' })); + await waitFor(() => expect(bar.open).toHaveBeenCalledWith('file', expect.anything())); + }); + + it('the shell can take it away again', async () => { + overlayOn(); + const bar = withBar(); + render(); + await act(async () => bar.send({ visible: true })); + await screen.findByRole('menubar'); + await act(async () => bar.send({ visible: false })); + expect(screen.queryByRole('menubar')).toBeNull(); + }); + + it('with no bridge there is none, and the title stays', async () => { + overlayOn(); + render(); + expect(screen.queryByRole('menubar')).toBeNull(); + }); +}); + describe('the title bar', () => { it('shows nothing and touches nothing without the API', () => { installOverlay(undefined);