From ff0e16c755cf71f8c1c28b84f819953cd0f7328f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:20:02 +0100 Subject: [PATCH 1/4] feat: show the desktop app's menu bar as a row at the top on Alt, or always --- app/globals.css | 15 + components/layout/window-menu-bar.tsx | 503 +++++++++++++++--- .../settings/desktop-window-settings.tsx | 16 + lib/__tests__/window-controls-host.test.tsx | 203 ++++++- lib/desktop/boot.ts | 11 +- lib/desktop/languages.ts | 12 + lib/desktop/menu-bar.ts | 79 +++ lib/desktop/types.ts | 30 ++ locales/ar/common.json | 12 + locales/ca/common.json | 12 + locales/cs/common.json | 12 + locales/da/common.json | 12 + locales/de/common.json | 12 + locales/en/common.json | 12 + locales/es/common.json | 12 + locales/et/common.json | 12 + locales/fa/common.json | 12 + locales/fr/common.json | 12 + locales/he/common.json | 12 + locales/hu/common.json | 12 + locales/it/common.json | 12 + locales/ja/common.json | 12 + locales/ko/common.json | 12 + locales/lv/common.json | 12 + locales/mn/common.json | 12 + locales/nb/common.json | 12 + locales/nl/common.json | 12 + locales/pl/common.json | 12 + locales/pt/common.json | 12 + locales/ro/common.json | 12 + locales/ru/common.json | 12 + locales/sk/common.json | 12 + locales/th/common.json | 12 + locales/tr/common.json | 12 + locales/uk/common.json | 12 + locales/vi/common.json | 12 + locales/zh-TW/common.json | 12 + locales/zh/common.json | 12 + 38 files changed, 1141 insertions(+), 88 deletions(-) create mode 100644 lib/desktop/menu-bar.ts diff --git a/app/globals.css b/app/globals.css index b9aee76aa..2dc1df83f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -629,6 +629,21 @@ html[data-window-controls] [data-window-row-left] { padding-left: calc(var(--titlebar-left-inset) + 0.5rem) !important; } +/* + * The desktop app's menu bar while it takes its row (components/layout/window-menu-bar.tsx). + * The app moves down under it, a page that fills the window starts below it, and the + * window's controls sit in the bar's row, so no column below makes room for them. + */ +html[data-menu-bar] { + --menu-bar-height: 2rem; + --app-top-inset: calc(env(safe-area-inset-top, 0px) + var(--menu-bar-height)); + --titlebar-inset: var(--menu-bar-height); +} + +html[data-menu-bar][data-window-controls] [data-window-end-column] { + padding-top: 0; +} + /* * The empty parts of the app's top rows are what the window is dragged by, and a * double-click there maximises. Anything to click stays clickable, and so does diff --git a/components/layout/window-menu-bar.tsx b/components/layout/window-menu-bar.tsx index 371cead30..548f18275 100644 --- a/components/layout/window-menu-bar.tsx +++ b/components/layout/window-menu-bar.tsx @@ -1,99 +1,478 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; +import { Check, ChevronRight } from "@/components/icons"; import { getDesktopBridge } from "@/lib/desktop/bridge"; -import type { DesktopMenuBarEntry } from "@/lib/desktop/types"; +import { MENU_BAR_ATTRIBUTE, cachedMenuBarPinned, menuKeyText, rememberMenuBarPinned } from "@/lib/desktop/menu-bar"; +import type { DesktopMenuBarEntry, DesktopMenuBarItem, DesktopMenuBarMenu } from "@/lib/desktop/types"; +import { watchTitlebarArea } from "@/lib/window-controls-overlay"; +import { cn } from "@/lib/utils"; /** - * Alt floats the menu's names over the top left for a moment, since a window with no - * frame has no native bar. Each name opens the app's menu as a native one - * (src/main/menu.ts in the desktop repo). Dismissed, nothing is left of it. + * The desktop app's menu bar where the window's controls sit over the page (Windows and + * Linux), as Thunderbird has it. Alt shows it as a row at the top that moves the app down + * under it, and the window's controls shrink into that row; "Always show the menu bar" + * keeps it. Alt, Escape or a click elsewhere hides it again. Alt and a letter opens that + * menu, with the letters underlined while the keys are on the bar, and the arrows go + * between menus and lines. The menus are the shell's own (src/main/menu.ts in the desktop + * repo), with what's enabled and ticked now, and a pick runs the very item the native + * menu has. macOS keeps its system bar. + * + * A shell from before this gives only the names, and opens each menu natively. */ + +const LINE = "[data-menu-line]"; +const ENABLED_LINE = `${LINE}:not([aria-disabled="true"])`; + +type Focus = { kind: "name"; index: number } | { kind: "line" | "panel"; sub?: boolean }; + +/** The label with its letter underlined, while the keys are on the bar. */ +function Label({ text, at, cue }: { text: string; at: number | undefined; cue: boolean }) { + if (!cue || at === undefined || at < 0 || at >= text.length) return <>{text}>; + return ( + <> + {text.slice(0, at)} + {text[at]} + {text.slice(at + 1)} + > + ); +} + +function nameLetterAt(entry: DesktopMenuBarEntry): number | undefined { + if (!entry.mnemonic) return undefined; + const at = entry.label.toLowerCase().indexOf(entry.mnemonic); + return at >= 0 ? at : undefined; +} + +function letterOf(item: DesktopMenuBarItem): string | undefined { + return item.mnemonic !== undefined ? item.label[item.mnemonic]?.toLowerCase() : undefined; +} + +function usable(item: DesktopMenuBarItem | undefined): item is DesktopMenuBarItem { + return !!item && item.type !== "separator" && item.enabled; +} + +const EDGE = 4; + +/** + * A menu that would leave the window moves back in. A submenu sits beside its line, on the + * reading side, or the other side where there's no room, and never below the window. + */ +function keepInside(root: HTMLElement | null): void { + if (!root) return; + const width = document.documentElement.clientWidth; + const height = document.documentElement.clientHeight; + const panel = root.querySelector("[data-menu-panel]"); + if (panel) { + panel.style.translate = ""; + const rect = panel.getBoundingClientRect(); + if (rect.right > width - EDGE) panel.style.translate = `${Math.round(width - EDGE - rect.right)}px 0`; + else if (rect.left < EDGE) panel.style.translate = `${Math.round(EDGE - rect.left)}px 0`; + } + const subpanel = root.querySelector("[data-menu-subpanel]"); + const line = panel?.querySelector(`${LINE}[aria-expanded="true"]`); + if (!panel || !subpanel || !line) return; + const outer = panel.getBoundingClientRect(); + const at = line.getBoundingClientRect(); + const own = subpanel.getBoundingClientRect(); + const rtl = getComputedStyle(root).direction === "rtl"; + const after = outer.right - EDGE; + const before = outer.left + EDGE - own.width; + const fitsAfter = after + own.width <= width - EDGE; + const fitsBefore = before >= EDGE; + const left = rtl ? (fitsBefore ? before : after) : fitsAfter || !fitsBefore ? Math.min(after, width - EDGE - own.width) : before; + subpanel.style.left = `${Math.round(Math.max(EDGE, left))}px`; + subpanel.style.top = `${Math.round(Math.max(EDGE, Math.min(at.top - EDGE, height - EDGE - own.height)))}px`; +} + export function WindowMenuBar() { const t = useTranslations("desktopUi.menu"); - const [entries, setEntries] = useState(null); - const stripRef = useRef(null); + const [desktop, setDesktop] = useState(false); + const [hasOverlay, setHasOverlay] = useState(false); + const [pinned, setPinned] = useState(false); + /** The keys are on the bar: Alt was pressed, or a menu was opened. */ + const [active, setActive] = useState(false); + const [menus, setMenus] = useState(null); + const [open, setOpen] = useState(null); + /** The line of the open menu whose submenu is open. */ + const [sub, setSub] = useState(null); + /** Underlined letters: the keys brought the bar, not the mouse. */ + const [cue, setCue] = useState(false); + const barRef = 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 pendingFocus = 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 }); - }, []); - - const dismiss = useCallback(() => { - setEntries(null); - previousFocus.current?.focus?.(); - previousFocus.current = null; - }, []); + const shown = desktop && hasOverlay && (pinned || active); + const drawsMenus = desktop && !!getDesktopBridge()?.menu?.bar?.menus; useEffect(() => { + const bridge = getDesktopBridge(); + if (!bridge?.menu?.bar || bridge.platform === "darwin") return; + setDesktop(true); + setPinned(cachedMenuBarPinned()); + return watchTitlebarArea((area) => setHasOverlay(area !== null)); + }, []); + + const refresh = useCallback(async (): Promise => { + const bar = getDesktopBridge()?.menu?.bar; + if (!bar) return null; + const list = bar.menus + ? await bar.menus().catch(() => null) + : await bar.entries().then((entries) => entries.map((entry) => ({ ...entry, items: [] }))).catch(() => null); + if (list) setMenus(list); + return list; + }, []); + + const rememberFocus = useCallback(() => { + const now = document.activeElement; + if (now instanceof HTMLElement && now !== document.body && !barRef.current?.contains(now)) previousFocus.current = now; + }, []); + + /** Back to how it was before Alt: the menus shut, the keys back where they were. */ + const deactivate = useCallback(() => { + setOpen(null); + setSub(null); + setActive(false); + setCue(false); + const back = previousFocus.current; + previousFocus.current = null; + if (barRef.current?.contains(document.activeElement)) { + if (back?.isConnected) back.focus({ preventScroll: true }); + else (document.activeElement as HTMLElement | null)?.blur(); + } + }, []); + + // The shell: Alt, Alt and a letter, the window losing the focus, the setting. + useEffect(() => { + if (!desktop) return; const bar = getDesktopBridge()?.menu?.bar; if (!bar) return; - return bar.onEvent((event) => { + const stop = bar.onEvent((event) => { + if (typeof event.pinned === "boolean") { + setPinned(event.pinned); + rememberMenuBarPinned(event.pinned); + } if (!event.visible) { - dismiss(); + deactivate(); return; } - void bar.entries().then((list) => { - previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null; - afterShow.current = event.open ?? null; - setEntries(list); + rememberFocus(); + setActive(true); + setCue(true); + setSub(null); + // Set now: the names may be drawn before the menus arrive, and take it then. + pendingFocus.current = event.open && bar.menus ? { kind: "line" } : { kind: "name", index: 0 }; + void refresh().then((list) => { + const index = event.open ? (list ?? []).findIndex((menu) => menu.id === event.open) : -1; + if (index >= 0 && bar.menus) setOpen(index); + else setOpen(null); }); }); - }, [dismiss]); + // Listening now: the shell answers with whether the bar is always there. + bar.report?.({ height: 0, active: false }); + return stop; + }, [desktop, refresh, deactivate, rememberFocus]); + // An always-shown bar has its names from the start. 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]); + if (shown && !menus) void refresh(); + }, [shown, menus, refresh]); - if (!entries) return null; + // The row it takes: everything else moves down under it, and the controls fit into it. + useLayoutEffect(() => { + if (!desktop) return; + document.documentElement.toggleAttribute(MENU_BAR_ATTRIBUTE, shown); + const height = shown ? Math.round(barRef.current?.getBoundingClientRect().height ?? 0) : 0; + getDesktopBridge()?.menu?.bar?.report?.({ height, active }); + }, [desktop, shown, active]); - 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") { + useEffect(() => () => document.documentElement.removeAttribute(MENU_BAR_ATTRIBUTE), []); + + // Menus stay in the window; the focus goes where the keys asked, once it's drawn. + useLayoutEffect(() => { + const root = barRef.current; + keepInside(root); + const want = pendingFocus.current; + if (!want || !root) return; + let target: HTMLElement | null = null; + if (want.kind === "name") { + target = root.querySelectorAll("[data-menu-name]")[want.index] ?? null; + } else { + const panel = root.querySelector(want.sub ? "[data-menu-subpanel]" : "[data-menu-panel]"); + target = want.kind === "panel" ? panel : panel?.querySelector(ENABLED_LINE) ?? panel ?? null; + } + if (!target) return; + pendingFocus.current = null; + target.focus({ preventScroll: true }); + }); + + // A click anywhere else puts it all away. + useEffect(() => { + if (!active && open === null) return; + const onDown = (event: PointerEvent) => { + if (barRef.current?.contains(event.target as Node)) return; + deactivate(); + }; + document.addEventListener("pointerdown", onDown, true); + return () => document.removeEventListener("pointerdown", onDown, true); + }, [active, open, deactivate]); + + const openMenu = useCallback((index: number, focus: "line" | "panel") => { + if (!drawsMenus) { + const entry = menus?.[index]; + const button = barRef.current?.querySelectorAll("[data-menu-name]")[index]; + if (!entry || !button) return; + const rect = button.getBoundingClientRect(); + getDesktopBridge()?.menu?.bar?.open(entry.id, { x: rect.left, y: rect.bottom }); + return; + } + setActive(true); + setOpen(index); + setSub(null); + pendingFocus.current = { kind: focus }; + // What's enabled may have changed since the bar showed. + void refresh(); + }, [drawsMenus, menus, refresh]); + + const run = useCallback((item: DesktopMenuBarItem) => { + if (!usable(item)) return; + // Cut, Copy and Paste work on what had the focus, so it has it back first. + deactivate(); + getDesktopBridge()?.menu?.bar?.run?.(item.key, item.label); + }, [deactivate]); + + if (!shown) return null; + + const list = menus ?? []; + const openItems = open !== null ? list[open]?.items ?? [] : []; + const subItems = sub !== null ? openItems[sub]?.items ?? [] : []; + + const names = () => Array.from(barRef.current?.querySelectorAll("[data-menu-name]") ?? []); + const linesIn = (selector: string) => Array.from(barRef.current?.querySelector(selector)?.querySelectorAll(ENABLED_LINE) ?? []); + + const step = (lines: HTMLElement[], delta: number) => { + if (lines.length === 0) return; + const at = lines.indexOf(document.activeElement as HTMLElement); + const next = at < 0 ? (delta > 0 ? 0 : lines.length - 1) : (at + delta + lines.length) % lines.length; + lines[next]?.focus({ preventScroll: true }); + }; + + const openSub = (index: number) => { + setSub(index); + pendingFocus.current = { kind: "line", sub: true }; + }; + + const closeSub = () => { + const parent = sub; + setSub(null); + barRef.current?.querySelector(`[data-menu-panel] [data-menu-line="${parent}"]`)?.focus({ preventScroll: true }); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + const target = event.target as HTMLElement; + const rtl = getComputedStyle(barRef.current ?? document.documentElement).direction === "rtl"; + const forward = rtl ? "ArrowLeft" : "ArrowRight"; + const backward = rtl ? "ArrowRight" : "ArrowLeft"; + const inSub = !!target.closest("[data-menu-subpanel]"); + const inMenu = inSub || !!target.closest("[data-menu-panel]"); + const key = event.key; + const plainLetter = key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey; + const take = () => { 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(); - dismiss(); + event.stopPropagation(); + }; + setCue(true); + + if (key === "Tab") { + take(); + deactivate(); + return; + } + + if (!inMenu) { + const at = names().indexOf(target); + if (key === forward || key === backward) { + take(); + step(names(), key === forward ? 1 : -1); + } else if (key === "ArrowDown" || key === "Enter" || key === " ") { + take(); + if (at >= 0) openMenu(at, "line"); + } else if (key === "Escape") { + take(); + deactivate(); + } else if (plainLetter) { + const index = list.findIndex((menu) => menu.mnemonic === key.toLowerCase()); + if (index >= 0) { + take(); + openMenu(index, "line"); + } + } + return; + } + + const items = inSub ? subItems : openItems; + const lineIndex = Number(target.closest(LINE)?.dataset.menuLine); + const current = Number.isInteger(lineIndex) ? items[lineIndex] : undefined; + const neighbour = (delta: number) => { + if (open !== null && list.length) openMenu((open + delta + list.length) % list.length, "line"); + }; + + if (key === "ArrowDown" || key === "ArrowUp") { + take(); + step(linesIn(inSub ? "[data-menu-subpanel]" : "[data-menu-panel]"), key === "ArrowDown" ? 1 : -1); + } else if (key === "Home" || key === "End") { + take(); + const lines = linesIn(inSub ? "[data-menu-subpanel]" : "[data-menu-panel]"); + lines[key === "Home" ? 0 : lines.length - 1]?.focus({ preventScroll: true }); + } else if (key === forward) { + take(); + if (!inSub && usable(current) && current.type === "submenu") openSub(lineIndex); + else neighbour(1); + } else if (key === backward) { + take(); + if (inSub) closeSub(); + else neighbour(-1); + } else if (key === "Enter" || key === " ") { + take(); + if (!usable(current)) return; + if (current.type === "submenu") openSub(lineIndex); + else run(current); + } else if (key === "Escape") { + take(); + if (inSub) { + closeSub(); + } else { + const index = open ?? 0; + setOpen(null); + names()[index]?.focus({ preventScroll: true }); + } + } else if (plainLetter) { + const index = items.findIndex((item) => usable(item) && letterOf(item) === key.toLowerCase()); + if (index < 0) return; + take(); + const item = items[index]; + if (item.type === "submenu" && !inSub) openSub(index); + else run(item); } }; + const renderLines = (items: DesktopMenuBarItem[], level: "panel" | "subpanel") => + items.map((item, index) => { + if (item.type === "separator") return ; + const isSub = item.type === "submenu"; + const checkable = item.type === "checkbox" || item.type === "radio"; + const opened = level === "panel" && isSub && sub === index; + return ( + + { + if (level === "panel") setSub(isSub && item.enabled ? index : null); + if (item.enabled) event.currentTarget.focus({ preventScroll: true }); + }} + onClick={() => { + if (!item.enabled) return; + if (isSub) openSub(index); + else run(item); + }} + className={cn( + "flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm outline-none", + item.enabled ? "focus:bg-muted" : "cursor-default text-muted-foreground/60", + opened && "bg-muted", + )} + > + + {checkable && item.checked && (item.type === "radio" ? : )} + + + + + {item.accelerator && ( + + {menuKeyText(item.accelerator, getDesktopBridge()?.platform)} + + )} + {isSub && } + + + ); + }); + return ( - {entries.map((entry) => ( - openMenu(entry.id)} - className="rounded px-2.5 py-1 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none" - > - {entry.label} - + {list.map((menu, index) => ( + + { + if (event.button !== 0) return; + // The focus stays put until a menu takes it: Cut and Paste need to know where it was. + event.preventDefault(); + rememberFocus(); + setCue(false); + if (open === index) deactivate(); + else openMenu(index, "panel"); + }} + onClick={(event) => { + // Enter and Space; the pointer was handled on the way down. + if (event.detail === 0) openMenu(index, "line"); + }} + onMouseEnter={() => { + if (open !== null && open !== index) openMenu(index, "panel"); + }} + className={cn( + "flex h-7 items-center rounded px-2.5 outline-none hover:bg-muted focus-visible:bg-muted", + open === index && "bg-muted", + )} + > + + + {open === index && drawsMenus && ( + + {renderLines(openItems, "panel")} + + )} + {open === index && drawsMenus && sub !== null && ( + + {renderLines(subItems, "subpanel")} + + )} + ))} ); diff --git a/components/settings/desktop-window-settings.tsx b/components/settings/desktop-window-settings.tsx index 3d604728c..863772a64 100644 --- a/components/settings/desktop-window-settings.tsx +++ b/components/settings/desktop-window-settings.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { hasDesktopWindows } from "@/lib/popout/support"; +import { menuBarAvailable, useMenuBarPinned } from "@/lib/desktop/menu-bar"; import { setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences"; import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; @@ -21,6 +22,21 @@ export function DesktopWindowSettings() { setDesktopPref("restoreWindows", checked)} /> + ); } + +/** "Always show the menu bar", as in the View menu. Windows and Linux: macOS has the system's. */ +function MenuBarSetting() { + const t = useTranslations("desktopMenuBar"); + const [available, setAvailable] = useState(false); + const [pinned, setPinned] = useMenuBarPinned(); + useEffect(() => setAvailable(menuBarAvailable()), []); + if (!available) return null; + return ( + + + + ); +} diff --git a/lib/__tests__/window-controls-host.test.tsx b/lib/__tests__/window-controls-host.test.tsx index b4d6f8673..ee7075b60 100644 --- a/lib/__tests__/window-controls-host.test.tsx +++ b/lib/__tests__/window-controls-host.test.tsx @@ -1,9 +1,11 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { WindowControlsHost } from '@/components/layout/window-controls-host'; import { WindowMenuBar } from '@/components/layout/window-menu-bar'; +import { menuKeyText } from '@/lib/desktop/menu-bar'; +import { desktopBootScript } from '@/lib/desktop/boot'; import { CONTROLS_ATTRIBUTE, CONTROLS_LEFT_ATTRIBUTE, ROW_ATTRIBUTE, ROW_LEFT_ATTRIBUTE, ROW_RIGHT_ATTRIBUTE, TITLEBAR_RIGHT_VAR, TITLEBAR_ROW_VAR, markWindowRows, readTitlebarArea, watchWindowRows } from '@/lib/window-controls-overlay'; class FakeOverlay extends EventTarget { @@ -34,32 +36,56 @@ afterEach(() => { root.removeAttribute(CONTROLS_LEFT_ATTRIBUTE); }); -describe('the menu strip in the desktop app', () => { - type BarEvent = { visible: boolean; open?: string }; +describe('the menu bar in the desktop app', () => { + type BarEvent = { visible: boolean; open?: string; pinned?: boolean }; + type Line = { key: string; label: string; type: string; enabled: boolean; checked?: boolean; accelerator?: string; mnemonic?: number; items?: Line[] }; - function withBar() { + const line = (key: string, label: string, extra: Partial = {}): Line => ({ key, label, type: 'normal', enabled: true, mnemonic: 0, ...extra }); + const MENUS = [ + { id: 'file', label: 'File', mnemonic: 'f', items: [ + line('0/0', 'New message', { accelerator: 'CmdOrCtrl+N' }), + line('0/1', 'Print…', { enabled: false, mnemonic: 0 }), + { key: '0/2', label: '', type: 'separator', enabled: false }, + line('0/3', 'Exit', { mnemonic: 1, accelerator: 'Ctrl+Q' }), + ] }, + { id: 'view', label: 'View', mnemonic: 'v', items: [ + line('1/0', 'Reading pane', { type: 'submenu', items: [line('1/0/0', 'On the right', { type: 'radio', checked: true }), line('1/0/1', 'Off', { type: 'radio', mnemonic: 1 })] }), + line('1/1', 'Always show the menu bar', { type: 'checkbox', checked: false }), + line('1/2', 'Zoom in', { mnemonic: 0, accelerator: 'CmdOrCtrl+Plus' }), + ] }, + { id: 'help', label: 'Help', mnemonic: 'h', items: [line('2/0', 'Keyboard shortcuts', { mnemonic: 0, accelerator: '?' })] }, + ]; + + function withBar(options: { legacy?: boolean; platform?: string } = {}) { let send: (event: BarEvent) => void = () => {}; const open = vi.fn(); + const run = vi.fn(); + const report = vi.fn(); + const setPinned = vi.fn(); window.bulwarkDesktop = { version: 1, - platform: 'win32', + platform: options.platform ?? 'win32', menu: { setState: () => {}, bar: { - entries: async () => [{ id: 'file', label: 'File', mnemonic: 'f' }, { id: 'edit', label: 'Edit', mnemonic: 'e' }], + entries: async () => MENUS.map(({ id, label, mnemonic }) => ({ id, label, mnemonic })), open, onEvent: (listener: (event: BarEvent) => void) => { send = listener; return () => {}; }, + ...(options.legacy ? {} : { menus: async () => MENUS, run, report, setPinned }), }, }, } as never; - return { open, send: (event: BarEvent) => send(event) }; + return { open, run, report, setPinned, send: (event: BarEvent) => act(async () => send(event)) }; } afterEach(() => { window.bulwarkDesktop = undefined; + document.documentElement.removeAttribute('data-menu-bar'); + document.documentElement.removeAttribute('data-desktop'); + localStorage.removeItem('bulwark-menu-bar'); }); const overlayOn = () => { @@ -73,55 +99,184 @@ describe('the menu strip in the desktop app', () => { 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'); + const typed = () => { + const before = document.createElement('input'); document.body.appendChild(before); before.focus(); + return before; + }; + + it('Alt shows a row that moves the app down, with the keys on the first name, and Escape gives them back', async () => { + overlayOn(); + const bar = withBar(); + const before = typed(); render(); - await act(async () => bar.send({ visible: true })); + await bar.send({ visible: true }); const file = await screen.findByRole('menuitem', { name: 'File' }); await waitFor(() => expect(document.activeElement).toBe(file)); + expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(true); + expect(screen.getByRole('menubar').hasAttribute('data-window-top-row')).toBe(true); + expect(bar.report).toHaveBeenLastCalledWith(expect.objectContaining({ active: true })); + // The letter that opens it is underlined while the keys are on the bar. + expect(file.querySelector('.underline')?.textContent).toBe('F'); fireEvent.keyDown(file, { key: 'ArrowRight' }); - expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Edit' })); + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'View' })); fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); expect(screen.queryByRole('menubar')).toBeNull(); + expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(false); + expect(bar.report).toHaveBeenLastCalledWith({ height: 0, active: false }); + expect(document.activeElement).toBe(before); + before.remove(); + }); + + it('draws the menu with its keys apart from the names, skips what is off, and runs a line where the keys were', async () => { + overlayOn(); + const bar = withBar(); + const before = typed(); + render(); + await bar.send({ visible: true }); + const file = await screen.findByRole('menuitem', { name: 'File' }); + await waitFor(() => expect(document.activeElement).toBe(file)); + fireEvent.keyDown(file, { key: 'ArrowDown' }); + const menu = await screen.findByRole('menu', { name: 'File' }); + const newMessage = within(menu).getByRole('menuitem', { name: /New message/ }); + await waitFor(() => expect(document.activeElement).toBe(newMessage)); + expect(newMessage.querySelector('[data-menu-label]')?.textContent).toBe('New message'); + expect(newMessage.querySelector('[data-menu-keys]')?.textContent).toBe('Ctrl+N'); + expect(within(menu).getByRole('menuitem', { name: /Print/ }).getAttribute('aria-disabled')).toBe('true'); + // Down skips the line that's off. + fireEvent.keyDown(newMessage, { key: 'ArrowDown' }); + expect(document.activeElement?.textContent).toContain('Exit'); + fireEvent.keyDown(document.activeElement!, { key: 'Enter' }); + expect(bar.run).toHaveBeenCalledWith('0/3', 'Exit'); + expect(screen.queryByRole('menubar')).toBeNull(); expect(document.activeElement).toBe(before); before.remove(); }); - it('a name opens that menu natively', async () => { + it('the arrows go across to the next menu and into a submenu', 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) })); + await bar.send({ visible: true, open: 'file' }); + const menu = await screen.findByRole('menu', { name: 'File' }); + await waitFor(() => expect(menu.contains(document.activeElement)).toBe(true)); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' }); + const view = await screen.findByRole('menu', { name: 'View' }); + await waitFor(() => expect(document.activeElement?.textContent).toContain('Reading pane')); + expect(screen.queryByRole('menu', { name: 'File' })).toBeNull(); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' }); + const sub = await screen.findByRole('menu', { name: 'Reading pane' }); + await waitFor(() => expect(sub.contains(document.activeElement)).toBe(true)); + expect(within(sub).getByRole('menuitemradio', { name: /On the right/ }).getAttribute('aria-checked')).toBe('true'); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' }); + expect(screen.queryByRole('menu', { name: 'Reading pane' })).toBeNull(); + expect(document.activeElement?.textContent).toContain('Reading pane'); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' }); + await screen.findByRole('menu', { name: 'File' }); + expect(view.isConnected).toBe(false); + // Escape closes the menu and leaves the keys on its name. + await waitFor(() => expect(screen.getByRole('menu', { name: 'File' }).contains(document.activeElement)).toBe(true)); + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + expect(screen.queryByRole('menu')).toBeNull(); + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'File' })); }); - it('Alt and a letter opens that one right away', async () => { + it('Alt and a letter opens that menu, and a line\'s letter runs it', async () => { overlayOn(); const bar = withBar(); render(); - await act(async () => bar.send({ visible: true, open: 'file' })); - await waitFor(() => expect(bar.open).toHaveBeenCalledWith('file', expect.anything())); + await bar.send({ visible: true, open: 'view' }); + const view = await screen.findByRole('menu', { name: 'View' }); + await waitFor(() => expect(view.contains(document.activeElement)).toBe(true)); + fireEvent.keyDown(document.activeElement!, { key: 'z' }); + expect(bar.run).toHaveBeenCalledWith('1/2', 'Zoom in'); + }); + + it('a click elsewhere puts it away', async () => { + overlayOn(); + const bar = withBar(); + render(); + await bar.send({ visible: true }); + fireEvent.pointerDown(await screen.findByRole('menuitem', { name: 'Help' }), { button: 0 }); + await screen.findByRole('menu', { name: 'Help' }); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole('menubar')).toBeNull(); + }); + + it('always shown, it is there without Alt and takes no keys, and a click on a name opens that menu', async () => { + overlayOn(); + const bar = withBar(); + const before = typed(); + render(); + await bar.send({ visible: false, pinned: true }); + const help = await screen.findByRole('menuitem', { name: 'Help' }); + expect(document.activeElement).toBe(before); + expect(help.querySelector('.underline')).toBeNull(); + expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(true); + expect(localStorage.getItem('bulwark-menu-bar')).toBe('pinned'); + fireEvent.pointerDown(help, { button: 0 }); + const menu = await screen.findByRole('menu', { name: 'Help' }); + fireEvent.click(within(menu).getByRole('menuitem', { name: /Keyboard shortcuts/ })); + expect(bar.run).toHaveBeenCalledWith('2/0', 'Keyboard shortcuts'); + // Still there: it's always shown. + expect(screen.getByRole('menubar')).toBeTruthy(); + expect(document.activeElement).toBe(before); + await bar.send({ visible: false, pinned: false }); + expect(screen.queryByRole('menubar')).toBeNull(); + expect(localStorage.getItem('bulwark-menu-bar')).toBeNull(); + before.remove(); + }); + + it('an older shell opens the menu natively', async () => { + overlayOn(); + const bar = withBar({ legacy: true }); + render(); + await bar.send({ visible: true }); + fireEvent.pointerDown(await screen.findByRole('menuitem', { name: 'View' }), { button: 0 }); + expect(bar.open).toHaveBeenCalledWith('view', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) })); }); it('the shell can take it away again', async () => { overlayOn(); const bar = withBar(); render(); - await act(async () => bar.send({ visible: true })); + await bar.send({ visible: true }); await screen.findByRole('menubar'); - await act(async () => bar.send({ visible: false })); + await bar.send({ visible: false }); expect(screen.queryByRole('menubar')).toBeNull(); }); - it('with no bridge there is none, and the title stays', async () => { + it('with no bridge there is none, and none on a Mac, which has the system bar', async () => { overlayOn(); - render(); + const { unmount } = render(); expect(screen.queryByRole('menubar')).toBeNull(); + unmount(); + const bar = withBar({ platform: 'darwin' }); + render(); + await bar.send({ visible: true }); + expect(screen.queryByRole('menubar')).toBeNull(); + }); + + it('an always-shown bar has its row from the first frame, before any script of the app', () => { + overlayOn(); + localStorage.setItem('bulwark-menu-bar', 'pinned'); + window.bulwarkDesktop = { version: 1, platform: 'darwin' } as never; + new Function(desktopBootScript())(); + expect(document.documentElement.hasAttribute('data-menu-bar'), 'a Mac has the system bar').toBe(false); + window.bulwarkDesktop = { version: 1, platform: 'win32' } as never; + new Function(desktopBootScript())(); + expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(true); + }); + + it('menu keys read the way Windows writes them', () => { + expect(menuKeyText('CmdOrCtrl+N', 'win32')).toBe('Ctrl+N'); + expect(menuKeyText('CmdOrCtrl+Plus', 'win32')).toBe('Ctrl++'); + expect(menuKeyText('Alt+Left', 'win32')).toBe('Alt+←'); + expect(menuKeyText('Shift+R', 'linux')).toBe('Shift+R'); + expect(menuKeyText('Delete', 'win32')).toBe('Del'); + expect(menuKeyText('?', 'win32')).toBe('?'); + expect(menuKeyText('CommandOrControl+Shift+Z', 'win32')).toBe('Ctrl+Shift+Z'); }); }); diff --git a/lib/desktop/boot.ts b/lib/desktop/boot.ts index a5652ccfe..a82615388 100644 --- a/lib/desktop/boot.ts +++ b/lib/desktop/boot.ts @@ -1,9 +1,16 @@ +/** What the boot script reads for "Always show the menu bar" (lib/desktop/menu-bar.ts). */ +export const MENU_BAR_STORAGE_KEY = 'bulwark-menu-bar'; +/** On while the menu bar takes its row: app/globals.css moves everything down under it. */ +export const MENU_BAR_ATTRIBUTE = 'data-menu-bar'; + /** * Runs in the inline boot script, before the first paint. The preload bridge * exists before any page script, so app/desktop.css holds from the first - * frame. Nothing happens in a browser, which has no bridge. + * frame. Nothing happens in a browser, which has no bridge. An always-shown + * menu bar has its row from the first frame too, so nothing jumps when it draws. */ export function desktopBootScript(): string { return `if (window.bulwarkDesktop) document.documentElement.setAttribute('data-desktop', ''); - if (navigator.windowControlsOverlay && navigator.windowControlsOverlay.visible) document.documentElement.setAttribute('data-window-controls', '');`; + if (navigator.windowControlsOverlay && navigator.windowControlsOverlay.visible) document.documentElement.setAttribute('data-window-controls', ''); + try { if (window.bulwarkDesktop && window.bulwarkDesktop.platform !== 'darwin' && navigator.windowControlsOverlay && navigator.windowControlsOverlay.visible && localStorage.getItem('${MENU_BAR_STORAGE_KEY}') === 'pinned') document.documentElement.setAttribute('${MENU_BAR_ATTRIBUTE}', ''); } catch (e) {}`; } diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts index 28945be18..a877ef3dd 100644 --- a/lib/desktop/languages.ts +++ b/lib/desktop/languages.ts @@ -128,6 +128,18 @@ export function shellLabels(t: (key: string, values?: Record) => menuDocs: t('desktopUi.menu.docs'), menuSettings: t('desktopUi.menu.settings'), menuAbout: t('desktopUi.menu.about'), + menuExit: t('desktopMenuBar.exit'), + menuShowMenuBar: t('desktopMenuBar.always_show_label'), + trayNewMessage: t('email_composer.new_message'), + trayLockNow: t('appLock.lock_now'), + trayUnread: t('email_list.sections.unread'), + trayPause: t('desktopMenuBar.tray_pause'), + trayPauseHour: t('desktopMenuBar.tray_pause_hour'), + trayPauseTomorrow: t('desktopMenuBar.tray_pause_tomorrow'), + trayResume: t('desktopMenuBar.tray_resume'), + trayOpen: t('desktopMenuBar.tray_open'), + trayQuit: t('desktopMenuBar.tray_quit'), + trayExit: t('desktopMenuBar.tray_exit'), contextCut: t('desktopUi.menu.cut'), contextCopy: t('desktopUi.menu.copy'), contextPaste: t('desktopUi.menu.paste'), diff --git a/lib/desktop/menu-bar.ts b/lib/desktop/menu-bar.ts new file mode 100644 index 000000000..5ac75b267 --- /dev/null +++ b/lib/desktop/menu-bar.ts @@ -0,0 +1,79 @@ +/** + * The desktop app's menu bar on Windows and Linux, where a window with its controls over + * the page has no native bar: the page draws the app menu as a row at the top + * (components/layout/window-menu-bar.tsx). Alt shows it, or "Always show the menu bar" + * keeps it. The shell owns that setting; this keeps a copy for the first frame. + */ + +import { useEffect, useState } from 'react'; +import { getDesktopBridge } from './bridge'; +import { describeAccelerator } from './shortcut-keys'; + +import { MENU_BAR_STORAGE_KEY } from './boot'; + +export { MENU_BAR_ATTRIBUTE, MENU_BAR_STORAGE_KEY } from './boot'; + +/** A shell that lets the page draw the bar and keep it shown. Never on macOS: it has the system's. */ +export function menuBarAvailable(): boolean { + const bridge = getDesktopBridge(); + return !!bridge?.menu?.bar?.setPinned && bridge.platform !== 'darwin'; +} + +export function cachedMenuBarPinned(): boolean { + try { + return localStorage.getItem(MENU_BAR_STORAGE_KEY) === 'pinned'; + } catch { + return false; + } +} + +export function rememberMenuBarPinned(pinned: boolean): void { + try { + if (pinned) localStorage.setItem(MENU_BAR_STORAGE_KEY, 'pinned'); + else localStorage.removeItem(MENU_BAR_STORAGE_KEY); + } catch { + // Private storage off: the bar still follows the shell, a frame late. + } +} + +/** "Always show the menu bar" as the shell has it, and a way to change it in every window. */ +export function useMenuBarPinned(): [boolean, (pinned: boolean) => void] { + const [pinned, setPinned] = useState(false); + useEffect(() => { + setPinned(cachedMenuBarPinned()); + const bar = getDesktopBridge()?.menu?.bar; + if (!bar) return; + return bar.onEvent((event) => { + if (typeof event.pinned === 'boolean') setPinned(event.pinned); + }); + }, []); + const change = (next: boolean) => { + setPinned(next); + rememberMenuBarPinned(next); + getDesktopBridge()?.menu?.bar?.setPinned?.(next); + }; + return [pinned, change]; +} + +const KEY_NAMES: Record = { + plus: '+', + delete: 'Del', + escape: 'Esc', + left: '←', + right: '→', + up: '↑', + down: '↓', + space: 'Space', +}; + +/** An Electron accelerator the way a Windows or Linux menu shows it: `Ctrl+Shift+N`, `Alt+←`, `?`. */ +export function menuKeyText(accelerator: string, platform: string | undefined): string { + // A key that is itself "+", as in Ctrl++, ends in an empty part. + const parts = accelerator.split('+'); + const last = parts[parts.length - 1] === '' ? '+' : parts[parts.length - 1]; + const key = KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last); + const modifiers = parts.slice(0, parts[parts.length - 1] === '' ? -2 : -1); + if (modifiers.length === 0) return key; + const named = describeAccelerator(`${modifiers.join('+')}+X`, platform); + return `${named.slice(0, -1)}${key}`; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 69cbd18c0..256433cf9 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -95,9 +95,31 @@ export interface DesktopMenuBarEntry { } export interface DesktopMenuBarEvent { + /** The bar has the keys: Alt was pressed. With `pinned` it shows either way. */ visible: boolean; /** Open this menu right away: Alt plus its letter. */ open?: string; + /** "Always show the menu bar". */ + pinned?: boolean; +} + +/** One line of a menu the page draws as the menu bar's. */ +export interface DesktopMenuBarItem { + /** Its place in the app menu: handed back with its text when it's picked. */ + key: string; + label: string; + type: 'normal' | 'checkbox' | 'radio' | 'separator' | 'submenu'; + enabled: boolean; + checked?: boolean; + /** As Electron writes it: `CmdOrCtrl+N`. */ + accelerator?: string; + /** Where in `label` the letter for it is. */ + mnemonic?: number; + items?: DesktopMenuBarItem[]; +} + +export interface DesktopMenuBarMenu extends DesktopMenuBarEntry { + items: DesktopMenuBarItem[]; } /** What a page answers when asked to save before a window goes. */ @@ -124,6 +146,14 @@ export interface DesktopMenuBridge { /** Opens that menu natively, at page coordinates. */ open: (id: string, at: { x: number; y: number }) => void; onEvent: (listener: (event: DesktopMenuBarEvent) => void) => () => void; + /** Every menu, with what's enabled and ticked in this window now. An older shell has none. */ + menus?: () => Promise; + /** Runs a line of `menus`, by its key and text. */ + run?: (key: string, label: string) => void; + /** The room the bar takes now (CSS pixels, 0 when gone), and whether it has the keys. */ + report?: (report: { height: number; active: boolean }) => void; + /** "Always show the menu bar", in every window. */ + setPinned?: (pinned: boolean) => void; }; } diff --git a/locales/ar/common.json b/locales/ar/common.json index ed9cbecb0..b1b4b735d 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6810,6 +6810,18 @@ "close": "إغلاق" } }, + "desktopMenuBar": { + "always_show_label": "إظهار شريط القوائم دائمًا", + "always_show_description": "وإلا فسيظهر عند الضغط على Alt.", + "exit": "خروج", + "tray_pause": "إيقاف الإشعارات مؤقتًا", + "tray_pause_hour": "لمدة ساعة واحدة", + "tray_pause_tomorrow": "حتى الغد", + "tray_resume": "إعادة تشغيل الإشعارات", + "tray_open": "فتح Bulwark", + "tray_quit": "إنهاء Bulwark", + "tray_exit": "الخروج من Bulwark" + }, "appLock": { "section_title": "قفل التطبيق", "section_desc": "يطلب رمز PIN بعد فترة دون استخدام، في كل نافذة وعلامة تبويب. أثناء القفل يبقى البريد على هذا الجهاز مشفرًا ولا يملك التطبيق مفتاحه.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 5e3d4916d..dc08334c9 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6810,6 +6810,18 @@ "close": "Tanca" } }, + "desktopMenuBar": { + "always_show_label": "Mostra sempre la barra de menús", + "always_show_description": "Si no, apareix en prémer Alt.", + "exit": "Surt", + "tray_pause": "Posa en pausa les notificacions", + "tray_pause_hour": "Durant 1 hora", + "tray_pause_tomorrow": "Fins demà", + "tray_resume": "Torna a activar les notificacions", + "tray_open": "Obre Bulwark", + "tray_quit": "Surt de Bulwark", + "tray_exit": "Surt de Bulwark" + }, "appLock": { "section_title": "Bloqueig de l'app", "section_desc": "Demana un PIN després d'un temps sense ús, a cada finestra i pestanya. Mentre està bloquejada, el correu d'aquest dispositiu continua xifrat i l'app no en té la clau.", diff --git a/locales/cs/common.json b/locales/cs/common.json index ace3e4105..652817cea 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6810,6 +6810,18 @@ "close": "Zavřít" } }, + "desktopMenuBar": { + "always_show_label": "Vždy zobrazovat řádek nabídek", + "always_show_description": "Jinak se zobrazí po stisknutí klávesy Alt.", + "exit": "Konec", + "tray_pause": "Pozastavit oznámení", + "tray_pause_hour": "Na 1 hodinu", + "tray_pause_tomorrow": "Do zítřka", + "tray_resume": "Znovu zapnout oznámení", + "tray_open": "Otevřít Bulwark", + "tray_quit": "Ukončit Bulwark", + "tray_exit": "Ukončit Bulwark" + }, "appLock": { "section_title": "Zámek aplikace", "section_desc": "Po čase bez použití žádá o PIN, v každém okně a na každé kartě. Dokud je zamčeno, zůstává pošta na tomto zařízení zašifrovaná a aplikace k ní nemá klíč.", diff --git a/locales/da/common.json b/locales/da/common.json index b473c8379..991f6a822 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6810,6 +6810,18 @@ "close": "Luk" } }, + "desktopMenuBar": { + "always_show_label": "Vis altid menulinjen", + "always_show_description": "Ellers vises den, når du trykker på Alt.", + "exit": "Afslut", + "tray_pause": "Sæt notifikationer på pause", + "tray_pause_hour": "I 1 time", + "tray_pause_tomorrow": "Indtil i morgen", + "tray_resume": "Slå notifikationer til igen", + "tray_open": "Åbn Bulwark", + "tray_quit": "Afslut Bulwark", + "tray_exit": "Afslut Bulwark" + }, "appLock": { "section_title": "Applås", "section_desc": "Beder om en pinkode efter en tid uden brug, i alle vinduer og faner. Mens den er låst, forbliver mail på denne enhed krypteret, og appen har ingen nøgle til den.", diff --git a/locales/de/common.json b/locales/de/common.json index 9cb2097f7..e81bec01a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6810,6 +6810,18 @@ "close": "Schließen" } }, + "desktopMenuBar": { + "always_show_label": "Menüleiste immer anzeigen", + "always_show_description": "Sonst erscheint sie, wenn Sie Alt drücken.", + "exit": "Beenden", + "tray_pause": "Benachrichtigungen pausieren", + "tray_pause_hour": "Für 1 Stunde", + "tray_pause_tomorrow": "Bis morgen", + "tray_resume": "Benachrichtigungen wieder einschalten", + "tray_open": "Bulwark öffnen", + "tray_quit": "Bulwark beenden", + "tray_exit": "Bulwark beenden" + }, "appLock": { "section_title": "App-Sperre", "section_desc": "Fragt nach einer Zeit ohne Nutzung in jedem Fenster und Tab nach einer PIN. Solange gesperrt ist, bleiben E-Mails auf diesem Gerät verschlüsselt und die App hat keinen Schlüssel dafür.", diff --git a/locales/en/common.json b/locales/en/common.json index 84f9389a7..3dd599069 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6810,6 +6810,18 @@ "close": "Close" } }, + "desktopMenuBar": { + "always_show_label": "Always show the menu bar", + "always_show_description": "Otherwise it shows when you press Alt.", + "exit": "Exit", + "tray_pause": "Pause notifications", + "tray_pause_hour": "For 1 hour", + "tray_pause_tomorrow": "Until tomorrow", + "tray_resume": "Turn notifications back on", + "tray_open": "Open Bulwark", + "tray_quit": "Quit Bulwark", + "tray_exit": "Exit Bulwark" + }, "appLock": { "section_title": "App lock", "section_desc": "Asks for a PIN after a time without use, in every window and tab. While it's locked, mail on this device stays encrypted and the app has no key for it.", diff --git a/locales/es/common.json b/locales/es/common.json index a44fce868..dfd992ca4 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6810,6 +6810,18 @@ "close": "Cerrar" } }, + "desktopMenuBar": { + "always_show_label": "Mostrar siempre la barra de menús", + "always_show_description": "Si no, aparece al pulsar Alt.", + "exit": "Salir", + "tray_pause": "Pausar notificaciones", + "tray_pause_hour": "Durante 1 hora", + "tray_pause_tomorrow": "Hasta mañana", + "tray_resume": "Volver a activar las notificaciones", + "tray_open": "Abrir Bulwark", + "tray_quit": "Salir de Bulwark", + "tray_exit": "Salir de Bulwark" + }, "appLock": { "section_title": "Bloqueo de la app", "section_desc": "Pide un PIN tras un tiempo sin uso, en cada ventana y pestaña. Mientras está bloqueada, el correo de este dispositivo sigue cifrado y la app no tiene la clave.", diff --git a/locales/et/common.json b/locales/et/common.json index ace51f3b5..bba893139 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6810,6 +6810,18 @@ "close": "Sulge" } }, + "desktopMenuBar": { + "always_show_label": "Näita menüüriba alati", + "always_show_description": "Muidu ilmub see Alt-klahvi vajutamisel.", + "exit": "Välju", + "tray_pause": "Peata teavitused", + "tray_pause_hour": "1 tunniks", + "tray_pause_tomorrow": "Homseni", + "tray_resume": "Lülita teavitused uuesti sisse", + "tray_open": "Ava Bulwark", + "tray_quit": "Sulge Bulwark", + "tray_exit": "Välju Bulwarkist" + }, "appLock": { "section_title": "Rakenduse lukk", "section_desc": "Küsib pärast kasutamata aega PIN-koodi, igas aknas ja vahekaardil. Lukus olles jääb selle seadme e-post krüpteerituks ja rakendusel pole selle võtit.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 9961aab01..9a10242b1 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6810,6 +6810,18 @@ "close": "بستن" } }, + "desktopMenuBar": { + "always_show_label": "همیشه نوار منو نمایش داده شود", + "always_show_description": "در غیر این صورت با فشردن Alt نمایش داده میشود.", + "exit": "خروج", + "tray_pause": "توقف موقت اعلانها", + "tray_pause_hour": "به مدت ۱ ساعت", + "tray_pause_tomorrow": "تا فردا", + "tray_resume": "روشن کردن دوباره اعلانها", + "tray_open": "باز کردن Bulwark", + "tray_quit": "بستن Bulwark", + "tray_exit": "خروج از Bulwark" + }, "appLock": { "section_title": "قفل برنامه", "section_desc": "پس از مدتی بیاستفاده، در هر پنجره و زبانه پین میخواهد. تا وقتی قفل است، ایمیلهای این دستگاه رمزگذاریشده میمانند و برنامه کلید آنها را ندارد.", diff --git a/locales/fr/common.json b/locales/fr/common.json index 750e84417..14a9b0181 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6810,6 +6810,18 @@ "close": "Fermer" } }, + "desktopMenuBar": { + "always_show_label": "Toujours afficher la barre de menus", + "always_show_description": "Sinon, elle s’affiche quand vous appuyez sur Alt.", + "exit": "Quitter", + "tray_pause": "Suspendre les notifications", + "tray_pause_hour": "Pendant 1 heure", + "tray_pause_tomorrow": "Jusqu’à demain", + "tray_resume": "Réactiver les notifications", + "tray_open": "Ouvrir Bulwark", + "tray_quit": "Quitter Bulwark", + "tray_exit": "Quitter Bulwark" + }, "appLock": { "section_title": "Verrouillage de l'app", "section_desc": "Demande un code PIN après un temps sans utilisation, dans chaque fenêtre et onglet. Tant que l'app est verrouillée, les e-mails sur cet appareil restent chiffrés et l'app n'a pas de clé pour les lire.", diff --git a/locales/he/common.json b/locales/he/common.json index 23cb11fa8..11fefdefb 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6811,6 +6811,18 @@ "close": "סגירה" } }, + "desktopMenuBar": { + "always_show_label": "תמיד להציג את סרגל התפריטים", + "always_show_description": "אחרת הוא מופיע בלחיצה על Alt.", + "exit": "יציאה", + "tray_pause": "השהיית התראות", + "tray_pause_hour": "לשעה אחת", + "tray_pause_tomorrow": "עד מחר", + "tray_resume": "הפעלת ההתראות מחדש", + "tray_open": "פתיחת Bulwark", + "tray_quit": "סגירת Bulwark", + "tray_exit": "יציאה מ-Bulwark" + }, "appLock": { "section_title": "נעילת אפליקציה", "section_desc": "מבקשת קוד PIN אחרי זמן ללא שימוש, בכל חלון וכרטיסייה. בזמן הנעילה הדואר במכשיר הזה נשאר מוצפן ולאפליקציה אין מפתח אליו.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 172fa6df6..810d32d68 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6810,6 +6810,18 @@ "close": "Bezárás" } }, + "desktopMenuBar": { + "always_show_label": "Menüsor mindig látható", + "always_show_description": "Különben az Alt billentyű lenyomásakor jelenik meg.", + "exit": "Kilépés", + "tray_pause": "Értesítések szüneteltetése", + "tray_pause_hour": "1 órára", + "tray_pause_tomorrow": "Holnapig", + "tray_resume": "Értesítések visszakapcsolása", + "tray_open": "Bulwark megnyitása", + "tray_quit": "Kilépés a Bulwarkból", + "tray_exit": "Kilépés a Bulwarkból" + }, "appLock": { "section_title": "Alkalmazászár", "section_desc": "Használat nélküli idő után PIN-kódot kér, minden ablakban és lapon. Amíg zárolva van, az eszközön lévő levelek titkosítva maradnak, és az alkalmazásnak nincs hozzájuk kulcsa.", diff --git a/locales/it/common.json b/locales/it/common.json index 71f13cca0..9cbd379dd 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6810,6 +6810,18 @@ "close": "Chiudi" } }, + "desktopMenuBar": { + "always_show_label": "Mostra sempre la barra dei menu", + "always_show_description": "Altrimenti compare premendo Alt.", + "exit": "Esci", + "tray_pause": "Sospendi le notifiche", + "tray_pause_hour": "Per 1 ora", + "tray_pause_tomorrow": "Fino a domani", + "tray_resume": "Riattiva le notifiche", + "tray_open": "Apri Bulwark", + "tray_quit": "Esci da Bulwark", + "tray_exit": "Esci da Bulwark" + }, "appLock": { "section_title": "Blocco dell'app", "section_desc": "Chiede un PIN dopo un periodo senza utilizzo, in ogni finestra e scheda. Finché è bloccata, la posta su questo dispositivo resta cifrata e l'app non ha la chiave.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7d382e843..b0a7b72f7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6810,6 +6810,18 @@ "close": "閉じる" } }, + "desktopMenuBar": { + "always_show_label": "メニューバーを常に表示", + "always_show_description": "オフにすると、Alt キーを押したときに表示されます。", + "exit": "終了", + "tray_pause": "通知を一時停止", + "tray_pause_hour": "1 時間", + "tray_pause_tomorrow": "明日まで", + "tray_resume": "通知を再開", + "tray_open": "Bulwark を開く", + "tray_quit": "Bulwark を終了", + "tray_exit": "Bulwark を終了" + }, "appLock": { "section_title": "アプリのロック", "section_desc": "一定時間使わないと、すべてのウィンドウとタブで PIN を求めます。ロック中、このデバイスのメールは暗号化されたままで、アプリには鍵がありません。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 9f9643ad0..190384004 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6810,6 +6810,18 @@ "close": "닫기" } }, + "desktopMenuBar": { + "always_show_label": "메뉴 막대 항상 표시", + "always_show_description": "끄면 Alt 키를 누를 때 표시됩니다.", + "exit": "끝내기", + "tray_pause": "알림 일시 중지", + "tray_pause_hour": "1시간 동안", + "tray_pause_tomorrow": "내일까지", + "tray_resume": "알림 다시 켜기", + "tray_open": "Bulwark 열기", + "tray_quit": "Bulwark 종료", + "tray_exit": "Bulwark 종료" + }, "appLock": { "section_title": "앱 잠금", "section_desc": "일정 시간 사용하지 않으면 모든 창과 탭에서 PIN을 요청합니다. 잠겨 있는 동안 이 기기의 메일은 암호화된 상태로 유지되며 앱에는 키가 없습니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index a4bc8ef83..ef012fb34 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6810,6 +6810,18 @@ "close": "Aizvērt" } }, + "desktopMenuBar": { + "always_show_label": "Vienmēr rādīt izvēļņu joslu", + "always_show_description": "Citādi tā parādās, nospiežot Alt.", + "exit": "Iziet", + "tray_pause": "Pauzēt paziņojumus", + "tray_pause_hour": "Uz 1 stundu", + "tray_pause_tomorrow": "Līdz rītdienai", + "tray_resume": "Atkal ieslēgt paziņojumus", + "tray_open": "Atvērt Bulwark", + "tray_quit": "Aizvērt Bulwark", + "tray_exit": "Iziet no Bulwark" + }, "appLock": { "section_title": "Lietotnes bloķēšana", "section_desc": "Pēc laika bez lietošanas prasa PIN kodu katrā logā un cilnē. Kamēr lietotne ir bloķēta, e-pasts šajā ierīcē paliek šifrēts, un lietotnei nav tā atslēgas.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 152574c4e..4fc549eb4 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6810,6 +6810,18 @@ "close": "Хаах" } }, + "desktopMenuBar": { + "always_show_label": "Цэсний мөрийг үргэлж харуулах", + "always_show_description": "Эс бөгөөс Alt товч дарахад гарч ирнэ.", + "exit": "Гарах", + "tray_pause": "Мэдэгдлийг түр зогсоох", + "tray_pause_hour": "1 цагийн турш", + "tray_pause_tomorrow": "Маргааш хүртэл", + "tray_resume": "Мэдэгдлийг дахин асаах", + "tray_open": "Bulwark-г нээх", + "tray_quit": "Bulwark-г хаах", + "tray_exit": "Bulwark-аас гарах" + }, "appLock": { "section_title": "Аппын түгжээ", "section_desc": "Хэсэг хугацаанд ашиглаагүй бол цонх, таб бүрт ПИН код асууна. Түгжээтэй үед энэ төхөөрөмжийн имэйл шифрлэгдсэн хэвээр байх бөгөөд аппад түлхүүр нь байхгүй.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 9a6c14f76..7cbba575b 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6810,6 +6810,18 @@ "close": "Lukk" } }, + "desktopMenuBar": { + "always_show_label": "Vis alltid menylinjen", + "always_show_description": "Ellers vises den når du trykker på Alt.", + "exit": "Avslutt", + "tray_pause": "Sett varsler på pause", + "tray_pause_hour": "I 1 time", + "tray_pause_tomorrow": "Til i morgen", + "tray_resume": "Slå på varsler igjen", + "tray_open": "Åpne Bulwark", + "tray_quit": "Avslutt Bulwark", + "tray_exit": "Avslutt Bulwark" + }, "appLock": { "section_title": "Applås", "section_desc": "Ber om en PIN-kode etter en tid uten bruk, i alle vinduer og faner. Mens den er låst, forblir e-post på denne enheten kryptert, og appen har ingen nøkkel til den.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3c57a4007..3ee4342ce 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6810,6 +6810,18 @@ "close": "Sluit" } }, + "desktopMenuBar": { + "always_show_label": "Menubalk altijd tonen", + "always_show_description": "Anders verschijnt hij bij een druk op Alt.", + "exit": "Afsluiten", + "tray_pause": "Meldingen pauzeren", + "tray_pause_hour": "1 uur", + "tray_pause_tomorrow": "Tot morgen", + "tray_resume": "Meldingen weer aanzetten", + "tray_open": "Bulwark openen", + "tray_quit": "Bulwark afsluiten", + "tray_exit": "Bulwark afsluiten" + }, "appLock": { "section_title": "App-vergrendeling", "section_desc": "Vraagt na een tijd zonder gebruik om een pincode, in elk venster en tabblad. Zolang de app vergrendeld is, blijft de mail op dit apparaat versleuteld en heeft de app er geen sleutel voor.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 20bd079ae..1a18a86c0 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6810,6 +6810,18 @@ "close": "Zamknij" } }, + "desktopMenuBar": { + "always_show_label": "Zawsze pokazuj pasek menu", + "always_show_description": "W przeciwnym razie pojawia się po naciśnięciu Alt.", + "exit": "Zakończ", + "tray_pause": "Wstrzymaj powiadomienia", + "tray_pause_hour": "Na 1 godzinę", + "tray_pause_tomorrow": "Do jutra", + "tray_resume": "Włącz powiadomienia z powrotem", + "tray_open": "Otwórz Bulwark", + "tray_quit": "Zakończ Bulwark", + "tray_exit": "Zakończ Bulwark" + }, "appLock": { "section_title": "Blokada aplikacji", "section_desc": "Prosi o PIN po czasie bezczynności, w każdym oknie i na każdej karcie. Gdy aplikacja jest zablokowana, poczta na tym urządzeniu pozostaje zaszyfrowana, a aplikacja nie ma do niej klucza.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 2452edf4a..88f508680 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6810,6 +6810,18 @@ "close": "Fechar" } }, + "desktopMenuBar": { + "always_show_label": "Mostrar sempre a barra de menus", + "always_show_description": "Caso contrário, aparece ao premir Alt.", + "exit": "Sair", + "tray_pause": "Pausar notificações", + "tray_pause_hour": "Durante 1 hora", + "tray_pause_tomorrow": "Até amanhã", + "tray_resume": "Voltar a ativar as notificações", + "tray_open": "Abrir o Bulwark", + "tray_quit": "Sair do Bulwark", + "tray_exit": "Sair do Bulwark" + }, "appLock": { "section_title": "Bloqueio da app", "section_desc": "Pede um PIN após um tempo sem uso, em todas as janelas e separadores. Enquanto está bloqueada, o correio neste dispositivo fica cifrado e a app não tem a chave.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 03bfef66a..3a5d1e993 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6810,6 +6810,18 @@ "close": "Închidere" } }, + "desktopMenuBar": { + "always_show_label": "Afișează mereu bara de meniu", + "always_show_description": "Altfel apare la apăsarea tastei Alt.", + "exit": "Ieșire", + "tray_pause": "Întrerupe notificările", + "tray_pause_hour": "Pentru 1 oră", + "tray_pause_tomorrow": "Până mâine", + "tray_resume": "Reactivează notificările", + "tray_open": "Deschide Bulwark", + "tray_quit": "Închide Bulwark", + "tray_exit": "Ieșire din Bulwark" + }, "appLock": { "section_title": "Blocarea aplicației", "section_desc": "Cere un PIN după un timp fără utilizare, în fiecare fereastră și filă. Cât timp e blocată, e-mailurile de pe acest dispozitiv rămân criptate și aplicația nu are cheia lor.", diff --git a/locales/ru/common.json b/locales/ru/common.json index ebb25a96f..0353ef0eb 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6810,6 +6810,18 @@ "close": "Закрыть" } }, + "desktopMenuBar": { + "always_show_label": "Всегда показывать строку меню", + "always_show_description": "Иначе она появляется при нажатии Alt.", + "exit": "Выход", + "tray_pause": "Приостановить уведомления", + "tray_pause_hour": "На 1 час", + "tray_pause_tomorrow": "До завтра", + "tray_resume": "Снова включить уведомления", + "tray_open": "Открыть Bulwark", + "tray_quit": "Закрыть Bulwark", + "tray_exit": "Выйти из Bulwark" + }, "appLock": { "section_title": "Блокировка приложения", "section_desc": "Запрашивает PIN-код после периода бездействия, в каждом окне и вкладке. Пока приложение заблокировано, почта на этом устройстве остаётся зашифрованной, и у приложения нет ключа к ней.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 43343c99e..f581b250b 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6810,6 +6810,18 @@ "close": "Zavrieť" } }, + "desktopMenuBar": { + "always_show_label": "Vždy zobrazovať panel ponúk", + "always_show_description": "Inak sa zobrazí po stlačení klávesu Alt.", + "exit": "Koniec", + "tray_pause": "Pozastaviť upozornenia", + "tray_pause_hour": "Na 1 hodinu", + "tray_pause_tomorrow": "Do zajtra", + "tray_resume": "Znova zapnúť upozornenia", + "tray_open": "Otvoriť Bulwark", + "tray_quit": "Ukončiť Bulwark", + "tray_exit": "Ukončiť Bulwark" + }, "appLock": { "section_title": "Zámok aplikácie", "section_desc": "Po čase bez používania žiada o PIN, v každom okne a na každej karte. Kým je zamknuté, pošta na tomto zariadení zostáva šifrovaná a aplikácia k nej nemá kľúč.", diff --git a/locales/th/common.json b/locales/th/common.json index d4aef2af2..099e9a1a4 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6818,6 +6818,18 @@ "close": "ปิด" } }, + "desktopMenuBar": { + "always_show_label": "แสดงแถบเมนูเสมอ", + "always_show_description": "มิฉะนั้นแถบเมนูจะแสดงเมื่อกด Alt", + "exit": "ออก", + "tray_pause": "หยุดการแจ้งเตือนชั่วคราว", + "tray_pause_hour": "1 ชั่วโมง", + "tray_pause_tomorrow": "จนถึงพรุ่งนี้", + "tray_resume": "เปิดการแจ้งเตือนอีกครั้ง", + "tray_open": "เปิด Bulwark", + "tray_quit": "ปิด Bulwark", + "tray_exit": "ออกจาก Bulwark" + }, "appLock": { "section_title": "ล็อกแอป", "section_desc": "ขอ PIN หลังจากไม่ได้ใช้งานระยะหนึ่ง ในทุกหน้าต่างและแท็บ ระหว่างล็อก อีเมลบนอุปกรณ์นี้ยังคงเข้ารหัสอยู่และแอปไม่มีกุญแจ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 03393f7f0..8796602fd 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6810,6 +6810,18 @@ "close": "Kapat" } }, + "desktopMenuBar": { + "always_show_label": "Menü çubuğunu her zaman göster", + "always_show_description": "Aksi halde Alt tuşuna basınca görünür.", + "exit": "Çıkış", + "tray_pause": "Bildirimleri duraklat", + "tray_pause_hour": "1 saat", + "tray_pause_tomorrow": "Yarına kadar", + "tray_resume": "Bildirimleri yeniden aç", + "tray_open": "Bulwark'ı aç", + "tray_quit": "Bulwark'tan çık", + "tray_exit": "Bulwark'tan çık" + }, "appLock": { "section_title": "Uygulama kilidi", "section_desc": "Bir süre kullanılmadığında her pencerede ve sekmede PIN ister. Kilitliyken bu cihazdaki e-postalar şifreli kalır ve uygulamada anahtarı olmaz.", diff --git a/locales/uk/common.json b/locales/uk/common.json index f033cd1ee..cb17dc5d4 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6810,6 +6810,18 @@ "close": "Закрити" } }, + "desktopMenuBar": { + "always_show_label": "Завжди показувати рядок меню", + "always_show_description": "Інакше він з’являється після натискання Alt.", + "exit": "Вихід", + "tray_pause": "Призупинити сповіщення", + "tray_pause_hour": "На 1 годину", + "tray_pause_tomorrow": "До завтра", + "tray_resume": "Знову ввімкнути сповіщення", + "tray_open": "Відкрити Bulwark", + "tray_quit": "Закрити Bulwark", + "tray_exit": "Вийти з Bulwark" + }, "appLock": { "section_title": "Блокування застосунку", "section_desc": "Запитує PIN-код після часу без використання, у кожному вікні й вкладці. Поки застосунок заблоковано, пошта на цьому пристрої залишається зашифрованою, і застосунок не має ключа до неї.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 9faa09bde..775a8adfe 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6822,6 +6822,18 @@ "close": "Đóng" } }, + "desktopMenuBar": { + "always_show_label": "Luôn hiện thanh menu", + "always_show_description": "Nếu tắt, thanh menu hiện khi bạn nhấn Alt.", + "exit": "Thoát", + "tray_pause": "Tạm dừng thông báo", + "tray_pause_hour": "Trong 1 giờ", + "tray_pause_tomorrow": "Đến ngày mai", + "tray_resume": "Bật lại thông báo", + "tray_open": "Mở Bulwark", + "tray_quit": "Thoát Bulwark", + "tray_exit": "Thoát Bulwark" + }, "appLock": { "section_title": "Khóa ứng dụng", "section_desc": "Yêu cầu mã PIN sau một thời gian không dùng, trong mọi cửa sổ và thẻ. Khi đang khóa, thư trên thiết bị này vẫn được mã hóa và ứng dụng không có khóa của chúng.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index eed67bf21..335956906 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6810,6 +6810,18 @@ "close": "關閉" } }, + "desktopMenuBar": { + "always_show_label": "永遠顯示選單列", + "always_show_description": "否則按 Alt 鍵時顯示。", + "exit": "結束", + "tray_pause": "暫停通知", + "tray_pause_hour": "1 小時", + "tray_pause_tomorrow": "直到明天", + "tray_resume": "重新開啟通知", + "tray_open": "開啟 Bulwark", + "tray_quit": "結束 Bulwark", + "tray_exit": "結束 Bulwark" + }, "appLock": { "section_title": "應用程式鎖定", "section_desc": "一段時間未使用後,在每個視窗和分頁中要求輸入 PIN。鎖定期間,此裝置上的郵件保持加密,應用程式沒有金鑰。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0edeb8c81..081104f87 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6810,6 +6810,18 @@ "close": "关闭" } }, + "desktopMenuBar": { + "always_show_label": "始终显示菜单栏", + "always_show_description": "否则按 Alt 键时显示。", + "exit": "退出", + "tray_pause": "暂停通知", + "tray_pause_hour": "1 小时", + "tray_pause_tomorrow": "直到明天", + "tray_resume": "重新开启通知", + "tray_open": "打开 Bulwark", + "tray_quit": "退出 Bulwark", + "tray_exit": "退出 Bulwark" + }, "appLock": { "section_title": "应用锁", "section_desc": "一段时间未使用后,在每个窗口和标签页中要求输入 PIN。锁定期间,此设备上的邮件保持加密,应用没有密钥。", From 674845df4e9c3951f5f94d586676f6496e136f0e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:20:02 +0100 Subject: [PATCH 2/4] fix: keep the desktop menus and tray in the language the app shows --- .../desktop/__tests__/desktop-host.test.tsx | 24 +++++++++++++++++++ components/desktop/desktop-host.tsx | 13 ++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx index 61f8a3b8d..2d757cbf2 100644 --- a/components/desktop/__tests__/desktop-host.test.tsx +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -14,6 +14,14 @@ import { offerReturnedComposer, takeReturnedComposer } from '@/lib/popout/return const push = vi.fn(); vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) })); +// The catalog on screen: its language, and text that says which one it came from. +const intl = vi.hoisted(() => ({ locale: 'en', prefix: '', messages: {} as object })); +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => `${intl.prefix}${key}`, + useLocale: () => intl.locale, + useMessages: () => intl.messages, +})); + const canPrint = vi.fn(() => true); const canDownload = vi.fn(() => true); vi.mock('@/lib/device-policy', () => ({ canPrintAnywhere: () => canPrint(), canDownloadAnywhere: () => canDownload() })); @@ -68,6 +76,7 @@ function fakeBridge() { const original = useAuthStore.getState(); beforeEach(() => { + Object.assign(intl, { locale: 'en', prefix: '', messages: {} }); push.mockClear(); resolveExternalLink.mockClear(); resolveExternalLink.mockImplementation(async (ctx) => ctx.href); @@ -170,11 +179,26 @@ describe('DesktopHost', () => { it('says which languages are in use', async () => { const shell = fakeBridge(); act(() => useLocaleStore.setState({ locale: 'de' })); + intl.locale = 'de'; render(); await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled()); expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'de' }); }); + it('follows the text on screen, not only the stored choice', async () => { + const shell = fakeBridge(); + // English chosen, but the first render still has the catalog the server picked from the OS. + act(() => useLocaleStore.setState({ locale: 'en' })); + Object.assign(intl, { locale: 'de', prefix: 'de:', messages: { de: true } }); + const view = render(); + await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled()); + // The chosen catalog lands a render later. + Object.assign(intl, { locale: 'en', prefix: 'en:', messages: { en: true } }); + view.rerender(); + await waitFor(() => expect(shell.setLanguages.mock.lastCall![0].labels.menuFile).toBe('en:desktopUi.menu.file')); + expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'en' }); + }); + describe('Open Link in a native menu', () => { const openLink = (url: string) => `link.open:${JSON.stringify({ url, text: 'click' })}`; diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx index 909ca657b..b80396b80 100644 --- a/components/desktop/desktop-host.tsx +++ b/components/desktop/desktop-host.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useMessages, useTranslations } from "next-intl"; import { useStrictestDevices } from "@/hooks/use-device-policy"; import { useRouter } from "@/i18n/navigation"; import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands"; @@ -45,6 +45,8 @@ export function DesktopHost() { const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts); const writingLanguage = useSettingsStore((s) => s.writingLanguage); const uiLocale = useLocaleStore((s) => s.locale); + const activeLocale = useLocale(); + const messages = useMessages(); const strictest = useStrictestDevices(); const { restoreWindows } = useDesktopPrefs(); const theme = useThemeStore((s) => s.theme); @@ -116,11 +118,14 @@ export function DesktopHost() { }); }, [isAuthenticated, singleKeys, strictest]); - // Menus and spellcheck follow the interface. + // Menus, the tray and spellcheck follow the interface: the language its text is in now, + // with that text. The stored choice isn't it: on the first render the catalog can still + // be the one the server picked from the OS language, and the chosen one lands a render + // later. Going by the choice alone left the menus in the OS language for good. useEffect(() => { - const ui = uiLocale || document.documentElement.lang; + const ui = activeLocale || uiLocale || document.documentElement.lang; if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as Parameters[0])); - }, [uiLocale]); + }, [activeLocale, uiLocale, messages]); // What the user writes in, whichever composer asks for it too. useEffect(() => { From ad970b35ef468cdedcdacf63958f7580cae75afd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:20:03 +0100 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20move=20a=20message=20from=20its=20o?= =?UTF-8?q?wn=20window=20with=20Message=20>=20Move=20to=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/pro-email-edit-draft.test.tsx | 38 ++++++++++++++++++- components/pro/pro-email-tab-body.tsx | 14 +++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/components/pro/__tests__/pro-email-edit-draft.test.tsx b/components/pro/__tests__/pro-email-edit-draft.test.tsx index 9c453ee6f..0edefdb22 100644 --- a/components/pro/__tests__/pro-email-edit-draft.test.tsx +++ b/components/pro/__tests__/pro-email-edit-draft.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import React from 'react'; import { ProTabFocusContext } from '@/hooks/use-pane-context'; @@ -26,6 +26,20 @@ vi.mock('@/components/error', () => ({ })); vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); + +// A message window: the Message menu's Move to… opens the folder list there. +const popout = vi.hoisted(() => ({ value: false })); +vi.mock('@/lib/popout/window-role', async (importOriginal) => ({ + ...await importOriginal(), + isPopoutWindow: () => popout.value, +})); +const moveDialog = vi.hoisted(() => ({ current: null as { email: unknown } | null })); +vi.mock('@/components/email/move-to-dialog', () => ({ + MoveToDialog: (props: { email: unknown }) => { + moveDialog.current = props; + return null; + }, +})); vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } })); const multiAccount = vi.hoisted(() => ({ value: { enabled: false, groups: [] as unknown[], allIdentities: [] as Array<{ id: string; email: string; name: string }> }, @@ -79,6 +93,7 @@ vi.mock('@/stores/pro-tab-store', () => ({ })); import { ProEmailView } from '../pro-email-tab-body'; +import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands'; const DRAFT = { id: 'eaaaaab', @@ -307,3 +322,24 @@ describe('Pro email view - keyboard', () => { expect(openComposeTab).not.toHaveBeenCalled(); }); }); + +describe('Message > Move to… in a message window', () => { + it('opens the folder list for the message on screen', async () => { + popout.value = true; + window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: vi.fn() } } as never; + try { + const client = { getEmail: vi.fn().mockResolvedValue(DRAFT), getUsername: () => 'userb@example.org', getAccountId: () => 'e' }; + render(); + await waitFor(() => expect(viewerProps.current?.email).toBeTruthy()); + expect(moveDialog.current?.email ?? null).toBeNull(); + act(() => { + expect(runDesktopCommand('message.move')).toBe(true); + }); + await waitFor(() => expect(moveDialog.current?.email).toMatchObject({ id: 'eaaaaab' })); + } finally { + popout.value = false; + window.bulwarkDesktop = undefined; + resetDesktopCommands(); + } + }); +}); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 569b13b0a..8e854bfcc 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; import { EmailViewer } from "@/components/email/email-viewer"; +import { MoveToDialog } from "@/components/email/move-to-dialog"; import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store"; @@ -472,6 +473,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, // In a pop-out this message is all there is, so the menu bar's Message menu means it. const inOwnWindow = typeof window !== 'undefined' && isPopoutWindow(); const seen = !!email?.keywords?.$seen; + // Message > Move to… in a pop-out: the same folder list the command palette opens. + const [moveDialogOpen, setMoveDialogOpen] = useState(false); useDesktopCommands({ 'message.reply': () => handleReply(), 'message.replyAll': handleReplyAll, @@ -481,6 +484,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, 'message.star': () => void handleToggleStar(), 'message.archive': archiveActionEnabled ? () => void handleArchive() : undefined, 'message.delete': () => void handleDelete(), + 'message.move': () => { if (email) setMoveDialogOpen(true); }, }, { 'message.reply': !!email, 'message.replyAll': !!email, @@ -490,6 +494,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, 'message.star': !!email, 'message.archive': !!email && archiveActionEnabled, 'message.delete': !!email, + 'message.move': !!email, }, inOwnWindow); const handleMoveToMailbox = useCallback(async (mailboxId: string) => { @@ -642,6 +647,15 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, /> + {inOwnWindow && ( + setMoveDialogOpen(false)} + onMove={(mailboxId) => void handleMoveToMailbox(mailboxId)} + /> + )} {preview && ( Date: Wed, 4 Nov 2026 06:17:12 +0100 Subject: [PATCH 4/4] fix: translate Lock now in the desktop File menu --- lib/desktop/languages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts index a877ef3dd..6f6840339 100644 --- a/lib/desktop/languages.ts +++ b/lib/desktop/languages.ts @@ -132,6 +132,7 @@ export function shellLabels(t: (key: string, values?: Record) => menuShowMenuBar: t('desktopMenuBar.always_show_label'), trayNewMessage: t('email_composer.new_message'), trayLockNow: t('appLock.lock_now'), + menuLockNow: t('appLock.lock_now'), trayUnread: t('email_list.sections.unread'), trayPause: t('desktopMenuBar.tray_pause'), trayPauseHour: t('desktopMenuBar.tray_pause_hour'),