From 3fa82849a8aecd7606c49edb6408799853ba6af8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:59:52 +0100 Subject: [PATCH 01/20] fix: open menus next to what opened them and show their keys apart from the label --- components/calendar/calendar-toolbar.tsx | 6 +- components/layout/phone-header-actions.tsx | 2 +- components/plugins/plugin-actions.tsx | 2 +- .../__tests__/context-menu-placement.test.tsx | 162 ++++++++++++++++++ components/ui/context-menu.tsx | 83 +++++---- hooks/use-context-menu.ts | 34 +--- .../__tests__/native-menu-keys.test.tsx | 98 +++++++++++ lib/desktop/native-menu.ts | 133 +++++++++++++- lib/desktop/types.ts | 5 + lib/menu-placement.ts | 83 +++++++++ 10 files changed, 539 insertions(+), 69 deletions(-) create mode 100644 components/ui/__tests__/context-menu-placement.test.tsx create mode 100644 lib/desktop/__tests__/native-menu-keys.test.tsx create mode 100644 lib/menu-placement.ts diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index d1957b882..fe6471667 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; import { ToolbarButton } from "@/components/ui/toolbar"; import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw, Search, Printer } from "@/components/icons"; -import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator, MenuShortcut } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { startOfWeek } from "date-fns"; import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; @@ -482,9 +482,9 @@ export function CalendarToolbar({ v === viewMode ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted text-foreground", )} > - {t(`views.${v}`)} + {t(`views.${v}`)} {/* The key, on devices with a keyboard (R8). */} - {VIEW_SHORTCUTS[v]} + ))} diff --git a/components/layout/phone-header-actions.tsx b/components/layout/phone-header-actions.tsx index e33815115..7e74c7074 100644 --- a/components/layout/phone-header-actions.tsx +++ b/components/layout/phone-header-actions.tsx @@ -92,7 +92,7 @@ export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) { 0 ? formatBadgeCount(unread) : undefined} + badge={unread > 0 ? formatBadgeCount(unread) : undefined} onClick={() => { close(); setShowNotifications(true); }} /> { void run(action); }} /> ))} diff --git a/components/ui/__tests__/context-menu-placement.test.tsx b/components/ui/__tests__/context-menu-placement.test.tsx new file mode 100644 index 000000000..a856a4268 --- /dev/null +++ b/components/ui/__tests__/context-menu-placement.test.tsx @@ -0,0 +1,162 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ContextMenu, ContextMenuItem, ContextMenuSubMenu, type ContextMenuPosition } from '../context-menu'; +import { useContextMenu } from '@/hooks/use-context-menu'; +import { placeMenu } from '@/lib/menu-placement'; +import type { BulwarkDesktopBridge } from '@/lib/desktop/types'; + +// Every menu opens next to what opened it: under a button, lined up with its +// edge, or at the pointer, and towards the other side only where there's no +// room. Never clamped somewhere away from the button. + +function rect(left: number, top: number, width: number, height: number): DOMRect { + return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect; +} + +const MENU = { width: 220, height: 300 }; + +beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: 720, configurable: true }); + Object.defineProperty(window, 'outerWidth', { value: 1280, configurable: true }); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return this.getAttribute('role') === 'menu' ? rect(0, 0, MENU.width, MENU.height) : rect(0, 0, 0, 0); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + window.bulwarkDesktop = undefined; + document.documentElement.dir = ''; +}); + +function openAt(position: ContextMenuPosition) { + render( + {}}> + {}} /> + , + ); + const menu = screen.getByRole('menu'); + return { left: parseFloat(menu.style.left), top: parseFloat(menu.style.top) }; +} + +describe('a menu from a right-click', () => { + it('opens at the pointer where there is room', () => { + expect(openAt({ x: 300, y: 200 })).toEqual({ left: 300, top: 200 }); + }); + + it('opens to the left of the pointer near the right edge, its corner still at the pointer', () => { + // Clamped instead, the menu would sit under the pointer, away from where the click was. + expect(openAt({ x: 1200, y: 200 })).toEqual({ left: 1200 - MENU.width, top: 200 }); + }); + + it('opens upwards from the pointer near the bottom', () => { + expect(openAt({ x: 300, y: 650 })).toEqual({ left: 300, top: 650 - MENU.height }); + }); + + it('keeps the pointer where it was: the hook no longer guesses a size and moves it first', () => { + function Row() { + const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu(); + return ( + <> +
openContextMenu(e, 'row')}>Row
+ + {}} /> + + + ); + } + render(); + // A short menu right-clicked at 640: it fits below the pointer. + MENU.height = 60; + try { + fireEvent.contextMenu(screen.getByTestId('row'), { clientX: 400, clientY: 640 }); + const menu = screen.getByRole('menu'); + expect(menu.style.top).toBe('640px'); + expect(menu.style.left).toBe('400px'); + } finally { + MENU.height = 300; + } + }); +}); + +describe('a menu from a button', () => { + it('sits under the button, lined up with its start edge', () => { + expect(openAt({ x: 0, y: 0, anchor: { left: 100, top: 40, right: 132, bottom: 72 } })).toEqual({ left: 100, top: 76 }); + }); + + it('lines up with the button\'s end edge for a button at the end of a toolbar', () => { + expect(openAt({ x: 0, y: 0, anchor: { left: 900, top: 40, right: 932, bottom: 72, align: 'end' } })).toEqual({ left: 932 - MENU.width, top: 76 }); + }); + + it('switches to the button\'s other edge when it doesn\'t fit, instead of sliding away from it', () => { + const at = openAt({ x: 0, y: 0, anchor: { left: 1200, top: 40, right: 1232, bottom: 72 } }); + expect(at.left + MENU.width).toBe(1232); + }); + + it('goes above the button when there is no room below', () => { + expect(openAt({ x: 0, y: 0, anchor: { left: 100, top: 600, right: 132, bottom: 632 } }).top).toBe(600 - 4 - MENU.height); + }); + + it('mirrors in RTL: start is the right edge', () => { + document.documentElement.dir = 'rtl'; + const at = openAt({ x: 0, y: 0, anchor: { left: 500, top: 40, right: 532, bottom: 72 } }); + // jsdom computes no direction from dir; the pure function covers RTL below. + expect([500, 532 - MENU.width]).toContain(at.left); + }); +}); + +describe('placeMenu', () => { + const viewport = { width: 1280, height: 720 }; + const size = { width: 220, height: 300 }; + + it('RTL: under a button, its right edge with the button\'s right edge', () => { + expect(placeMenu({ point: { x: 0, y: 0 }, anchor: { left: 500, top: 40, right: 532, bottom: 72 }, size, viewport, rtl: true })) + .toEqual({ x: 532 - 220, y: 76 }); + }); + + it('RTL: an end-aligned button gets the left edge', () => { + expect(placeMenu({ point: { x: 0, y: 0 }, anchor: { left: 40, top: 40, right: 72, bottom: 72, align: 'end' }, size, viewport, rtl: true })) + .toEqual({ x: 40, y: 76 }); + }); + + it('RTL: a right-click opens to the left of the pointer', () => { + expect(placeMenu({ point: { x: 600, y: 100 }, size, viewport, rtl: true })).toEqual({ x: 380, y: 100 }); + }); + + it('taller than the window: inside it, from the top margin, and the menu scrolls', () => { + expect(placeMenu({ point: { x: 600, y: 100 }, size: { width: 220, height: 900 }, viewport })).toEqual({ x: 600, y: 10 }); + }); + + it('wider than the room on both sides of a button: clamped inside the window', () => { + expect(placeMenu({ point: { x: 0, y: 0 }, anchor: { left: 100, top: 40, right: 132, bottom: 72 }, size: { width: 1270, height: 100 }, viewport }).x).toBe(10); + }); +}); + +describe('a native menu from a button', () => { + it('lines its right edge up with an end-aligned button, by the native menu\'s width, not the hidden copy\'s', async () => { + const popup = vi.fn(async () => null); + window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as unknown as BulwarkDesktopBridge; + // The hidden copy holds every submenu open, so it is far wider than the menu the OS draws. + MENU.width = 700; + try { + render( + {}}> + {}} /> + + {}} /> + + {}} /> + , + ); + await waitFor(() => expect(popup).toHaveBeenCalled()); + const at = (popup.mock.calls[0] as unknown[])[1] as { x: number; y: number }; + expect(at.y).toBe(76); + // Under the button: its left edge somewhere left of the button, not at the window's edge. + expect(at.x).toBeGreaterThan(200); + expect(at.x).toBeLessThan(468); + } finally { + MENU.width = 220; + } + }); +}); diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index 8241bd4d0..84e0c359c 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -18,7 +18,8 @@ import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; import { ChevronRight } from "@/components/icons"; import { useTranslations } from "next-intl"; -import { nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu"; +import { estimateNativeMenuWidth, nativeMenuPoint, nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu"; +import { placeMenu } from "@/lib/menu-placement"; import { toast } from "@/stores/toast-store"; interface Position { @@ -282,8 +283,11 @@ export const ContextMenu = forwardRef( } const { anchor: at } = positionRef.current; // From a right-click it opens at the pointer, which the OS knows best. + // From a button: under it, lined up with the edge the HTML menu would + // use. The hidden copy is as wide as its widest submenu row, not as the + // native menu, so the native menu's own width is estimated. const place = at - ? { x: at.align === "end" ? Math.max(0, at.right - root.getBoundingClientRect().width) : at.left, y: at.bottom + ANCHOR_GAP } + ? nativeMenuPoint(at, estimateNativeMenuWidth(read.items), getComputedStyle(root).direction === "rtl", ANCHOR_GAP) : undefined; const outcome = await showNativeMenu(read, root, place, tDesktopRef.current("from_plugins")); if (cancelled) return; @@ -322,32 +326,16 @@ export const ContextMenu = forwardRef( const clamp = () => { const rect = node.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - - let x = position.x; - let y = position.y; - - if (anchor) { - // Under the anchor, lined up with its start edge (end edge for - // align="end"); above it when there is no room below. - const rtl = getComputedStyle(node).direction === "rtl"; - const alignEnd = (anchor.align === "end") !== rtl; - x = alignEnd ? anchor.right - rect.width : anchor.left; - y = anchor.bottom + ANCHOR_GAP; - if (y + rect.height > vh - VIEWPORT_MARGIN && anchor.top - ANCHOR_GAP - rect.height >= VIEWPORT_MARGIN) { - y = anchor.top - ANCHOR_GAP - rect.height; - } - } - - if (x + rect.width > vw - VIEWPORT_MARGIN) { - x = vw - rect.width - VIEWPORT_MARGIN; - } - if (y + rect.height > vh - VIEWPORT_MARGIN) { - y = vh - rect.height - VIEWPORT_MARGIN; - } - x = Math.max(VIEWPORT_MARGIN, x); - y = Math.max(VIEWPORT_MARGIN, y); + // Next to the button it opened from, or at the pointer (lib/menu-placement.ts). + const { x, y } = placeMenu({ + point: { x: position.x, y: position.y }, + anchor, + size: { width: rect.width, height: rect.height }, + viewport: { width: window.innerWidth, height: window.innerHeight }, + rtl: getComputedStyle(node).direction === "rtl", + margin: VIEWPORT_MARGIN, + gap: ANCHOR_GAP, + }); setAdjustedPosition((prev) => (prev && prev.x === x && prev.y === y ? prev : { x, y })); }; @@ -574,7 +562,10 @@ interface ContextMenuItemProps { onClick: () => void; disabled?: boolean; destructive?: boolean; + /** The key that does the same, shown apart from the label. */ shortcut?: string; + /** A count at the end of the row, e.g. unread notifications. Not a key. */ + badge?: string; /** Stable hook for integration tests (not user-visible). */ testId?: string; /** Tooltip, e.g. the address behind a display name. */ @@ -590,6 +581,7 @@ export function ContextMenuItem({ disabled = false, destructive = false, shortcut, + badge, testId, title, hint, @@ -636,14 +628,41 @@ export function ContextMenuItem({ ) : ( {label} )} - {/* Key hints mean nothing without a keyboard: hidden on touch (R8). */} - {shortcut && ( - {shortcut} - )} + {shortcut && } + {badge && } ); } +/** + * The key that does what a menu row does, at the row's end, muted, as a keycap. The + * desktop app's native menu shows it as the item's accelerator, apart from the label + * (lib/desktop/native-menu.ts reads `data-menu-shortcut`). Key hints mean nothing + * without a keyboard: hidden on touch (R8). + */ +export function MenuShortcut({ keys, className }: { keys: string; className?: string }) { + return ( + + {keys} + + ); +} + +/** A count at a row's end ("3" unread). Not a key: a native menu puts it after the label. */ +export function MenuBadge({ text }: { text: string }) { + return ( + + {text} + + ); +} + export function ContextMenuSeparator() { return
; } diff --git a/hooks/use-context-menu.ts b/hooks/use-context-menu.ts index eb412b42d..d76555be2 100644 --- a/hooks/use-context-menu.ts +++ b/hooks/use-context-menu.ts @@ -24,10 +24,6 @@ export interface UseContextMenuReturn { menuRef: React.RefObject; } -const MENU_WIDTH = 200; -const MENU_HEIGHT = 320; // Approximate max height -const VIEWPORT_MARGIN = 10; - /** Shift+F10 or the Menu key: open the focused element's context menu. */ export function isContextMenuKey(e: { key: string; shiftKey: boolean }): boolean { return e.key === "ContextMenu" || (e.shiftKey && e.key === "F10"); @@ -71,30 +67,6 @@ export function useContextMenu(): UseContextMenuReturn { const menuRef = useRef(null); - const calculatePosition = useCallback((clientX: number, clientY: number): ContextMenuPosition => { - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let x = clientX; - let y = clientY; - - // Adjust for right edge - if (x + MENU_WIDTH > viewportWidth - VIEWPORT_MARGIN) { - x = viewportWidth - MENU_WIDTH - VIEWPORT_MARGIN; - } - - // Adjust for bottom edge - if (y + MENU_HEIGHT > viewportHeight - VIEWPORT_MARGIN) { - y = viewportHeight - MENU_HEIGHT - VIEWPORT_MARGIN; - } - - // Ensure minimum position - x = Math.max(VIEWPORT_MARGIN, x); - y = Math.max(VIEWPORT_MARGIN, y); - - return { x, y }; - }, []); - const openContextMenu = useCallback((e: React.MouseEvent, data: T) => { e.preventDefault(); e.stopPropagation(); @@ -103,14 +75,16 @@ export function useContextMenu(): UseContextMenuReturn { const trigger = target ? focusReturnTarget(target) : null; const position = target && openedByKeyboard(e) ? { ...anchorPosition(target), trigger } - : { ...calculatePosition(e.clientX, e.clientY), trigger }; + // At the pointer as it is: the menu places itself once it knows its size, + // opening up or to the side where there's no room (lib/menu-placement.ts). + : { x: e.clientX, y: e.clientY, trigger }; setContextMenu({ isOpen: true, position, data, }); - }, [calculatePosition]); + }, []); const openContextMenuFrom = useCallback((element: HTMLElement, data: T, align: "start" | "end" = "start") => { setContextMenu({ isOpen: true, position: anchorPosition(element, align), data }); diff --git a/lib/desktop/__tests__/native-menu-keys.test.tsx b/lib/desktop/__tests__/native-menu-keys.test.tsx new file mode 100644 index 000000000..3b4016619 --- /dev/null +++ b/lib/desktop/__tests__/native-menu-keys.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from '@testing-library/react'; +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ContextMenuItem } from '@/components/ui/context-menu'; +import { readMenu, toAccelerator } from '../native-menu'; + +// A native menu built from an HTML one shows a row's key apart from its label, +// as the native menus' own accelerators do. The calendar's view menu came out +// as "DayD", "WeekW": the key was read as part of the label. + +function menuOf(children: React.ReactNode) { + render(
{children}
); + return readMenu(screen.getByTestId('root')).items; +} + +describe('a row\'s key in a native menu', () => { + it('is not part of the label, and becomes the accelerator', () => { + const items = menuOf( + , + ); + expect(items[0].label).toBe('Day'); + expect(items[0].accelerator).toBe('D'); + }); + + it('a menu item\'s shortcut too', () => { + const items = menuOf( {}} />); + expect(items[0]).toMatchObject({ label: 'Cut', accelerator: 'Ctrl+X' }); + }); + + it('a count is no key: it goes after the label', () => { + const items = menuOf( {}} />); + expect(items[0].label).toBe('Notifications (3)'); + expect(items[0].accelerator).toBeUndefined(); + }); + + it('words in separate elements stay separate words', () => { + const items = menuOf( + , + ); + expect(items[0].label).toBe('Alice Example alice@example.net'); + }); +}); + +describe('toAccelerator', () => { + it.each([ + ['D', 'D'], + ['r', 'R'], + ['Shift + I', 'Shift+I'], + ['Ctrl+X', 'Ctrl+X'], + ['⌘X', 'Cmd+X'], + ['⌘⇧K', 'Cmd+Shift+K'], + ['Mod + Enter', 'CmdOrCtrl+Enter'], + ['Del', 'Delete'], + ['F2', 'F2'], + ['?', '?'], + ['#', '#'], + ['Ctrl++', 'Ctrl+Plus'], + ])('%s is %s', (hint, accelerator) => { + expect(toAccelerator(hint)).toBe(accelerator); + }); + + it.each(['j / ↓', '', 'Hyper+Q', 'twelve'])('%s is no single key', (hint) => { + expect(toAccelerator(hint)).toBeUndefined(); + }); +}); + +// Menu rows show their keys through MenuShortcut (or ContextMenuItem's +// shortcut), which the native menu knows. A raw in a file with menu +// rows is how "DayD" happened. +describe('menu rows show keys through MenuShortcut', () => { + const root = path.resolve(__dirname, '../../..'); + it('no raw next to role="menuitem…" rows', () => { + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.tsx')) files.push(full); + } + }; + walk(path.join(root, 'components')); + walk(path.join(root, 'app')); + const offenders = files.filter((file) => { + if (file.endsWith(path.join('ui', 'context-menu.tsx'))) return false; + const source = readFileSync(file, 'utf8'); + return /role="menuitem/.test(source) && /]/.test(source); + }); + expect(offenders.map((file) => path.relative(root, file))).toEqual([]); + }, 60_000); +}); diff --git a/lib/desktop/native-menu.ts b/lib/desktop/native-menu.ts index 5fac69c9e..2ae8e4bcf 100644 --- a/lib/desktop/native-menu.ts +++ b/lib/desktop/native-menu.ts @@ -35,9 +35,68 @@ function isDisabled(el: HTMLElement): boolean { return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true'; } +/** What a row shows apart from its label: the key, a count. Never part of the label. */ +const ASIDE = 'kbd, [data-menu-shortcut], [data-menu-badge]'; + +/** + * The words of an element, each element's apart: "Day" and "D" make "Day D", not + * "DayD", and a name over an address doesn't run into it. Keys and counts are left out. + */ +function wordsOf(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ''; + if (!(node instanceof Element) || node.matches(ASIDE)) return ''; + return Array.from(node.childNodes).map(wordsOf).join(' '); +} + 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(); + const named = el.getAttribute('aria-label') ?? el.querySelector('[data-menu-label]')?.textContent ?? wordsOf(el); + const badge = el.querySelector('[data-menu-badge]')?.textContent?.trim(); + const label = named.replace(/\s+/g, ' ').trim(); + return badge && label ? `${label} (${badge})` : label; +} + +const KEY_NAMES: Record = { + del: 'Delete', delete: 'Delete', esc: 'Escape', escape: 'Escape', enter: 'Enter', return: 'Enter', + space: 'Space', tab: 'Tab', backspace: 'Backspace', insert: 'Insert', home: 'Home', end: 'End', + pageup: 'PageUp', pagedown: 'PageDown', up: 'Up', down: 'Down', left: 'Left', right: 'Right', + '↑': 'Up', '↓': 'Down', '←': 'Left', '→': 'Right', '+': 'Plus', '⌫': 'Backspace', '⏎': 'Enter', '↵': 'Enter', +}; +const MODIFIERS: Record = { + ctrl: 'Ctrl', control: 'Ctrl', mod: 'CmdOrCtrl', cmd: 'Cmd', command: 'Cmd', '⌘': 'Cmd', alt: 'Alt', option: 'Alt', + '⌥': 'Alt', shift: 'Shift', '⇧': 'Shift', '⌃': 'Ctrl', meta: 'Super', win: 'Super', +}; +const KEY = /^([A-Z0-9]|F([1-9]|1[0-9]|2[0-4])|[)!@#$%^&*(:;=<,_\-.>?/~`{\][|\\}"']|Plus|Space|Tab|Backspace|Delete|Insert|Enter|Up|Down|Left|Right|Home|End|PageUp|PageDown|Escape)$/; + +/** + * A key hint as the app shows it ("Shift + I", "⌘X", "Del") in Electron's accelerator + * syntax, or undefined when it isn't one key (two alternatives, a word). + */ +export function toAccelerator(hint: string): string | undefined { + let text = hint.trim(); + if (!text || text.includes(' / ')) return undefined; + const parts: string[] = []; + // macOS writes its modifiers as symbols with nothing between: ⌘⇧K. + while (text.length > 1 && /^[⌘⌥⇧⌃]/.test(text)) { + parts.push(MODIFIERS[text[0]]); + text = text.slice(1).replace(/^\s*\+?\s*/, ''); + } + const pieces = text === '+' ? ['+'] : text.split(/\s*\+\s*/).filter(Boolean); + if (text.endsWith('++')) pieces.push('+'); + for (let i = 0; i < pieces.length - 1; i++) { + const modifier = MODIFIERS[pieces[i].toLowerCase()]; + if (!modifier) return undefined; + parts.push(modifier); + } + const last = pieces[pieces.length - 1]; + if (!last) return undefined; + const key = KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last); + if (!KEY.test(key)) return undefined; + return [...new Set(parts), key].join('+'); +} + +function acceleratorOf(el: HTMLElement): string | undefined { + const hint = el.querySelector('[data-menu-shortcut], kbd')?.textContent; + return hint ? toAccelerator(hint) : undefined; } function isHidden(el: Element): boolean { @@ -100,10 +159,12 @@ export function readMenu(root: HTMLElement): ReadMenu { const id = `i${next++}`; elements.set(id, child); const role = child.getAttribute('role'); + const accelerator = acceleratorOf(child); into.push({ id, label, enabled: !isDisabled(child), + ...(accelerator ? { accelerator } : {}), ...(role === 'menuitemcheckbox' || role === 'menuitemradio' ? { type: role === 'menuitemcheckbox' ? 'checkbox' as const : 'radio' as const, checked: child.getAttribute('aria-checked') === 'true' } : {}), @@ -182,6 +243,74 @@ export async function showNativeMenu(read: ReadMenu, root: HTMLElement, at?: { x return 'done'; } +/** + * The system's menu font and the room a native menu adds around its words, in screen + * pixels: Chromium's menus on Windows and Linux, AppKit's on macOS. Measured off the + * real menus at 100 %; an estimate within a few pixels is all a placement needs. + */ +const NATIVE_METRICS = { + win32: { font: '12px "Segoe UI", system-ui, sans-serif', padding: 74, keyGap: 28, charWidth: 6.2 }, + darwin: { font: '14px -apple-system, system-ui, sans-serif', padding: 48, keyGap: 24, charWidth: 7.2 }, + linux: { font: '13px system-ui, sans-serif', padding: 70, keyGap: 28, charWidth: 6.8 }, +} as const; + +let measureContext: CanvasRenderingContext2D | null | undefined; + +function textWidth(text: string, font: string, charWidth: number): number { + if (measureContext === undefined) { + try { + // jsdom has no canvas, and says so on the console. + measureContext = typeof document === 'undefined' || /jsdom/i.test(navigator.userAgent) + ? null + : document.createElement('canvas').getContext('2d'); + } catch { + measureContext = null; + } + } + if (measureContext) { + measureContext.font = font; + const width = measureContext.measureText(text).width; + if (Number.isFinite(width) && width > 0) return width; + } + return text.length * charWidth; +} + +/** + * How wide the native menu for these items will be, in the page's CSS pixels. A native + * menu is drawn at the screen's scale, not the page's zoom: at 150 % zoom it takes up + * less of the page. outerWidth is in screen pixels and innerWidth in CSS pixels, which + * gives the zoom in a window with no frame. + */ +export function estimateNativeMenuWidth(items: DesktopNativeMenuItem[], platform = getDesktopBridge()?.platform ?? 'win32'): number { + const metrics = NATIVE_METRICS[platform] ?? NATIVE_METRICS.win32; + let widest = 0; + for (const item of items) { + if (item.type === 'separator' || !item.label) continue; + const key = item.accelerator ? metrics.keyGap + textWidth(item.accelerator, metrics.font, metrics.charWidth) : 0; + widest = Math.max(widest, textWidth(item.label, metrics.font, metrics.charWidth) + key); + } + const screenWidth = widest + metrics.padding; + const zoom = typeof window !== 'undefined' && window.innerWidth > 0 && window.outerWidth > 0 + ? Math.min(5, Math.max(0.25, window.outerWidth / window.innerWidth)) + : 1; + return Math.round(screenWidth / zoom); +} + +/** + * Where a native menu from a button opens, in page coordinates: its top left corner, + * under the button and lined up with the edge the HTML menu would use. Only that: the + * OS keeps a native menu on the screen itself, and it may reach past the window. + */ +export function nativeMenuPoint( + anchor: { left: number; right: number; bottom: number; align?: 'start' | 'end' }, + menuWidth: number, + rtl: boolean, + gap: number, +): { x: number; y: number } { + const alignEnd = (anchor.align === 'end') !== rtl; + return { x: Math.max(0, Math.round(alignEnd ? anchor.right - menuWidth : anchor.left)), y: Math.round(anchor.bottom + gap) }; +} + /** 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/types.ts b/lib/desktop/types.ts index 69cbd18c0..1a36ccf10 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -84,6 +84,11 @@ export interface DesktopNativeMenuItem { type?: 'normal' | 'separator' | 'checkbox' | 'radio'; enabled?: boolean; checked?: boolean; + /** + * The key the page answers for this item, in Electron's accelerator syntax ("D", + * "Shift+I", "Ctrl+X"). Only shown, apart from the label: never registered. + */ + accelerator?: string; submenu?: DesktopNativeMenuItem[]; } diff --git a/lib/menu-placement.ts b/lib/menu-placement.ts new file mode 100644 index 000000000..a8397d1ee --- /dev/null +++ b/lib/menu-placement.ts @@ -0,0 +1,83 @@ +/** + * Where a menu goes: next to what opened it, inside the window. + * + * - Opened from a button (an anchor): under it, lined up with its start edge, or its + * end edge for a button at the end of a toolbar. In RTL start and end swap. When it + * doesn't fit on that side it lines up with the other edge, and when there's no room + * below it goes above. + * - Opened by a right-click (a point): its corner at the pointer, opening to the right + * and down (to the left in RTL), and towards the other side where there's no room, as + * the OS does with its own menus. + * + * Whatever is left over is clamped into the window. A menu taller than the window + * scrolls inside (the menu caps its own height). + */ + +export interface MenuAnchorRect { + left: number; + top: number; + right: number; + bottom: number; + /** Line up with the anchor's start (default) or end edge. */ + align?: "start" | "end"; +} + +export interface MenuPlacementInput { + /** Where a right-click happened. Ignored when there's an anchor. */ + point: { x: number; y: number }; + anchor?: MenuAnchorRect; + size: { width: number; height: number }; + viewport: { width: number; height: number }; + rtl?: boolean; + /** Room kept free along the window's edges. */ + margin?: number; + /** Between the anchor and the menu. */ + gap?: number; +} + +export const MENU_VIEWPORT_MARGIN = 10; +export const MENU_ANCHOR_GAP = 4; + +function clamp(value: number, size: number, room: number, margin: number): number { + return Math.max(margin, Math.min(value, room - size - margin)); +} + +export function placeMenu({ + point, + anchor, + size, + viewport, + rtl = false, + margin = MENU_VIEWPORT_MARGIN, + gap = MENU_ANCHOR_GAP, +}: MenuPlacementInput): { x: number; y: number } { + const { width: w, height: h } = size; + const { width: vw, height: vh } = viewport; + const fitsX = (x: number) => x >= margin && x + w <= vw - margin; + const fitsY = (y: number) => y >= margin && y + h <= vh - margin; + + let x: number; + let y: number; + if (anchor) { + const alignEnd = (anchor.align === "end") !== rtl; + const startEdge = anchor.left; + const endEdge = anchor.right - w; + const preferred = alignEnd ? endEdge : startEdge; + const other = alignEnd ? startEdge : endEdge; + x = fitsX(preferred) || !fitsX(other) ? preferred : other; + const below = anchor.bottom + gap; + const above = anchor.top - gap - h; + if (fitsY(below)) y = below; + else if (fitsY(above)) y = above; + // Neither fits whole: the side with more room, and the menu scrolls. + else y = vh - anchor.bottom >= anchor.top ? below : above; + } else { + const towardEnd = rtl ? point.x - w : point.x; + const towardStart = rtl ? point.x : point.x - w; + x = fitsX(towardEnd) || !fitsX(towardStart) ? towardEnd : towardStart; + const down = point.y; + const up = point.y - h; + y = fitsY(down) || !fitsY(up) ? down : up; + } + return { x: clamp(x, w, vw, margin), y: clamp(y, h, vh, margin) }; +} From f44be446bb29ac334e13987a7085a812e35fe220 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:09:06 +0100 Subject: [PATCH 02/20] fix: show the keys for reply, delete, star and the rest in the message menu --- .../email-context-menu-shortcuts.test.tsx | 89 +++++++++++++++++++ components/email/email-context-menu.tsx | 7 ++ components/email/message-action-shortcuts.ts | 39 ++++++++ 3 files changed, 135 insertions(+) create mode 100644 components/email/__tests__/email-context-menu-shortcuts.test.tsx create mode 100644 components/email/message-action-shortcuts.ts diff --git a/components/email/__tests__/email-context-menu-shortcuts.test.tsx b/components/email/__tests__/email-context-menu-shortcuts.test.tsx new file mode 100644 index 000000000..062e60278 --- /dev/null +++ b/components/email/__tests__/email-context-menu-shortcuts.test.tsx @@ -0,0 +1,89 @@ +import { render, screen, within } from '@testing-library/react'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useSettingsStore } from '@/stores/settings-store'; + +vi.mock('@/components/plugins/plugin-slot', () => ({ + PluginSlot: () => null, +})); + +import { EmailContextMenu } from '../email-context-menu'; +import { messageActionShortcut } from '../message-action-shortcuts'; + +const email = { + id: 'e1', + blobId: 'b1', + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: {}, + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Hello', + receivedAt: '2026-01-01T00:00:00Z', + hasAttachment: false, + size: 100, +} as unknown as Email; + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +function renderMenu(props: Partial> = {}) { + render( + , + ); +} + +const row = (name: string) => screen.getByText(name).closest('button') as HTMLElement; + +describe('message menu key hints', () => { + afterEach(() => useSettingsStore.setState({ singleKeyShortcuts: true })); + + it('shows the keys the list runs each action with', () => { + renderMenu(); + expect(within(row('reply')).getByText('R')).toBeInTheDocument(); + expect(within(row('reply_all')).getByText('Shift+R')).toBeInTheDocument(); + expect(within(row('forward')).getByText('F')).toBeInTheDocument(); + expect(within(row('star')).getByText('S')).toBeInTheDocument(); + expect(within(row('mark_read')).getByText('Shift+I')).toBeInTheDocument(); + expect(within(row('mark_as_spam')).getByText('!')).toBeInTheDocument(); + expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument(); + }); + + it('leaves single keys out when they are turned off, and keeps Del', () => { + useSettingsStore.setState({ singleKeyShortcuts: false }); + renderMenu(); + expect(within(row('reply')).queryByText('R')).toBeNull(); + expect(within(row('star')).queryByText('S')).toBeNull(); + expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument(); + }); + + it('shows them on a selection too', () => { + renderMenu({ isMultiSelect: true, selectedCount: 2, onBatchDelete: vi.fn(), onBatchMarkAsRead: vi.fn(), onBatchMarkAsSpam: vi.fn() }); + expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument(); + expect(within(screen.getByTestId('ctx-mark-read')).getByText('Shift+I')).toBeInTheDocument(); + expect(within(screen.getByTestId('ctx-spam')).getByText('!')).toBeInTheDocument(); + }); + + it('matches the read state: U marks a read message unread', () => { + expect(messageActionShortcut('read', { unread: false, singleKeys: true })).toBe('U'); + expect(messageActionShortcut('read', { unread: true, singleKeys: true })).toBe('Shift+I'); + expect(messageActionShortcut('print', { singleKeys: true })).toBeUndefined(); + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index a27531ead..deaf6e91e 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -30,6 +30,7 @@ import { Fish, } from "@/components/icons"; import { useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions"; +import { useMessageActionShortcut } from "./message-action-shortcuts"; import { buildMailboxTree, MailboxNode } from "@/lib/utils"; import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; import { localizeMailboxName } from "@/lib/mailbox-label"; @@ -198,6 +199,7 @@ export function EmailContextMenu({ label, }: EmailContextMenuProps) { const t = useTranslations("context_menu"); + const shortcutOf = useMessageActionShortcut(); const archiveActionEnabled = useArchiveActionEnabled(); const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); const tSidebar = useTranslations("sidebar"); @@ -356,6 +358,7 @@ export function EmailContextMenu({ handleAction(onBatchArchive!)} disabled={!onBatchArchive} /> @@ -368,6 +371,7 @@ export function EmailContextMenu({ icon={Trash2} label={t("delete")} testId="ctx-delete" + shortcut={shortcutOf("delete")} onClick={() => handleAction(onBatchDelete!)} disabled={!onBatchDelete} destructive={!mountainView} @@ -381,6 +385,7 @@ export function EmailContextMenu({ icon={isUnread ? MailOpen : Mail} label={isUnread ? t("mark_read") : t("mark_unread")} testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"} + shortcut={shortcutOf("read", isUnread)} onClick={() => handleAction(() => onBatchMarkAsRead?.(isUnread))} /> @@ -457,6 +462,7 @@ export function EmailContextMenu({ icon={isInJunkFolder ? ShieldCheck : ShieldAlert} label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")} testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"} + shortcut={shortcutOf("spam")} onClick={() => handleAction(isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)} disabled={isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam} destructive={!isInJunkFolder && !mountainView} @@ -511,6 +517,7 @@ export function EmailContextMenu({ testId={entry.testId} disabled={entry.disabled} destructive={entry.destructive} + shortcut={shortcutOf(entry.key, isUnread)} onClick={() => handleAction(entry.run)} /> ); diff --git a/components/email/message-action-shortcuts.ts b/components/email/message-action-shortcuts.ts new file mode 100644 index 000000000..ce9efe28c --- /dev/null +++ b/components/email/message-action-shortcuts.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useCallback } from "react"; +import { useSettingsStore } from "@/stores/settings-store"; +import type { MessageActionKey } from "./message-actions"; + +/** + * The keys the mail list runs a message's actions with (hooks/use-keyboard-shortcuts.ts), + * as the message menus show them next to the entries. The native Message menu shows the + * same ones. A single key goes when the user turned single-key shortcuts off, since it + * does nothing then. + */ +const KEYS: Partial> = { + reply: { key: "R", single: true }, + reply_all: { key: "Shift+R", single: true }, + forward: { key: "F", single: true }, + archive: { key: "E", single: true }, + delete: { key: "Del", single: false }, + star: { key: "S", single: true }, + spam: { key: "!", single: true }, +}; + +export function messageActionShortcut( + key: MessageActionKey, + options: { unread?: boolean; singleKeys: boolean }, +): string | undefined { + const entry = key === "read" + ? { key: options.unread ? "Shift+I" : "U", single: true } + : KEYS[key]; + if (!entry) return undefined; + if (entry.single && !options.singleKeys) return undefined; + return entry.key; +} + +/** `messageActionShortcut` with the user's single-key setting. */ +export function useMessageActionShortcut(): (key: MessageActionKey, unread?: boolean) => string | undefined { + const singleKeys = useSettingsStore((state) => state.singleKeyShortcuts !== false); + return useCallback((key, unread) => messageActionShortcut(key, { unread, singleKeys }), [singleKeys]); +} From 551b5ce4fa90412e39dbd23bcd2f2dafe95ad15e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:09:09 +0100 Subject: [PATCH 03/20] fix: name the account sign out leaves in the command palette, add star, hide dead key hints --- .../__tests__/command-palette.test.tsx | 35 +++++++++++++++++++ .../__tests__/mail-palette-commands.test.ts | 28 +++++++++++++++ .../command-palette/command-palette.tsx | 21 ++++++++--- components/mail/mail-app.tsx | 6 +++- lib/single-key-shortcuts.ts | 9 +++++ 5 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 components/command-palette/__tests__/mail-palette-commands.test.ts diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx index 2bd4fae1e..927e2b174 100644 --- a/components/command-palette/__tests__/command-palette.test.tsx +++ b/components/command-palette/__tests__/command-palette.test.tsx @@ -119,6 +119,41 @@ describe('command palette', () => { expect(hint('search')).toBe(''); }); + it('shows no single-key hints while single-key shortcuts are off, since those keys do nothing then', async () => { + const { useSettingsStore } = await import('@/stores/settings-store'); + const hint = (label: string) => { + const row = screen.getAllByRole('option').find((o) => o.querySelector('.flex-1')?.textContent === label)!; + return [...row.querySelectorAll('kbd')].map((k) => k.textContent).join(' '); + }; + useSettingsStore.setState({ singleKeyShortcuts: false }); + try { + render(); + pressCtrlK(); + expect(hint('compose')).toBe(''); + expect(hint('shortcuts')).toBe(''); + } finally { + useSettingsStore.setState({ singleKeyShortcuts: true }); + } + }); + + it('names the account Sign out leaves, as the account menu does', async () => { + const { useAccountStore } = await import('@/stores/account-store'); + const before = useAccountStore.getState().accounts; + useAccountStore.setState({ accounts: [ + { id: 'a1', email: 'one@example.org', username: 'one@example.org' }, + { id: 'a2', email: 'two@example.org', username: 'two@example.org' }, + ] } as never); + useAuthStore.setState({ activeAccountId: 'a2' } as never); + try { + render(); + pressCtrlK(); + expect(screen.getByText('sign_out_of(two@example.org)')).toBeInTheDocument(); + expect(screen.queryByText('sign_out')).toBeNull(); + } finally { + useAccountStore.setState({ accounts: before } as never); + } + }); + it('shows plugin entries with the plugin name and runs them with Enter', async () => { act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }])); render(); diff --git a/components/command-palette/__tests__/mail-palette-commands.test.ts b/components/command-palette/__tests__/mail-palette-commands.test.ts new file mode 100644 index 000000000..247954e94 --- /dev/null +++ b/components/command-palette/__tests__/mail-palette-commands.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// Mail registers its palette commands in mail-app.tsx, which is too big to render +// here. Each message action with a key in the shortcuts help is in the palette too, +// under the key the help names. +const source = readFileSync(path.join(process.cwd(), 'components/mail/mail-app.tsx'), 'utf8'); +const block = source.slice(source.indexOf("useRegisterCommands(t('sidebar.mail')")); + +describe('Mail commands in the palette', () => { + it.each([ + ['reply', 'r'], + ['reply-all', 'a'], + ['forward', 'f'], + ['star', 's'], + ['archive', 'e'], + ['delete', '#'], + ['mark-unread', 'u'], + ['mark-read', 'Shift + I'], + ['spam', '!'], + ])('has %s, shown with %s', (id, key) => { + const at = block.indexOf(`id: '${id}'`); + expect(at).toBeGreaterThan(-1); + const entry = block.slice(at, block.indexOf('}', at)); + expect(entry).toContain(`shortcut: '${key}'`); + }); +}); diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx index 058d4a448..8169769bd 100644 --- a/components/command-palette/command-palette.tsx +++ b/components/command-palette/command-palette.tsx @@ -27,6 +27,7 @@ import { useGlobalSearchStore } from "@/stores/global-search-store"; import { useCalendarStore } from "@/stores/calendar-store"; import { usePolicyStore } from "@/stores/policy-store"; import { useThemeStore } from "@/stores/theme-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { usePluginActions } from "@/components/plugins/plugin-actions"; import { usePluginPages, pluginPageHref } from "@/hooks/use-plugin-pages"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; @@ -38,7 +39,7 @@ import { commandMatches, useRegisteredCommandGroups } from "@/lib/command-regist import { shortcutAlternatives } from "@/lib/shortcut-keys"; import { toUnicodeEmail } from "@/lib/idn"; import { isEditableEventTarget, isKeyboardLayerOpen } from "@/lib/keyboard"; -import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; +import { isSingleKeyHint, isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useMediaQuery } from "@/hooks/use-media-query"; import { IconButton } from "@/components/ui/icon-button"; @@ -162,6 +163,9 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS const activeAccountId = useAuthStore((s) => s.activeAccountId); // A touch screen has no keyboard to use the shortcuts with (R8). const touch = useMediaQuery("(pointer: coarse)"); + // With single-key shortcuts off, "r" or "#" does nothing: no hint for it. + const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts !== false); + const hintKeys = (shortcut: string) => shortcutAlternatives(shortcut).filter((key) => singleKeys || !isSingleKeyHint(key)); const inputRef = useRef(null); const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true, initialFocus: () => inputRef.current }); @@ -255,7 +259,16 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS run: () => { void useAuthStore.getState().switchAccount(entry.id); }, }); } - list.push({ id: "sign-out", group: account, icon: LogOut, label: tSidebar("sign_out"), run: () => { void useAuthStore.getState().logout(); } }); + // It signs out of the open account only, as the account menu's entry says. + const current = accounts.find((entry) => entry.id === activeAccountId); + const currentName = current ? toUnicodeEmail(current.email || current.username) : ""; + list.push({ + id: "sign-out", + group: account, + icon: LogOut, + label: currentName ? tSidebar("sign_out_of", { account: currentName }) : tSidebar("sign_out"), + run: () => { void useAuthStore.getState().logout(); }, + }); for (const action of pluginActions) { list.push({ id: `plugin:${action.pluginId}:${action.id}`, @@ -394,10 +407,10 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS {command.source && ( {command.source} )} - {command.shortcut && ( + {command.shortcut && hintKeys(command.shortcut).length > 0 && ( // Key hints only where there is a keyboard (R8). diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 11d4246c0..e22795c00 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -136,7 +136,7 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { openMailImport } from "@/stores/mail-transfer-store"; import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; -import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons"; +import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Star } from "@/components/icons"; import { useRegisterCommands } from "@/lib/command-registry"; import { MoveToDialog } from "@/components/email/move-to-dialog"; import { ResizeHandle } from "@/components/layout/resize-handle"; @@ -4813,6 +4813,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { when: () => !!paletteEmail && !paletteEmail.keywords?.$seen, run: () => { if (client && paletteEmail) void markReadWithUndo(paletteEmail, true).catch(toastUpdateFailure); }, }, + { + id: 'star', label: paletteEmail?.keywords?.$flagged ? t('context_menu.unstar') : t('context_menu.star'), icon: Star, shortcut: 's', + when: hasPaletteEmail, run: () => void handleToggleStar(), + }, { id: 'spam', label: conversationInJunk ? t('context_menu.not_spam') : t('context_menu.mark_as_spam'), icon: conversationInJunk ? ShieldCheck : ShieldAlert, shortcut: '!', diff --git a/lib/single-key-shortcuts.ts b/lib/single-key-shortcuts.ts index e3e8477d8..26cba959c 100644 --- a/lib/single-key-shortcuts.ts +++ b/lib/single-key-shortcuts.ts @@ -10,3 +10,12 @@ import { useSettingsStore } from "@/stores/settings-store"; export function isSingleKeyShortcutOff(event: Pick): boolean { return isSingleCharacterKey(event) && useSettingsStore.getState().singleKeyShortcuts === false; } + +/** + * A key hint as the help and menus write it ("r", "Shift + I", "#") that's a single-key + * shortcut, so it does nothing while those are off. Arrows aren't characters. + */ +export function isSingleKeyHint(combo: string): boolean { + const key = combo.replace(/^(shift\s*\+\s*)+/i, "").trim(); + return [...key].length === 1 && !/[←→↑↓]/.test(key); +} From cb183bc45f798a1565571388357da65fca7bb001 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:11:51 +0100 Subject: [PATCH 04/20] test: check that no text is cut off at common window sizes and zoom levels --- integration/tests/25-do-layout-fit.spec.ts | 149 +++++++++++++++++++++ integration/tests/helpers/layout-fit.ts | 80 +++++++++++ 2 files changed, 229 insertions(+) create mode 100644 integration/tests/25-do-layout-fit.spec.ts create mode 100644 integration/tests/helpers/layout-fit.ts diff --git a/integration/tests/25-do-layout-fit.spec.ts b/integration/tests/25-do-layout-fit.spec.ts new file mode 100644 index 000000000..77ec19577 --- /dev/null +++ b/integration/tests/25-do-layout-fit.spec.ts @@ -0,0 +1,149 @@ +/** + * Nothing cut off at common window sizes: the main screens at 1280×720 and 1366×768, + * and at 125 % and 150 % zoom (the same windows in CSS pixels), in English, German + * (long words) and Arabic (right to left). Each screen fails on a line of text that + * runs past the edge of the box it sits in or hides behind a scroll bar + * (helpers/layout-fit.ts). The message details panel also has to give the sender's + * address room and show its dates on one line. + * + * Runs as dso-menus@example.org on Stalwart 1.0: check or create the account on the + * VM first (~/desktop-offline/dso_menus_provision.py stw-v1, then + * dso_menus_authmail.py stw-v1 for the message with long sign-in results), then + * desktop-offline-rig/run-spec.sh with IT_DSO_PASSWORD from the vault item + * "Stalwart test – dso-* throwaway accounts". Reads only; changes nothing. + * IT_LAYOUT_SHOTS: a folder for a screenshot of each screen. IT_LAYOUT_SCREENS=mail: + * the mail screens only. + */ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { requireEntEnv } from './helpers/ent'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; +import { WEBMAIL_URL } from './helpers/config'; +import { findCutOffText, type CutOff } from './helpers/layout-fit'; + +requireEntEnv(); +test.setTimeout(600_000); + +const USER = 'dso-menus@example.org'; +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +if (!PASSWORD) throw new Error('Set IT_DSO_PASSWORD, and check the account on the VM first: see the top of this file'); +const AUTH_SUBJECT = 'Your weekly account summary is ready for review'; +const SHOTS = process.env.IT_LAYOUT_SHOTS ?? ''; +const MAIL_ONLY = process.env.IT_LAYOUT_SCREENS === 'mail'; + +const SETTINGS_TABS = ['account', 'security', 'notifications', 'general', 'appearance', 'accessibility', 'reading', + 'composing', 'organize', 'filters', 'vacation', 'calendar', 'contacts', 'files', 'backup']; + +interface Scenario { name: string; locale: string; language: string; width: number; height: number } + +const SCENARIOS: Scenario[] = [ + { name: '1280x720-en', locale: 'en-US', language: 'en', width: 1280, height: 720 }, + { name: '1366x768-de', locale: 'de-DE', language: 'de', width: 1366, height: 768 }, + // 1280×720 at 125 % and 1366×768 at 150 %: the same window in CSS pixels. + { name: '1280x720-125-de', locale: 'de-DE', language: 'de', width: 1024, height: 576 }, + { name: '1366x768-150-de', locale: 'de-DE', language: 'de', width: 911, height: 512 }, + { name: '1280x720-ar', locale: 'ar', language: 'ar', width: 1280, height: 720 }, +]; + +function viewerText(language: string): Record { + const file = path.join(__dirname, '..', '..', 'locales', language, 'common.json'); + const viewer = JSON.parse(readFileSync(file, 'utf-8')).email_viewer; + return { showDetails: viewer.show_details, routing: viewer.details.recipients_routing }; +} + +async function signIn(browser: Browser, scenario: Scenario): Promise { + const context = await browser.newContext({ + baseURL: WEBMAIL_URL, + locale: scenario.locale, + viewport: { width: scenario.width, height: scenario.height }, + }); + const page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.fill('#username', USER); + await page.fill('#password', PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); + return page; +} + +async function settle(page: Page): Promise { + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); + await page.waitForTimeout(800); +} + +async function check(page: Page, scenario: Scenario, screen: string, found: Map): Promise { + await settle(page); + if (SHOTS) { + if (!existsSync(SHOTS)) mkdirSync(SHOTS, { recursive: true }); + await page.screenshot({ path: path.join(SHOTS, `${screen}-${scenario.name}.png`) }); + } + const cut = await findCutOffText(page); + if (cut.length) found.set(screen, cut); +} + +function report(found: Map): string { + return [...found].map(([screen, list]) => `${screen}:\n${list.map((c) => ` ${c.px}px ${c.where} "${c.text}"`).join('\n')}`).join('\n'); +} + +for (const scenario of SCENARIOS) { + test(`nothing is cut off at ${scenario.name}`, async ({ browser }) => { + const page = await signIn(browser, scenario); + const text = viewerText(scenario.language); + const found = new Map(); + + // Mail: the list, a message with long sign-in results and its details open. + const item = page.locator('[data-testid="email-list-item"]', { hasText: AUTH_SUBJECT }).first(); + await item.waitFor({ state: 'visible', timeout: 60000 }); + await check(page, scenario, 'mail-list', found); + await item.click(); + await page.getByRole('button', { name: text.showDetails }).first().click(); + const routing = page.locator('section', { has: page.getByText(text.routing, { exact: true }) }).first(); + await expect(routing).toBeVisible(); + await check(page, scenario, 'mail-details', found); + + // The sender's address gets a column of its own width, not a sliver, and a date + // takes one line. Rows: From, To, Sent, Received. + const fit = await routing.evaluate((section) => { + const values = Array.from(section.querySelectorAll('dl > dd')); + const from = values[0].getBoundingClientRect(); + const sent = values[values.length - 2]; + const lineHeight = parseFloat(getComputedStyle(sent).lineHeight) || 20; + return { fromWidth: Math.round(from.width), sentLines: Math.round(sent.getBoundingClientRect().height / lineHeight) }; + }); + expect.soft(fit.fromWidth, 'width of the From value in the details panel').toBeGreaterThanOrEqual(220); + expect.soft(fit.sentLines, 'lines the Sent date takes in the details panel').toBeLessThanOrEqual(1); + + if (scenario.language === 'ar' || MAIL_ONLY) { + expect(found, report(found)).toEqual(new Map()); + return; + } + + // Composer. + await page.keyboard.press('Escape'); + await page.keyboard.press('c'); + await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 }); + await check(page, scenario, 'composer', found); + await page.keyboard.press('Escape'); + + // Calendar, contacts, files. + await page.goto('/calendar'); + await page.waitForTimeout(1500); + await page.keyboard.press('w'); + await check(page, scenario, 'calendar-week', found); + await page.keyboard.press('m'); + await check(page, scenario, 'calendar-month', found); + await page.goto('/contacts'); + await check(page, scenario, 'contacts', found); + await page.goto('/files'); + await check(page, scenario, 'files', found); + + for (const tab of SETTINGS_TABS) { + await page.goto(`/settings/${tab}`); + await check(page, scenario, `settings-${tab}`, found); + } + + expect(found, report(found)).toEqual(new Map()); + }); +} diff --git a/integration/tests/helpers/layout-fit.ts b/integration/tests/helpers/layout-fit.ts new file mode 100644 index 000000000..127b2d785 --- /dev/null +++ b/integration/tests/helpers/layout-fit.ts @@ -0,0 +1,80 @@ +/** + * Finds text that's cut off at the side: a line of text that runs past the edge of + * the nearest box that clips it (overflow other than visible), or past the window + * when nothing clips it. A box's vertical scroll bar counts as its edge, so text + * hidden behind a scroll bar is found too. + * + * Not counted: text that's meant to be cut (text-overflow: ellipsis, line clamp), + * text inside a box that scrolls only sideways, text that's wholly outside its box (a + * closed drawer, a slide), text in a box 1-2px wide (visually hidden), screen-reader-only text, and anything marked + * data-layout-fit-ignore. Frames (a message body) aren't looked into. + */ +import type { Page } from '@playwright/test'; + +export interface CutOff { + /** Tag, test id and a few classes of the element holding the text. */ + where: string; + text: string; + /** How many CSS pixels are cut off. */ + px: number; +} + +export async function findCutOffText(page: Page): Promise { + return page.evaluate(() => { + const out: { where: string; text: string; px: number }[] = []; + const seen = new Set(); + const viewport = { left: 0, right: document.documentElement.clientWidth }; + const clipsX = (s: CSSStyleDeclaration) => s.overflowX !== 'visible'; + // A sideways scroller on purpose: one that scrolls sideways only. A box that + // scrolls up and down computes overflow-x: auto as well, and text running off + // its side is still cut off for anyone who doesn't think to scroll sideways. + const scrollsX = (s: CSSStyleDeclaration) => + (s.overflowX === 'auto' || s.overflowX === 'scroll') && s.overflowY !== 'auto' && s.overflowY !== 'scroll'; + const describe = (el: Element) => { + const id = el.getAttribute('data-testid'); + const cls = (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean).slice(0, 4).join('.'); + return `${el.tagName.toLowerCase()}${id ? `[data-testid=${id}]` : ''}${cls ? `.${cls}` : ''}`; + }; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim(); + const el = node.parentElement; + if (!text || !el || seen.has(el)) continue; + if (el.closest('script, style, noscript, [aria-hidden="true"], .sr-only, [data-layout-fit-ignore]')) continue; + const own = getComputedStyle(el); + if (own.visibility === 'hidden' || own.display === 'none' || Number(own.opacity) === 0) continue; + const range = document.createRange(); + range.selectNodeContents(node); + const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0.5 && r.height > 0.5); + if (rects.length === 0) continue; + const textLeft = Math.min(...rects.map((r) => r.left)); + const textRight = Math.max(...rects.map((r) => r.right)); + + let meant = false; + let box: { left: number; right: number } | null = null; + for (let at: Element | null = el; at && at !== document.documentElement; at = at.parentElement) { + const s = getComputedStyle(at); + if (s.textOverflow === 'ellipsis' || (s.webkitLineClamp && s.webkitLineClamp !== 'none')) meant = true; + if (Number(s.opacity) === 0 || s.visibility === 'hidden') { meant = true; break; } + if (!clipsX(s)) continue; + if (scrollsX(s)) { meant = true; break; } + // A 1px box is the visually-hidden pattern: read out, not shown. + if (at.clientWidth <= 2) { meant = true; break; } + const r = at.getBoundingClientRect(); + const left = r.left + at.clientLeft; + box = { left, right: left + at.clientWidth }; + break; + } + if (meant) continue; + const edge = box ?? viewport; + // Wholly outside: not cut, put away. + if (textRight <= edge.left || textLeft >= edge.right) continue; + const px = Math.max(textRight - edge.right, edge.left - textLeft); + if (px > 1.5) { + seen.add(el); + out.push({ where: describe(el), text: text.slice(0, 60), px: Math.round(px) }); + } + } + return out; + }); +} From 2525c27a7d3dbf01ad5bc227d37376657ee23e58 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:19:08 +0100 Subject: [PATCH 05/20] fix: group the message menus and keep app-level items out of the reader's more menu The reader's more menu no longer offers Star while the star sits by the subject, so the tests that expected Star in the menu and the phone panel now expect it gone. Import mail moved to the command palette (it stays in the folder menu and Settings); Keyboard shortcuts stay behind ?, the rail and Help. --- .../__tests__/message-action-list.test.tsx | 47 +++++++++++++++++-- components/email/email-context-menu.tsx | 6 ++- components/email/email-viewer.tsx | 26 ++++------ components/email/message-actions.ts | 24 +++++++--- .../__tests__/mail-palette-commands.test.tsx | 13 +++++ components/mail/mail-app.tsx | 8 +++- 6 files changed, 92 insertions(+), 32 deletions(-) diff --git a/components/email/__tests__/message-action-list.test.tsx b/components/email/__tests__/message-action-list.test.tsx index 9233cd61f..3abd6d75a 100644 --- a/components/email/__tests__/message-action-list.test.tsx +++ b/components/email/__tests__/message-action-list.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, act, within } from '@testing-library/react'; +import { render, renderHook, screen, fireEvent, act, within } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Email, Mailbox } from '@/lib/jmap/types'; @@ -28,6 +28,7 @@ const { EmailViewer } = await import('../email-viewer'); const { EmailContextMenu } = await import('../email-context-menu'); const { usePrintRequestStore, requestPrint } = await import('@/hooks/use-message-file-actions'); const { emailKeyFor } = await import('@/lib/thread-utils'); +const { useMessageActionList } = await import('../message-actions'); const email = { id: 'e1', @@ -112,17 +113,53 @@ describe("the reader's ⋮ on a desktop", () => { renderViewer({ onTogglePinned }); fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); const menu = screen.getByRole('menu', { name: 'more_actions' }); - for (const id of ['ctx-star', 'ctx-move-to', 'ctx-tag', 'ctx-export', 'ctx-print', 'ctx-view-source']) { + for (const id of ['ctx-move-to', 'ctx-tag', 'ctx-export', 'ctx-print', 'ctx-view-source']) { expect(within(menu).getByTestId(id)).toBeTruthy(); } - // Delete is on the toolbar already. + // Delete is on the toolbar already, and the star by the subject. expect(within(menu).queryByTestId('ctx-delete')).toBeNull(); + expect(within(menu).queryByTestId('ctx-star')).toBeNull(); fireEvent.click(within(menu).getByRole('menuitem', { name: 'pin' })); expect(onTogglePinned).toHaveBeenCalledTimes(1); expect(screen.queryByRole('menu', { name: 'more_actions' })).toBeNull(); }); }); +describe("the reader's ⋮ in groups", () => { + it("has the message's actions only, in groups", () => { + renderViewer({ onTogglePinned: vi.fn(), onShowShortcuts: vi.fn() } as never); + fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); + const menu = screen.getByRole('menu', { name: 'more_actions' }); + const rows = Array.from(menu.querySelectorAll('[role="menuitem"], [role="separator"]')) + .map((row) => (row.getAttribute('role') === 'separator' ? '|' : row.querySelector('[data-menu-label]')?.textContent ?? '')); + // No app-level entries: Import is in the folder's menu, Settings and the + // palette, the shortcuts behind ?. Answer; file away; mark; spam; link; + // how it shows; save. + expect(rows).toEqual([ + 'reply', 'reply_all', 'forward', 'forward_as_attachment', '|', + 'archive', '|', + 'move_to', '|', + 'mark_unread', 'pin', 'tag', '|', + 'mark_as_spam', '|', + 'copy_message', 'copy_conversation', '|', + 'reader_view', '|', + 'export_email', 'print', 'view_source', + ]); + }); + + it('puts how the reader shows the message in a group before Export, Print and View source', () => { + const { result } = renderHook(() => useMessageActionList({ + email, + onExport: () => {}, + onPrint: () => {}, + views: [{ kind: 'item', key: 'extra:reader_view', icon: () => null, label: 'reader_view', run: () => {} }], + extras: [{ kind: 'item', key: 'extra:other', icon: () => null, label: 'other', run: () => {} }], + })); + const labels = result.current.map((entry) => (entry.kind === 'separator' ? '|' : entry.label)); + expect(labels.slice(-7)).toEqual(['|', 'reader_view', '|', 'export_email', 'print', '|', 'other']); + }); +}); + describe("the reader toolbar's Move and Tag menus", () => { it('are the ContextMenu: in a portal, with its rows and keys', () => { renderViewer(); @@ -149,7 +186,9 @@ describe("the phone's More panel", () => { const panel = screen.getByRole('menu', { name: 'more_actions' }); const pin = within(panel).getByTestId('panel-pin'); expect(pin.className).toContain('min-h-[44px]'); - for (const id of ['panel-star', 'panel-export', 'panel-print', 'panel-view_source', 'panel-copy_message_link']) { + // The star is by the subject, on the phone too. + expect(within(panel).queryByTestId('panel-star')).toBeNull(); + for (const id of ['panel-export', 'panel-print', 'panel-view_source', 'panel-copy_message_link']) { expect(within(panel).getByTestId(id)).toBeTruthy(); } fireEvent.click(pin); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index a27531ead..b7205a3f6 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -131,7 +131,9 @@ interface EmailContextMenuProps { onViewSource?: () => void; /** Entries the host already shows elsewhere (the reader's toolbar). */ omit?: ReadonlySet; - /** The host's own entries after the message's (the reader's view toggles). */ + /** How the host shows the message (the reader's view toggles), before Export, Print and View source. */ + views?: MessageActionEntry[]; + /** The host's own entries after the message's. */ extras?: MessageActionEntry[]; /** The row's plugin entries (message-row-actions). Default true. */ rowPlugins?: boolean; @@ -192,6 +194,7 @@ export function EmailContextMenu({ onPrint, onViewSource, omit, + views, extras, rowPlugins = true, extraEntries, @@ -319,6 +322,7 @@ export function EmailContextMenu({ onPrint, onViewSource, omit, + views, extras, }); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0b1b5fac7..b8b4e3ede 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3,7 +3,6 @@ import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; -import { openMailImport } from "@/stores/mail-transfer-store"; import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { mailLanguage } from "@/lib/mail-language"; @@ -106,7 +105,6 @@ import { Send, FolderInput, Sun, - Upload, Moon, PlayCircle, PenSquare, @@ -278,7 +276,6 @@ interface EmailViewerProps { onReplyAllConversationEmail?: (email: Email) => void; /** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */ mvInlineComposer?: React.ReactNode; - onShowShortcuts?: () => void; onEditDraft?: () => void; onCancelScheduledForEdit?: () => void; /** Stop a scheduled message from going out; it goes back to Drafts. */ @@ -1157,6 +1154,7 @@ function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMo onPrint={options.onPrint} onViewSource={options.onViewSource} omit={options.omit} + views={options.views} extras={options.extras} rowPlugins={false} extraEntries={pluginEntries} @@ -1341,7 +1339,6 @@ export function EmailViewer({ onReplyConversationEmail, onReplyAllConversationEmail, mvInlineComposer, - onShowShortcuts, onEditDraft, onCancelScheduledForEdit, onCancelScheduledSend, @@ -1364,7 +1361,6 @@ export function EmailViewer({ const tWindows = useTranslations('pwaWindows'); const tComposer = useTranslations('email_composer'); const tNotifications = useTranslations('notifications'); - const tImport = useTranslations('mail_import'); const tCommon = useTranslations('common'); const tFiles = useTranslations('files'); const tDemoWelcome = useTranslations('demo_welcome'); @@ -3768,11 +3764,6 @@ export function EmailViewer({ await exportEmail(email, { onArchive, onDelete, archiveEnabled: archiveActionEnabled }, blobSource ?? { client, accountId: undefined }); }; - // Import mail into the open folder: the import dialog (lib/mail-import) does the rest. - const handleImportEmail = () => { - openMailImport({ mailboxId: useEmailStore.getState().selectedMailbox || undefined }); - }; - // What a printout of the message shows, for the Print action's window and // for the page's print copy (Ctrl+P). Text fields are unescaped. const messagePrintFields = (): MessagePrintFields | null => { @@ -4228,6 +4219,8 @@ ${printWatermark ? printWatermark.html : ''} // the bottom bar has the replies. const toolbarShownActions = (): ReadonlySet => { const shown = new Set(['delete', 'edit_draft']); + // The star sits by the subject, or on the sender's line in Mountain View. + if (toolbarPosition === 'top' || mvViewer) shown.add('star'); toolbarRef.current?.querySelectorAll('[data-overflow-item]').forEach((item) => { const key = TOOLBAR_PRIORITY_ACTIONS[item.dataset.overflowPriority ?? '']; if (key && item.getClientRects().length > 0) shown.add(key); @@ -4248,9 +4241,10 @@ ${printWatermark ? printWatermark.html : ''} return hidden; }; const toggleViewMode = () => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); - // The reader's own entries after the message's: the view toggle, Import - // (into the open folder) and, with a keyboard, the shortcuts. - const readerExtras: MessageActionEntry[] = [ + // How the reader shows the message, a group of the ⋮ menu of its own. Import + // and the keyboard shortcuts are no message's: Import is in the folder's menu, + // Settings and the command palette, the shortcuts behind ?, the rail and Help. + const readerViews: MessageActionEntry[] = [ ...(readAloud.supported && !isBodyLoading ? [{ kind: 'item' as const, key: 'extra:read_aloud' as const, icon: Volume2, label: t('read_aloud'), run: readAloud.start }] : []), @@ -4263,10 +4257,6 @@ ${printWatermark ? printWatermark.html : ''} ...(effectiveEmailContent.isHtml ? [{ kind: 'item' as const, key: 'extra:view_mode' as const, icon: isDark ? Sun : Moon, label: isDark ? t('view_in_light_mode') : t('view_in_dark_mode'), run: toggleViewMode }] : []), - { kind: 'item', key: 'extra:import', icon: Upload, label: tImport('menu_import'), run: handleImportEmail }, - ...(onShowShortcuts && !isMobile - ? [{ kind: 'item' as const, key: 'extra:shortcuts' as const, icon: Keyboard, label: t('keyboard_shortcuts'), run: onShowShortcuts }] - : []), ]; const readerActionOptions: MessageActionOptions = { email, @@ -4291,7 +4281,7 @@ ${printWatermark ? printWatermark.html : ''} onPrint: devices.noPrint ? undefined : () => handlePrint(), onViewSource: devices.noExport ? undefined : () => setShowSourceModal(true), omit: readerMenuOmit, - extras: readerExtras.filter((entry) => entry.kind !== 'item' || !readerMenuOmit.has(entry.key)), + views: readerViews, }; // Shared toolbar items used by both 'top' and 'below-subject' positions diff --git a/components/email/message-actions.ts b/components/email/message-actions.ts index 8fafc6ba2..b9078819a 100644 --- a/components/email/message-actions.ts +++ b/components/email/message-actions.ts @@ -125,6 +125,8 @@ export interface MessageActionOptions { onViewSource?: () => void; /** Entries the host already shows elsewhere (the reader's toolbar). */ omit?: ReadonlySet; + /** How the host shows the message (the reader's view toggles): a group before Export, Print and View source. */ + views?: MessageActionEntry[]; /** The host's own entries, after the message's own and a separator. */ extras?: MessageActionEntry[]; } @@ -230,7 +232,6 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr void copyLink(buildMailPath({ mailboxId: null, emailId: null, threadId: email.threadId!, ...emailLinkSource(email) })); })] : []), - SEPARATOR, ]; const archive = archiveEnabled ? [item("archive", Archive, t("archive"), o.onArchive)] : []; const del = item("delete", Trash2, t("delete"), o.onDelete, { destructive: !mountainView, testId: "ctx-delete" }); @@ -267,25 +268,34 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr const find = o.onFindFromSender && sender?.email ? [SEPARATOR, item("find_from_sender", Search, t("find_emails_from", { name: sender.name || sender.email }), o.onFindFromSender, { testId: "ctx-find-from" })] : []; - // Download/Export, Print, View source (R6: after Copy, before the end). + const openWindow = o.onOpenInWindow + ? [item("open_window", ExternalLink, tWindows("open_in_window"), o.onOpenInWindow, { testId: "ctx-open-window" })] + : []; + // How the host shows it, then Download/Export, Print, View source, at the end. + const views = o.views ?? []; const file = [ - ...(o.onOpenInWindow ? [item("open_window", ExternalLink, tWindows("open_in_window"), o.onOpenInWindow, { testId: "ctx-open-window" })] : []), ...(o.onExport ? [item("export", Download, tViewer("export_email"), o.onExport, { disabled: !email.blobId, testId: "ctx-export" })] : []), ...(o.onPrint ? [item("print", Printer, tViewer("print"), o.onPrint, { testId: "ctx-print" })] : []), ...(o.onViewSource ? [item("view_source", Code, tViewer("view_source"), o.onViewSource, { disabled: !email.blobId, testId: "ctx-view-source" })] : []), ]; + // In groups, as mail clients have them: answer; file away; mark; spam; + // open and link; how it shows; save, print, source. list = mountainView ? [ ...draft, ...replies, ...archive, del, read, SEPARATOR, ...move, tag, ...rules, ...copy, SEPARATOR, star, ...pin, ...spam, ...reportPhishing, ...find, SEPARATOR, - ...links, ...file, + ...links, SEPARATOR, ...views, SEPARATOR, ...openWindow, ...file, ] : [ - ...draft, ...replies, ...links, ...archive, del, SEPARATOR, - ...move, ...copy, ...rules, star, ...pin, tag, ...spam, ...reportPhishing, SEPARATOR, - read, SEPARATOR, ...file, + ...draft, ...replies, ...archive, del, SEPARATOR, + ...move, ...copy, ...rules, SEPARATOR, + read, star, ...pin, tag, SEPARATOR, + ...spam, ...reportPhishing, SEPARATOR, + ...openWindow, ...links, SEPARATOR, + ...views, SEPARATOR, + ...file, ]; } diff --git a/components/mail/__tests__/mail-palette-commands.test.tsx b/components/mail/__tests__/mail-palette-commands.test.tsx index b630376c6..d19546878 100644 --- a/components/mail/__tests__/mail-palette-commands.test.tsx +++ b/components/mail/__tests__/mail-palette-commands.test.tsx @@ -8,6 +8,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { usePluginStore } from '@/stores/plugin-store'; import { useThemeStore } from '@/stores/theme-store'; import { useToastStore } from '@/stores/toast-store'; +import { useMailTransferStore } from '@/stores/mail-transfer-store'; import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; import { clearAllHooks } from '@/lib/plugin-hooks'; import type { CommandGroup } from '@/lib/command-registry'; @@ -108,6 +109,18 @@ describe('Mail in the command palette', () => { expect(ids).not.toContain('mark-read'); }); + it("offers Import mail into the open folder, which left the reader's ⋮ menu", async () => { + let groups: CommandGroup[] = []; + render(<> { groups = g; }} />); + await screen.findByText('viewer', {}, { timeout: 5000 }); + await waitFor(() => expect(groups.find((g) => g.label === 'sidebar.mail')).toBeDefined()); + const importMail = groups.find((g) => g.label === 'sidebar.mail')!.commands.find((c) => c.id === 'import'); + expect(importMail?.label).toBe('mail_import.menu_import'); + act(() => importMail!.run()); + expect(useMailTransferStore.getState().request).toMatchObject({ kind: 'import', mailboxId: 'inbox' }); + act(() => useMailTransferStore.setState({ request: null, visible: false })); + }); + it("says whose a shared folder is and where a subfolder is, so two Inboxes don't look the same", async () => { const shared = { ...folder('support:inbox', 'inbox'), isShared: true, accountName: 'Support', accountId: 'support', originalId: 'inbox' } as Mailbox; const archive = folder('archive', 'archive', 'Archive'); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 11d4246c0..555d2b7ca 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -136,7 +136,7 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { openMailImport } from "@/stores/mail-transfer-store"; import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; -import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons"; +import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload } from "@/components/icons"; import { useRegisterCommands } from "@/lib/command-registry"; import { MoveToDialog } from "@/components/email/move-to-dialog"; import { ResizeHandle } from "@/components/layout/resize-handle"; @@ -4819,6 +4819,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { when: () => !!paletteEmail && conversationSpamApplicable, run: () => void (conversationInJunk ? handleUndoSpam(paletteEmail) : handleMarkAsSpam(paletteEmail)), }, + // Into the open folder, as the folder's own menu does it. + { + id: 'import', label: t('mail_import.menu_import'), icon: Upload, + run: () => openMailImport({ mailboxId: selectedMailbox || undefined }), + }, ...mailboxes .filter((mb) => !mb.id.startsWith('shared-') && mb.id !== selectedMailbox) .map((mb) => { @@ -6113,7 +6118,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined} onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined} isFullscreen={fullscreenReading} - onShowShortcuts={() => setShowShortcutsModal(true)} onEditDraft={handleEditDraft} onCancelScheduledForEdit={async () => { if (!client || !selectedEmail) return; 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 06/20] 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 ( +
+ +
+ ); + }); + return (
- {entries.map((entry) => ( - + {list.map((menu, index) => ( +
+ + {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 07/20] 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 08/20] =?UTF-8?q?feat:=20move=20a=20message=20from=20its?= =?UTF-8?q?=20own=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 05:20:14 +0100 Subject: [PATCH 09/20] fix: show the message's own folders in Move to, in mail-client order, with recents and a filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Move lists (message menu, reader Move button and its phone panel, selection toolbar, Move dialog) now share one model in lib/move-menu.ts. In the desktop app the native copy of the menu lost the shared accounts' headers and every folder's nesting, so Inbox, Sent, Spam and Trash showed once per account with nothing to tell them apart. - Only the message's own account at the top; other accounts a move can reach are submenus under their names. - System folders first (Inbox, Sent, Archive, Spam, Trash), then the user's folders by name; subfolders nest, as submenus natively. - The open folder shows greyed instead of missing. - A Recent group, a filter field on long lists, and "Choose folder…" for the dialog, which has the filter too. Tests changed: the open folder is now shown disabled rather than left out, the own account of a shared-mailbox message is a submenu, and the selection toolbar's filter field has its own label. --- .../email-context-menu-copy.test.tsx | 6 +- ...-context-menu-move-to-open-folder.test.tsx | 7 +- .../email/__tests__/move-menu-native.test.tsx | 88 ++++++ .../email/__tests__/move-target-list.test.tsx | 107 +++++-- .../email/__tests__/move-to-dialog.test.tsx | 46 +++ .../email/__tests__/mv-list-toolbar.test.tsx | 5 +- .../email/conversation-header-actions.tsx | 24 +- components/email/email-context-menu.tsx | 116 +++---- components/email/email-viewer.tsx | 66 ++-- components/email/move-target-list.tsx | 299 +++++++++++++++--- components/email/move-to-dialog.tsx | 93 ++++-- components/email/mv-list-toolbar.tsx | 87 +---- components/mail/mail-app.tsx | 5 +- lib/__tests__/move-menu.test.ts | 106 +++++++ lib/desktop/native-menu.ts | 13 + lib/move-menu.ts | 253 +++++++++++++++ lib/move-recent.ts | 40 +++ locales/ar/common.json | 7 + locales/ca/common.json | 7 + locales/cs/common.json | 7 + locales/da/common.json | 7 + locales/de/common.json | 7 + locales/en/common.json | 7 + locales/es/common.json | 7 + locales/et/common.json | 7 + locales/fa/common.json | 7 + locales/fr/common.json | 7 + locales/he/common.json | 7 + locales/hu/common.json | 7 + locales/it/common.json | 7 + locales/ja/common.json | 7 + locales/ko/common.json | 7 + locales/lv/common.json | 7 + locales/mn/common.json | 7 + locales/nb/common.json | 7 + locales/nl/common.json | 7 + locales/pl/common.json | 7 + locales/pt/common.json | 7 + locales/ro/common.json | 7 + locales/ru/common.json | 7 + locales/sk/common.json | 7 + locales/th/common.json | 7 + locales/tr/common.json | 7 + locales/uk/common.json | 7 + locales/vi/common.json | 7 + locales/zh-TW/common.json | 7 + locales/zh/common.json | 7 + 47 files changed, 1296 insertions(+), 275 deletions(-) create mode 100644 components/email/__tests__/move-menu-native.test.tsx create mode 100644 components/email/__tests__/move-to-dialog.test.tsx create mode 100644 lib/__tests__/move-menu.test.ts create mode 100644 lib/move-menu.ts create mode 100644 lib/move-recent.ts diff --git a/components/email/__tests__/email-context-menu-copy.test.tsx b/components/email/__tests__/email-context-menu-copy.test.tsx index f888a4cb8..61411ed2e 100644 --- a/components/email/__tests__/email-context-menu-copy.test.tsx +++ b/components/email/__tests__/email-context-menu-copy.test.tsx @@ -89,8 +89,12 @@ describe('Copy to entry of the message menu', () => { }); fireEvent.mouseEnter(screen.getByTestId('ctx-move-to').parentElement!); + // The shared account's own folders at the top, the open one greyed; the + // login's own folders one level down, under its name. const items = screen.getAllByTestId(/^move-to:/).map((el) => el.getAttribute('data-testid')); - expect(items).toEqual(['move-to:S:junk', 'move-to:inbox', 'move-to:junk']); + expect(items).toEqual(['move-to:S:inbox', 'move-to:S:junk']); + expect(screen.getByTestId('move-to:S:inbox')).toBeDisabled(); + expect(screen.getByTestId('move-account:A')).toHaveTextContent('me@example.org'); fireEvent.click(screen.getByTestId('move-to:S:junk')); expect(onMoveToMailbox).toHaveBeenCalledWith('S:junk'); diff --git a/components/email/__tests__/email-context-menu-move-to-open-folder.test.tsx b/components/email/__tests__/email-context-menu-move-to-open-folder.test.tsx index 932a82b11..51de81f15 100644 --- a/components/email/__tests__/email-context-menu-move-to-open-folder.test.tsx +++ b/components/email/__tests__/email-context-menu-move-to-open-folder.test.tsx @@ -66,10 +66,11 @@ describe('Move to the open folder from search results', () => { expect(onMoveToMailbox).toHaveBeenCalledWith('inbox'); }); - it('does not offer the open folder for a message already in it', () => { + it('shows the open folder greyed for a message already in it', () => { openMoveMenu({ email: inInbox }); - expect(screen.queryByTestId('move-to:inbox')).toBeNull(); - expect(screen.getByTestId('move-to:work')).toBeTruthy(); + // Shown, as in the sidebar, but it can't be picked. + expect(screen.getByTestId('move-to:inbox')).toBeDisabled(); + expect(screen.getByTestId('move-to:work')).not.toBeDisabled(); }); it('offers it for a selection when one of the messages is elsewhere', () => { diff --git a/components/email/__tests__/move-menu-native.test.tsx b/components/email/__tests__/move-menu-native.test.tsx new file mode 100644 index 000000000..145de43d0 --- /dev/null +++ b/components/email/__tests__/move-menu-native.test.tsx @@ -0,0 +1,88 @@ +import { render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ContextMenu, ContextMenuSubMenu } from '@/components/ui/context-menu'; +import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '@/lib/desktop/types'; +import type { Mailbox } from '@/lib/jmap/types'; +import { buildMoveMenu } from '@/lib/move-menu'; +import { MoveFolderList } from '../move-target-list'; + +// The desktop app shows the Move menu natively, read back from the HTML one. +// A native menu can't indent and the account headers used to be plain divs, +// so it showed every account's Inbox, Sent, Spam and Trash in one flat list +// with nothing to tell them apart. + +const writable = { mayAddItems: true } as Mailbox['myRights']; +const box = (id: string, name: string, extra: Partial = {}) => + ({ + id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: writable, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra, + }) as Mailbox; +const shared = (id: string, name: string, extra: Partial = {}) => + box(`S:${id}`, name, { accountId: 'S', accountName: 'team@example.org', isShared: true, ...extra }); + +const mailboxes = [ + box('inbox', 'Inbox', { role: 'inbox' }), + box('trash', 'Trash', { role: 'trash' }), + box('work', 'Work'), + box('clients', 'Clients', { parentId: 'work' }), + box('acme', 'Acme', { parentId: 'clients' }), + box('rd', 'R&D'), + shared('inbox', 'Inbox', { role: 'inbox' }), + shared('trash', 'Trash', { role: 'trash' }), +]; + +let popup: ReturnType; + +beforeEach(() => { + popup = vi.fn(() => new Promise(() => {})); + window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } } as unknown as BulwarkDesktopBridge; +}); + +afterEach(() => { + window.bulwarkDesktop = undefined; +}); + +async function nativeMoveMenu(): Promise { + const model = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', recentIds: () => ['acme'] }); + render( + {}}> + + {}} /> + + , + ); + await waitFor(() => expect(popup).toHaveBeenCalled()); + const items = popup.mock.calls[0][0] as DesktopNativeMenuItem[]; + return items[0].submenu!; +} + +const labels = (items: DesktopNativeMenuItem[] | undefined) => (items ?? []).map((item) => item.label ?? item.type); + +describe('the Move menu shown natively', () => { + it('has the recent folders under a header, then the account\'s own folders', async () => { + const move = await nativeMoveMenu(); + expect(labels(move)).toEqual([ + 'recent', 'Work / Clients / Acme', 'separator', + 'mailboxes.inbox', 'mailboxes.trash', 'R&D', 'Work', + 'separator', 'team@example.org', + ]); + expect(move[0].enabled).toBe(false); + // The open folder is there, greyed. + expect(move[3].enabled).toBe(false); + }); + + it('nests subfolders as submenus, the folder itself first', async () => { + const move = await nativeMoveMenu(); + const work = move.find((item) => item.label === 'Work')!; + expect(labels(work.submenu)).toEqual(['Work', 'separator', 'Clients']); + expect(work.submenu![0].id).toBeTruthy(); + const clients = work.submenu![2]; + expect(labels(clients.submenu)).toEqual(['Clients', 'separator', 'Acme']); + }); + + it('puts another account\'s folders in a submenu under its name', async () => { + const move = await nativeMoveMenu(); + const team = move.find((item) => item.label === 'team@example.org')!; + expect(labels(team.submenu)).toEqual(['mailboxes.inbox', 'mailboxes.trash']); + }); +}); diff --git a/components/email/__tests__/move-target-list.test.tsx b/components/email/__tests__/move-target-list.test.tsx index a85de34bc..149615116 100644 --- a/components/email/__tests__/move-target-list.test.tsx +++ b/components/email/__tests__/move-target-list.test.tsx @@ -1,25 +1,41 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import type { MailboxNode } from '@/lib/utils'; -import { MoveTargetList } from '../move-target-list'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; +import { buildMoveMenu } from '@/lib/move-menu'; +import { readRecentMoveTargets } from '@/lib/move-recent'; +import { MoveFolderList } from '../move-target-list'; -// The reader's Move menus show the names the sidebar shows (Stalwart's -// "Sent Items" / "Junk Mail" / "Deleted Items" are Sent, Spam and Trash, in -// the user's language), and indent subfolders on the reading side. +// The Move lists show the names the sidebar shows (Stalwart's "Sent Items" / +// "Junk Mail" are Sent and Spam, in the user's language), nest subfolders, +// and get a filter field once they're long. -const node = (id: string, name: string, role: string | undefined, children: MailboxNode[] = []) => - ({ id, name, role, children, depth: 0 }) as unknown as MailboxNode; +const writable = { mayAddItems: true } as Mailbox['myRights']; +const box = (id: string, name: string, extra: Partial = {}) => + ({ + id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: writable, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra, + }) as Mailbox; -const tree = [ - node('sent', 'Sent Items', 'sent'), - node('junk', 'Junk Mail', 'junk'), - node('archive', 'Archive', 'archive', [node('y2025', '2025', undefined)]), +const short = [ + box('inbox', 'Inbox', { role: 'inbox' }), + box('sent', 'Sent Items', { role: 'sent' }), + box('junk', 'Junk Mail', { role: 'junk' }), + box('archive', 'Archive', { role: 'archive' }), + box('y2025', '2025', { parentId: 'archive' }), ]; -describe('MoveTargetList', () => { +const long = [ + ...short, + ...Array.from({ length: 12 }, (_, i) => box(`f${i}`, `Folder ${i}`)), + box('acme', 'Acme', { parentId: 'f3' }), +]; + +afterEach(() => localStorage.clear()); + +describe('MoveFolderList', () => { it('shows the localized folder names and moves on a pick', () => { const onPick = vi.fn(); - render(); + render(); // The i18n mock returns the key: the role's name, not the server's. expect(screen.queryByText('Sent Items')).toBeNull(); expect(screen.getByText('mailboxes.sent')).toBeTruthy(); @@ -27,10 +43,63 @@ describe('MoveTargetList', () => { expect(onPick).toHaveBeenCalledWith('junk'); }); - it('indents subfolders with a logical padding', () => { - render(); - const row = screen.getByText('2025').closest('button')!; - expect(row.style.paddingInlineStart).toBe('1.75rem'); - expect(row.style.paddingLeft).toBe(''); + it('greys the open folder and keeps the pick as a recent folder', () => { + const onPick = vi.fn(); + render(); + expect(screen.getByTestId('move-to:inbox')).toBeDisabled(); + fireEvent.click(screen.getByTestId('move-to:inbox')); + expect(onPick).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('move-to:y2025')); + expect(readRecentMoveTargets('A')).toEqual(['y2025']); + }); + + it('puts recent folders at the top, with where they live', () => { + render( + ['y2025'] })} + onPick={vi.fn()} + />, + ); + expect(screen.getByText('recent')).toBeTruthy(); + expect(screen.getByText('Archive / 2025')).toBeTruthy(); + }); + + it('nests subfolders in a group the desktop app makes a submenu of', () => { + render(); + const group = screen.getByTestId('move-to:archive').closest('[data-native-submenu]')!; + expect(group).not.toBeNull(); + expect(group.contains(screen.getByTestId('move-to:y2025'))).toBe(true); + }); + + it('has no filter while short', () => { + render(); + expect(screen.queryByTestId('move-filter')).toBeNull(); + expect(screen.queryByTestId('move-choose-folder')).toBeNull(); + }); + + it('a long list gets a filter that keeps the folders above a match, and Enter moves to the first', () => { + const onPick = vi.fn(); + const onChooseFolder = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('move-choose-folder')); + expect(onChooseFolder).toHaveBeenCalled(); + + const filter = screen.getByTestId('move-filter'); + fireEvent.change(filter, { target: { value: 'acm' } }); + expect(screen.getByTestId('move-to:f3')).toBeTruthy(); + expect(screen.getByTestId('move-to:acme')).toBeTruthy(); + expect(screen.queryByTestId('move-to:f4')).toBeNull(); + fireEvent.keyDown(filter, { key: 'Enter' }); + expect(onPick).toHaveBeenCalledWith('f3'); + + fireEvent.change(filter, { target: { value: 'nothing like it' } }); + expect(screen.getByText('no_match')).toBeTruthy(); + }); + + it('indents subfolders on the reading side in the phone panel', () => { + render(); + const row = screen.getByTestId('move-to:y2025'); + expect(row.parentElement!.className).toContain('ps-6'); }); }); diff --git a/components/email/__tests__/move-to-dialog.test.tsx b/components/email/__tests__/move-to-dialog.test.tsx new file mode 100644 index 000000000..571e1d07d --- /dev/null +++ b/components/email/__tests__/move-to-dialog.test.tsx @@ -0,0 +1,46 @@ +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { MoveToDialogHost, useChooseMoveFolder, useMoveDialogStore } from '../move-to-dialog'; + +// "Choose folder…" at the end of a long Move menu opens this dialog: a native +// menu has no filter field, the dialog has one. + +const box = (id: string, name: string, extra: Partial = {}) => + ({ + id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: { mayAddItems: true }, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra, + }) as Mailbox; + +const mailboxes = [box('inbox', 'Inbox', { role: 'inbox' }), box('work', 'Work'), box('travel', 'Travel')]; +const email = { id: 'e1', mailboxIds: { inbox: true } } as unknown as Email; + +afterEach(() => { + act(() => useMoveDialogStore.setState({ request: null })); +}); + +describe('the Move dialog', () => { + it('is only offered where a host can show it', () => { + const without = renderHook(() => useChooseMoveFolder()); + expect(without.result.current).toBeUndefined(); + without.unmount(); + + render(); + const withHost = renderHook(() => useChooseMoveFolder()); + expect(withHost.result.current).toBeTypeOf('function'); + }); + + it('shows the folders with a filter, and moves to the one picked', () => { + render(); + const onMove = vi.fn(); + act(() => useMoveDialogStore.getState().open({ emails: [email], onMove })); + + expect(screen.getByTestId('move-to:inbox')).toBeDisabled(); + fireEvent.change(screen.getByTestId('move-filter'), { target: { value: 'tra' } }); + expect(screen.queryByTestId('move-to:work')).toBeNull(); + fireEvent.click(screen.getByTestId('move-to:travel')); + + expect(onMove).toHaveBeenCalledWith('travel'); + expect(useMoveDialogStore.getState().request).toBeNull(); + }); +}); diff --git a/components/email/__tests__/mv-list-toolbar.test.tsx b/components/email/__tests__/mv-list-toolbar.test.tsx index 082bb667b..96862dbfc 100644 --- a/components/email/__tests__/mv-list-toolbar.test.tsx +++ b/components/email/__tests__/mv-list-toolbar.test.tsx @@ -67,7 +67,8 @@ describe('MvListToolbar filing actions', () => { toolbar(); fireEvent.click(screen.getByLabelText('move_to')); - expect(screen.queryByText('mailboxes.inbox')).toBeNull(); + // Shown greyed, where the sidebar has it. + expect(screen.getByText('mailboxes.inbox').closest('button')).toBeDisabled(); }); it('puts a tag on the whole selection when only some of it carries the tag', async () => { @@ -105,7 +106,7 @@ describe('MvListToolbar filing actions', () => { fireEvent.click(screen.getByRole('button', { name: 'move_to' })); expect(screen.getByText('Travel')).toBeInTheDocument(); - fireEvent.change(screen.getByRole('textbox', { name: 'move_to' }), { target: { value: 'rec' } }); + fireEvent.change(screen.getByRole('textbox', { name: 'filter_placeholder' }), { target: { value: 'rec' } }); expect(screen.getByText('Receipts')).toBeInTheDocument(); expect(screen.queryByText('Travel')).toBeNull(); diff --git a/components/email/conversation-header-actions.tsx b/components/email/conversation-header-actions.tsx index daccc86de..fd1e6a735 100644 --- a/components/email/conversation-header-actions.tsx +++ b/components/email/conversation-header-actions.tsx @@ -8,12 +8,13 @@ import { ContextMenu } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { useCopyToAccount, useEmailExport } from "@/hooks/use-message-file-actions"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; import { resolveEmailBlobContext } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; import { Archive, FolderInput, MoreVertical, Trash2 } from "@/components/icons"; import { EmailContextMenu } from "./email-context-menu"; -import { MoveTargetList } from "./move-target-list"; +import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list"; +import { useChooseMoveFolder } from "./move-to-dialog"; import { ViewSourceDialog } from "./view-source-dialog"; import { canExport } from "@/lib/device-policy"; import type { MessageActionKey } from "./message-actions"; @@ -56,11 +57,10 @@ export function ConversationHeaderActions({ email, actions }: { email: Email; ac const { mailboxes, selectedMailbox, onArchive, onDelete, onMoveToMailbox } = actions; const ownerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox); - const { tree: moveTree, targetIds: moveTargetIds } = useMemo( - () => buildMoveTargets(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId, emails: [email] }), - [mailboxes, selectedMailbox, ownerAccountId, email], - ); - const canMove = !!onMoveToMailbox && moveTree.length > 0; + const moveEmails = useMemo(() => [email], [email]); + const moveModel = useMoveMenu(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId, emails: moveEmails }); + const canMove = !!onMoveToMailbox && !moveMenuIsEmpty(moveModel); + const chooseMoveFolder = useChooseMoveFolder(); const exportFollowUp = { onArchive, onDelete, archiveEnabled }; const source = resolveEmailBlobContext(email, client); @@ -107,11 +107,13 @@ export function ConversationHeaderActions({ email, actions }: { email: Email; ac onClose={moveMenu.closeContextMenu} label={t("email_viewer.move_to")} > -
- + { moveMenu.closeContextMenu(); onMoveToMailbox!(mailboxId); }} + onChooseFolder={chooseMoveFolder + ? () => { moveMenu.closeContextMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox! }); } + : undefined} />
diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index a27531ead..45d6d96de 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useSettingsStore } from "@/stores/settings-store"; import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store"; import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags"; @@ -30,10 +30,10 @@ import { Fish, } from "@/components/icons"; import { useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions"; -import { buildMailboxTree, MailboxNode } from "@/lib/utils"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; -import { localizeMailboxName } from "@/lib/mailbox-label"; -import { moveMailboxIcon } from "./move-target-list"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu"; +import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list"; +import { useChooseMoveFolder } from "./move-to-dialog"; import { getEmailTagIds } from "@/lib/thread-utils"; import { TagPicker } from "./tag-picker"; import { RulesContextSubMenu } from "./rules-menu"; @@ -200,7 +200,6 @@ export function EmailContextMenu({ const t = useTranslations("context_menu"); const archiveActionEnabled = useArchiveActionEnabled(); const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); - const tSidebar = useTranslations("sidebar"); const isUnread = !email.keywords?.$seen; const currentTagIds = getEmailTagIds(email.keywords); // The row's account names its tags; a selection uses batchTagScope. @@ -221,68 +220,49 @@ export function EmailContextMenu({ ? !!onBatchRemoveFromFolder && !!batchEmails?.length && batchEmails.every((e) => canRemoveFromFolder(e, folderToLeave)) : !!onRemoveFromFolder && canRemoveFromFolder(email, folderToLeave); - // Move-to submenu: the message's own account first (#1149) - const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, { + // Move-to submenu: the message's own account first (#1149), shared with + // every other Move list (lib/move-menu.ts). + const movedEmails = useMemo( + () => (showBatchActions ? batchEmails ?? [] : [email]), + [showBatchActions, batchEmails, email], + ); + const moveModel = useMoveMenu(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId: resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox), - emails: showBatchActions ? batchEmails : [email], + emails: movedEmails, }); - - // Filter tree to only include branches that contain valid targets - const filterTree = (nodes: MailboxNode[], targetIds: Set): MailboxNode[] => { - return nodes.reduce((acc, node) => { - const filteredChildren = filterTree(node.children, targetIds); - if (targetIds.has(node.id) || filteredChildren.length > 0) { - acc.push({ ...node, children: filteredChildren }); - } - return acc; - }, []); - }; + const canMoveAnywhere = !moveMenuIsEmpty(moveModel); + const chooseMoveFolder = useChooseMoveFolder(); // Folders of the other connected accounts a copy can land in. - const copyTrees = (copyTargets ?? []) - .map((target) => { - const targetIds = new Set( - target.mailboxes - .filter((m) => !m.isShared && m.role !== "drafts" && m.myRights?.mayAddItems) - .map((m) => m.id) - ); - return { ...target, targetIds, tree: filterTree(buildMailboxTree(target.mailboxes), targetIds) }; - }) - .filter((target) => target.tree.length > 0); + const locale = useLocale(); + const copyTrees = useMemo( + () => (copyTargets ?? []) + .map((target) => ({ + ...target, + model: buildMoveMenu(target.mailboxes.filter((m) => !m.isShared), { locale }), + })) + .filter((target) => target.model.targetCount > 0), + [copyTargets, locale], + ); - const renderMailboxNodes = ( - nodes: MailboxNode[], - targetIds: Set, - testIdPrefix: string, - onPick: (mailboxId: string) => void, - ): React.ReactNode => - nodes.map((node) => { - const Icon = moveMailboxIcon(node.role); - const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); - return ( -
- {targetIds.has(node.id) ? ( - handleAction(() => onPick(node.id))} - /> - ) : ( -
- - {nodeLabel} -
- )} - {node.children.length > 0 && ( -
- {renderMailboxNodes(node.children, targetIds, testIdPrefix, onPick)} -
- )} -
- ); - }); + const renderMoveList = (onPick?: (mailboxId: string) => void) => ( + handleAction(() => onPick?.(mailboxId))} + onChooseFolder={chooseMoveFolder && onPick + ? () => handleAction(() => chooseMoveFolder({ emails: movedEmails, onMove: onPick })) + : undefined} + /> + ); + const renderCopyList = (target: { accountId: string; model: MoveMenuModel }) => ( + handleAction(() => onCopyToAccount?.(target.accountId, mailboxId))} + /> + ); const handleAction = (action: () => void) => { action(); @@ -294,7 +274,7 @@ export function EmailContextMenu({ const singleEntries = useMessageActionList({ email, currentMailboxRole, - canMove: moveTree.length > 0, + canMove: canMoveAnywhere, canRemoveFromFolder: canRemove, canCopyToAccount: !!onCopyToAccount && copyTrees.length > 0, onReply, @@ -388,9 +368,9 @@ export function EmailContextMenu({ const moveItem = ( <> {/* Move to submenu */} - {moveTree.length > 0 && ( + {canMoveAnywhere && ( - {renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onBatchMoveToMailbox?.(mailboxId))} + {renderMoveList(onBatchMoveToMailbox)} )} {canRemove && ( @@ -407,9 +387,7 @@ export function EmailContextMenu({ ? copyTrees.map((target) => (
{target.label} - {renderMailboxNodes(target.tree, target.targetIds, `copy-to:${target.accountId}:`, (mailboxId) => - onCopyToAccount(target.accountId, mailboxId) - )} + {renderCopyList(target)}
)) : null; @@ -478,7 +456,7 @@ export function EmailContextMenu({ case "move": return ( - {renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onMoveToMailbox?.(mailboxId))} + {renderMoveList(onMoveToMailbox)} ); case "copy_to_account": diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0b1b5fac7..ba0a2a066 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -47,11 +47,12 @@ import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/p import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry"; import { QuickReplyPluginActions } from "./quick-reply-plugin-actions"; import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope"; -import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils"; +import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils"; import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone"; import { itemsToHide } from "@/lib/toolbar-overflow"; import { formatScheduledSendAt } from "@/lib/scheduled-send-label"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu"; import { emailDisplayDate } from "@/lib/email-date"; import { TagBadge } from "./tag-badge"; import { TagPicker } from "./tag-picker"; @@ -63,7 +64,8 @@ import { getSecurityStatus, extractListHeaders, getSenderVerification, brandIndi import { emailToReadView } from "@/lib/plugin-projection"; import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types"; import { ViewSourceDialog } from "./view-source-dialog"; -import { MoveTargetList } from "./move-target-list"; +import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list"; +import { useChooseMoveFolder } from "./move-to-dialog"; import { SaveToFilesDialog, type SaveableAttachment } from "./save-to-files-dialog"; import { usePolicyStore } from "@/stores/policy-store"; import { ScheduleSendDialog } from "./schedule-send-dialog"; @@ -122,7 +124,7 @@ import { Paperclip, } from "@/components/icons"; import { MailMessageSkeleton, MailMessageToolbarSkeleton } from "@/components/boot-skeleton/mail-skeleton"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; import { useSettingsStore } from "@/stores/settings-store"; @@ -1172,8 +1174,7 @@ interface ReaderMorePanelBodyProps { openSub: (sub: MoreMenuSub) => void; close: () => void; entryRefs: React.MutableRefObject>; - moveTree: MailboxNode[]; - moveTargetIds: Set; + moveModel: MoveMenuModel; onMoveToMailbox?: (mailboxId: string) => void; currentTagIds: string[]; sortedTagIds: string[]; @@ -1197,8 +1198,7 @@ function ReaderMorePanelBody({ openSub, close, entryRefs, - moveTree, - moveTargetIds, + moveModel, onMoveToMailbox, currentTagIds, sortedTagIds, @@ -1209,6 +1209,7 @@ function ReaderMorePanelBody({ pluginTarget, }: ReaderMorePanelBodyProps) { const { email } = options; + const locale = useLocale(); const { tagColor } = useKeywordFormat(tagScope); const { copyTargets, copyToAccount } = useCopyToAccount(email, true); const entries = useMessageActionList({ @@ -1218,11 +1219,10 @@ function ReaderMorePanelBody({ }); if (sub === 'move') { - return moveTree.length > 0 && onMoveToMailbox ? ( - { onMoveToMailbox(mailboxId); close(); }} /> ) : null; @@ -1231,14 +1231,15 @@ function ReaderMorePanelBody({ return ( <> {copyTargets.map((target) => { - const { tree, targetIds } = buildMoveTargets(target.mailboxes.filter((m) => !m.isShared && m.role !== 'drafts'), {}); + const model = buildMoveMenu(target.mailboxes.filter((m) => !m.isShared), { locale }); return (
{target.label}
- { void copyToAccount([email], target.accountId, mailboxId); close(); }} />
@@ -1978,14 +1979,14 @@ export function EmailViewer({ // Move-to dropdown: the message's own account first (#1149) const moveOwnerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox); - const { tree: moveTree, targetIds: moveTargetIds } = useMemo( - () => buildMoveTargets(mailboxes, { - currentMailboxId: selectedMailbox, - ownerAccountId: moveOwnerAccountId, - emails: email ? [email] : [], - }), - [mailboxes, selectedMailbox, moveOwnerAccountId, email], - ); + const moveEmails = useMemo(() => (email ? [email] : []), [email]); + const moveModel = useMoveMenu(mailboxes, { + currentMailboxId: selectedMailbox, + ownerAccountId: moveOwnerAccountId, + emails: moveEmails, + }); + const canMoveAnywhere = !moveMenuIsEmpty(moveModel); + const chooseMoveFolder = useChooseMoveFolder(); // Close dropdown menus on click outside useEffect(() => { @@ -2164,7 +2165,7 @@ export function EmailViewer({ email?.id, showToolbarLabels, isLoading, - moveTree.length, + canMoveAnywhere, emailKeywords.length, currentColor, isInJunkFolder, @@ -4271,7 +4272,7 @@ ${printWatermark ? printWatermark.html : ''} const readerActionOptions: MessageActionOptions = { email, currentMailboxRole, - canMove: moveTree.length > 0 && !!onMoveToMailbox, + canMove: canMoveAnywhere && !!onMoveToMailbox, canRemoveFromFolder: !!onRemoveFromFolder, onReply: onReply ? () => onReply() : undefined, onReplyAll, @@ -4467,7 +4468,7 @@ ${printWatermark ? printWatermark.html : ''} {showToolbarLabels && {t('delete')}} {/* Move to folder */} - {moveTree.length > 0 && onMoveToMailbox && ( + {canMoveAnywhere && onMoveToMailbox && (
-
- + { onMoveToMailbox(mailboxId); closeMoveMenu(); }} + onChooseFolder={chooseMoveFolder ? () => { closeMoveMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox }); } : undefined} />
@@ -4831,8 +4832,7 @@ ${printWatermark ? printWatermark.html : ''} openSub={setMoreMenuSub} close={closeMoreMenu} entryRefs={moreEntryRefs} - moveTree={moveTree} - moveTargetIds={moveTargetIds} + moveModel={moveModel} onMoveToMailbox={onMoveToMailbox} currentTagIds={currentTagIds} sortedTagIds={sortedTagIds} diff --git a/components/email/move-target-list.tsx b/components/email/move-target-list.tsx index cc99987a4..51395ccb0 100644 --- a/components/email/move-target-list.tsx +++ b/components/email/move-target-list.tsx @@ -1,9 +1,21 @@ "use client"; -import { useTranslations } from "next-intl"; -import { Archive, Ban, File, Folder, Inbox, Send, Trash2, type AppIcon } from "@/components/icons"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { Archive, Ban, Clock, File, Folder, FolderSearch, Inbox, Search, Send, Trash2, Users, type AppIcon } from "@/components/icons"; +import { ContextMenuHeader, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; import { localizeMailboxName } from "@/lib/mailbox-label"; -import type { MailboxNode } from "@/lib/utils"; +import type { Email, Mailbox } from "@/lib/jmap/types"; +import { + buildMoveMenu, + countTargets, + filterMoveNodes, + firstEnabled, + MOVE_FILTER_THRESHOLD, + type MoveMenuModel, + type MoveMenuNode, +} from "@/lib/move-menu"; +import { noteMoveTarget, readRecentMoveTargets } from "@/lib/move-recent"; import { cn } from "@/lib/utils"; /** A folder's icon in a Move list, the sidebar's for the folders with a role. */ @@ -19,58 +31,257 @@ export function moveMailboxIcon(role?: string | null): AppIcon { } } -interface MoveTargetListProps { - /** From buildMoveTargets. */ - tree: MailboxNode[]; - targetIds: Set; +type MoveMenuEmails = Array>; + +/** The Move list for these messages, with this device's recent folders. */ +export function useMoveMenu( + mailboxes: Mailbox[], + opts: { currentMailboxId?: string | null; ownerAccountId?: string; emails?: MoveMenuEmails }, +): MoveMenuModel { + const locale = useLocale(); + const { currentMailboxId, ownerAccountId, emails } = opts; + return useMemo( + () => buildMoveMenu(mailboxes, { currentMailboxId, ownerAccountId, emails, locale, recentIds: readRecentMoveTargets }), + [mailboxes, currentMailboxId, ownerAccountId, emails, locale], + ); +} + +/** Whether the list is long enough for a filter field and "Choose folder…". */ +export function moveMenuIsLong(model: MoveMenuModel): boolean { + return model.targetCount > MOVE_FILTER_THRESHOLD; +} + +export function moveMenuIsEmpty(model: MoveMenuModel): boolean { + return model.targetCount === 0 && model.others.length === 0; +} + +interface MoveFolderListProps { + model: MoveMenuModel; onPick: (mailboxId: string) => void; - /** `menu` for a dropdown, `panel` for the phone's full-width panel (44px rows). */ - variant?: "menu" | "panel"; + /** + * `menu`: inside a ContextMenu; other accounts are submenus. + * `dialog`: a list in a dialog; other accounts follow under their names. + * `panel`: the phone's full-width panel (44px rows). + */ + variant?: "menu" | "dialog" | "panel"; + /** Opens the Move dialog. Offered at the end of a long list, since a native menu has no filter field. */ + onChooseFolder?: () => void; + /** Always show the filter field (the dialog). */ + alwaysFilter?: boolean; + /** Stable hook for integration tests: each row is ``. */ + testIdPrefix?: string; + /** Don't keep the pick as a recent folder (a copy, not a move). */ + noRecent?: boolean; } /** - * The folders a message can be moved to, as the reader's Move dropdown, its ⋮ - * and the phone panel show them: the names the sidebar shows (localized - * Sent, Spam, Trash), subfolders indented on the reading side. + * The folders messages can be moved to (lib/move-menu.ts): recent ones, the + * account's own folders nested and in mail-client order, the open folder + * greyed, other reachable accounts under their names, and a filter field + * once the list is long. + * + * Each folder with subfolders is wrapped in `data-native-submenu`: the + * desktop app's native copy of the menu makes it a submenu there, the folder + * itself first (lib/desktop/native-menu.ts). */ -export function MoveTargetList({ tree, targetIds, onPick, variant = "menu" }: MoveTargetListProps) { +export function MoveFolderList({ + model, + onPick, + variant = "menu", + onChooseFolder, + alwaysFilter = false, + testIdPrefix = "move-to:", + noRecent = false, +}: MoveFolderListProps) { + const t = useTranslations("menuMove"); const tSidebar = useTranslations("sidebar"); + const locale = useLocale(); + const [query, setQuery] = useState(""); + const inputRef = useRef(null); const panel = variant === "panel"; - const indent = (depth: number) => `${(panel ? 1 : 0.75) + depth}rem`; + const showFilter = alwaysFilter || moveMenuIsLong(model); - const render = (nodes: MailboxNode[], depth: number): React.ReactNode => - nodes.map((node) => { - const Icon = moveMailboxIcon(node.role); - const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); - const rowClass = panel - ? "w-full pe-4 py-2.5 min-h-[44px] text-sm text-start flex items-center gap-3" - : "w-full pe-3 py-1.5 text-sm text-start flex items-center gap-2 pointer-coarse:min-h-11"; - const icon = ; + const labelOf = (node: Pick) => + localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); + + const nodes = filterMoveNodes(model.nodes, query, labelOf, locale); + const others = model.others + .map((account) => ({ ...account, nodes: filterMoveNodes(account.nodes, query, labelOf, locale) })) + .filter((account) => countTargets(account.nodes) > 0); + const filtering = query.trim().length > 0; + + const pick = (id: string) => { + if (!noRecent) noteMoveTarget(model.accountId, id); + onPick(id); + }; + + // The filter takes the focus as the list shows, so typing narrows it at + // once. It can be hidden for a frame while its panel is placed. + useEffect(() => { + if (!showFilter || panel) return; + let tries = 0; + let frame = 0; + const attempt = () => { + const input = inputRef.current; + if (!input) return; + const visible = input.getClientRects().length > 0 && getComputedStyle(input).visibility !== "hidden"; + if (visible) { + input.focus({ preventScroll: true }); + return; + } + if (++tries < 20) frame = requestAnimationFrame(attempt); + }; + frame = requestAnimationFrame(attempt); + return () => cancelAnimationFrame(frame); + }, [showFilter, panel]); + + const row = (node: MoveMenuNode, label: string, key: string, title?: string) => { + const Icon = moveMailboxIcon(node.role); + const rowTitle = title ?? (node.current ? t("current_folder", { folder: label }) : label); + if (panel) { return ( -
- {targetIds.has(node.id) ? ( - - ) : ( -
- {icon} - {label} -
- )} - {node.children.length > 0 && render(node.children, depth + 1)} + + ); + } + return ( + pick(node.id)} + /> + ); + }; + + const tree = (list: MoveMenuNode[]): React.ReactNode => + list.map((node) => { + const label = labelOf(node); + if (node.children.length === 0) return row(node, label, node.id); + return ( +
+ {row(node, label, node.id)} +
{tree(node.children)}
); }); - return <>{render(tree, 0)}; + const recent = !filtering && !noRecent ? model.recent : []; + const nothing = nodes.length === 0 && others.length === 0; + const separator = panel ?
: ; + + return ( +
+ {showFilter && ( +
+ +
+ )} + + {recent.length > 0 && ( +
+ {panel ? ( +
+ + {t("recent")} +
+ ) : ( + {t("recent")} + )} + {recent.map((node) => { + const label = [...node.path, labelOf(node)].join(" / "); + return row({ ...node, children: [] }, label, `recent-${node.id}`, label); + })} + {separator} +
+ )} + + {tree(nodes)} + + {nothing && ( +
{t("no_match")}
+ )} + + {others.length > 0 && (variant === "menu" ? ( + <> + {nodes.length > 0 && } + {others.map((account) => ( + + {tree(account.nodes)} + + ))} + + ) : ( + others.map((account) => ( +
+ {panel ? ( +
{account.name}
+ ) : ( + {account.name} + )} + {tree(account.nodes)} +
+ )) + ))} + + {onChooseFolder && moveMenuIsLong(model) && !filtering && ( + <> + {separator} + {panel ? ( + + ) : ( + + )} + + )} +
+ ); } diff --git a/components/email/move-to-dialog.tsx b/components/email/move-to-dialog.tsx index 591ef49c8..33a8c68fd 100644 --- a/components/email/move-to-dialog.tsx +++ b/components/email/move-to-dialog.tsx @@ -1,15 +1,47 @@ "use client"; -import { useMemo } from "react"; +import { useEffect } from "react"; import { useTranslations } from "next-intl"; +import { create } from "zustand"; import { Dialog } from "@/components/ui/dialog"; import type { Email, Mailbox } from "@/lib/jmap/types"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; -import { MoveTargetList } from "./move-target-list"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { MoveFolderList, useMoveMenu } from "./move-target-list"; + +interface MoveDialogRequest { + /** The messages to move. */ + emails: Email[]; + onMove: (mailboxId: string) => void; +} + +interface MoveDialogState { + request: MoveDialogRequest | null; + /** Mounted hosts: without one, nothing offers the dialog. */ + hosts: number; + open: (request: MoveDialogRequest) => void; + close: () => void; +} + +export const useMoveDialogStore = create((set) => ({ + request: null, + hosts: 0, + open: (request) => set({ request }), + close: () => set({ request: null }), +})); + +/** + * "Choose folder…" at the end of a long Move list: the dialog with the filter + * field. Undefined where no dialog host is mounted. + */ +export function useChooseMoveFolder(): ((request: MoveDialogRequest) => void) | undefined { + const hosts = useMoveDialogStore((s) => s.hosts); + const open = useMoveDialogStore((s) => s.open); + return hosts > 0 ? open : undefined; +} interface MoveToDialogProps { - /** The message to move; the dialog is open while it is set. */ - email: Email | null; + /** The messages to move; the dialog is open while there are some. */ + emails: Email[]; mailboxes: Mailbox[]; selectedMailbox: string; onClose: () => void; @@ -17,25 +49,24 @@ interface MoveToDialogProps { } /** - * "Move to…" from the command palette: the same folder list as the reader's - * Move menu (the message's own account first), in a dialog. + * Move to…: the same folders as the Move menus (lib/move-menu.ts), in a + * dialog with a filter field. From the command palette, the Message menu and + * "Choose folder…" in a long Move menu. */ -export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMove }: MoveToDialogProps) { +export function MoveToDialog({ emails, mailboxes, selectedMailbox, onClose, onMove }: MoveToDialogProps) { const t = useTranslations("context_menu"); - const { tree, targetIds } = useMemo( - () => buildMoveTargets(mailboxes, { - currentMailboxId: selectedMailbox, - ownerAccountId: resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox), - emails: email ? [email] : [], - }), - [email, mailboxes, selectedMailbox], - ); + const model = useMoveMenu(mailboxes, { + currentMailboxId: selectedMailbox, + ownerAccountId: resolveMoveOwnerAccountId(emails[0], mailboxes, selectedMailbox), + emails, + }); return ( - -
- 0} onClose={onClose} title={t("move_to_dialog")} bodyClassName="px-2" size="sm"> +
+ { onClose(); onMove(mailboxId); @@ -45,3 +76,23 @@ export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMov
); } + +/** Shows the dialog a Move menu asked for. Mounted once, where the mail is. */ +export function MoveToDialogHost({ mailboxes, selectedMailbox }: { mailboxes: Mailbox[]; selectedMailbox: string }) { + const request = useMoveDialogStore((s) => s.request); + const close = useMoveDialogStore((s) => s.close); + useEffect(() => { + useMoveDialogStore.setState((s) => ({ hosts: s.hosts + 1 })); + return () => useMoveDialogStore.setState((s) => ({ hosts: s.hosts - 1, request: s.hosts <= 1 ? null : s.request })); + }, []); + if (!request) return null; + return ( + + ); +} diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index e3ddc9dcf..ae56cb2fc 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -1,17 +1,15 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Archive, CheckSquare, - Folder, FolderInput, FolderX, Loader2, Mail, MailOpen, MoreVertical, - Search, ShieldAlert, ShieldCheck, Square, @@ -31,14 +29,15 @@ import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo import { toast } from "@/stores/toast-store"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useContextMenu } from "@/hooks/use-context-menu"; -import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuHeader, CONTEXT_MENU_ITEM_CLASS } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuHeader } from "@/components/ui/context-menu"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { TagPicker } from "@/components/email/tag-picker"; -import { localizeMailboxName } from "@/lib/mailbox-label"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "@/components/email/move-target-list"; +import { useChooseMoveFolder } from "@/components/email/move-to-dialog"; import { getEmailTagIds } from "@/lib/thread-utils"; -import { cn, type MailboxNode } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { PluginActions } from "@/components/plugins/plugin-actions"; import { useUnreadFirstSection } from "@/hooks/use-unread-first-section"; import { useTimeoutWhileMounted } from "@/hooks/use-timeout-while-mounted"; @@ -170,8 +169,6 @@ export function MvListToolbar({ const moveOpen = moveMenu.contextMenu.isOpen; const closeMove = moveMenu.closeContextMenu; // Gmail's "Move to" menu opens on a search field that narrows the folders. - const [moveQuery, setMoveQuery] = useState(""); - useEffect(() => { if (!moveOpen) setMoveQuery(""); }, [moveOpen]); const tagMenu = useContextMenu(); const tagOpen = tagMenu.contextMenu.isOpen; const closeTag = tagMenu.closeContextMenu; @@ -288,58 +285,12 @@ export function MvListToolbar({ // The same targets the row's own context menu offers, with the account of // the selection leading (#1149). - const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, { + const moveModel = useMoveMenu(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId: resolveMoveOwnerAccountId(selectedEmails[0], mailboxes, selectedMailbox), emails: selectedEmails, }); - - const renderMoveNodes = (nodes: MailboxNode[], depth = 0): React.ReactNode => - nodes.map((node) => { - const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {label} -
- )} - {node.children.length > 0 && renderMoveNodes(node.children, depth + 1)} -
- ); - }); - - // While a search narrows the move menu it is a flat list of the matches. - const moveMatches = (() => { - const q = moveQuery.trim().toLowerCase(); - if (!q) return null; - const out: MailboxNode[] = []; - const walk = (nodes: MailboxNode[]) => { - for (const node of nodes) { - const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); - if (moveTargetIds.has(node.id) && label.toLowerCase().includes(q)) out.push({ ...node, children: [] }); - walk(node.children); - } - }; - walk(moveTree); - return out; - })(); + const chooseMoveFolder = useChooseMoveFolder(); // What the tag picker shows: a tag every selected message carries is // checked, one only some of them carry is drawn as partial. @@ -560,7 +511,7 @@ export function MvListToolbar({ disabled={isProcessing} />
- {moveTree.length > 0 && ( + {!moveMenuIsEmpty(moveModel) && (
)} {resource.isDirectory && !resource.isShared && ( diff --git a/components/files/file-views.tsx b/components/files/file-views.tsx index 11a26656c..972e8cfad 100644 --- a/components/files/file-views.tsx +++ b/components/files/file-views.tsx @@ -223,6 +223,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget, note?: string; }) { const t = useTranslations("files"); + const tMenu = useTranslations("menuAudit"); const touchProps = useTouchPress(touch, selecting, resource, handlers); const icon = ; return ( @@ -299,7 +300,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget, handlers.onDownload?.(resource)} /> )} {!touch && handlers.canShare(resource) && ( - handlers.onShare(resource)} /> + handlers.onShare(resource)} /> )} (); const overflowMenu = useContextMenu(); const [searchOpen, setSearchOpen] = useState(false); @@ -159,7 +160,7 @@ export function FilesHeader({ )} {selection.canShare && ( - + )} {selection.canRename && ( diff --git a/components/files/files-sidebar.tsx b/components/files/files-sidebar.tsx index 58dc0b629..d47ea17a5 100644 --- a/components/files/files-sidebar.tsx +++ b/components/files/files-sidebar.tsx @@ -125,6 +125,7 @@ export function FilesSidebar({ onDropToTrash, }: FilesSidebarProps) { const t = useTranslations("files"); + const tMenu = useTranslations("menuAudit"); const newMenu = useContextMenu(); const itemMenu = useContextMenu(); const newButtonRef = useRef(null); @@ -274,10 +275,10 @@ export function FilesSidebar({ const createItems = (path: string, id: string | null) => ( <> - run(() => onCreateIn(path, id, "folder"))} /> - run(() => onCreateIn(path, id, "text"))} /> - run(() => onCreateIn(path, id, "files"))} /> - run(() => onCreateIn(path, id, "dir"))} /> + run(() => onCreateIn(path, id, "folder"))} /> + run(() => onCreateIn(path, id, "text"))} /> + run(() => onCreateIn(path, id, "files"))} /> + run(() => onCreateIn(path, id, "dir"))} /> ); @@ -455,11 +456,11 @@ export function FilesSidebar({ - { newMenu.closeContextMenu(); onUploadFiles(); }} /> - { newMenu.closeContextMenu(); onUploadFolder(); }} /> + { newMenu.closeContextMenu(); onUploadFiles(); }} /> + { newMenu.closeContextMenu(); onUploadFolder(); }} /> - { newMenu.closeContextMenu(); onNewFolder(); }} /> - { newMenu.closeContextMenu(); onNewTextFile(); }} /> + { newMenu.closeContextMenu(); onNewFolder(); }} /> + { newMenu.closeContextMenu(); onNewTextFile(); }} /> @@ -484,7 +485,7 @@ export function FilesSidebar({ Details act on the list and are left out here (R6). */} {(!folder.isShared || canShare(folder)) && } {canShare(folder) && ( - run(() => onShare(folder))} /> + run(() => onShare(folder))} /> )} {!folder.isShared && ( Date: Wed, 4 Nov 2026 05:32:24 +0100 Subject: [PATCH 14/20] fix: leave Edit, Move and Delete out for contacts in a read-only address book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two "Add to group" tests click the entry by its new key, which ends in "…" because it opens a group picker. --- .../contacts/__tests__/contacts-app.test.tsx | 20 ++++++++++-- components/contacts/contact-context-menu.tsx | 31 ++++++++++++------- components/contacts/contact-detail.tsx | 7 +++-- components/contacts/contact-list.tsx | 4 +++ components/contacts/contacts-app.tsx | 5 +++ 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index cd153f9e8..802c8f994 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -182,7 +182,7 @@ describe('ContactsApp', () => { it('starts a new group with the contact when "Add to group" finds no group to add to', () => { renderContacts(); fireEvent.contextMenu(screen.getByText('Alice Smith')); - fireEvent.click(screen.getByText('context_menu.add_to_group')); + fireEvent.click(screen.getByText('contacts_add_to_group')); expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument(); }); @@ -190,11 +190,27 @@ describe('ContactsApp', () => { const team: ContactCard = { id: 'g-team', kind: 'group', addressBookIds: {}, name: { full: 'Team' }, members: {} }; renderContacts([alice, bob, team]); fireEvent.contextMenu(screen.getByText('Bob Jones')); - fireEvent.click(screen.getByText('context_menu.add_to_group')); + fireEvent.click(screen.getByText('contacts_add_to_group')); fireEvent.click(screen.getByText('bulk.new_group')); expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument(); }); + it('offers no Edit, Move or Delete for a contact in an address book shared read-only', () => { + const shared = { ...alice, addressBookIds: { 'ab-shared': true } }; + renderContacts([shared, bob]); + act(() => { + useContactStore.setState({ addressBooks: [{ id: 'ab-shared', name: 'Team', myRights: { mayRead: true, mayWrite: false } }] as never }); + }); + fireEvent.contextMenu(screen.getByText('Alice Smith')); + expect(screen.queryByRole('menuitem', { name: 'context_menu.edit' })).toBeNull(); + expect(screen.queryByRole('menuitem', { name: /context_menu.delete/ })).toBeNull(); + expect(screen.getByRole('menuitem', { name: 'context_menu.export_vcard' })).toBeInTheDocument(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }); + + fireEvent.contextMenu(screen.getByText('Bob Jones')); + expect(screen.getByRole('menuitem', { name: 'context_menu.edit' })).toBeInTheDocument(); + }); + it('imports in a real dialog that Escape closes', () => { renderContacts(); fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]); diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx index 9b6eea288..6281846ca 100644 --- a/components/contacts/contact-context-menu.tsx +++ b/components/contacts/contact-context-menu.tsx @@ -65,6 +65,8 @@ interface ContactContextMenuProps { onBatchExport?: () => void; onBatchAddToGroup?: () => void; onBatchDelete?: () => void; + /** The contact is in an address book shared read-only: no Edit, Move or Delete. */ + readOnly?: boolean; } export function ContactContextMenu({ @@ -88,8 +90,10 @@ export function ContactContextMenu({ onBatchExport, onBatchAddToGroup, onBatchDelete, + readOnly = false, }: ContactContextMenuProps) { const t = useTranslations("contacts"); + const tMenu = useTranslations("menuAudit"); const printOff = useStrictestDevices().noPrint; const email = getContactPrimaryEmail(contact); const phone = getContactPrimaryPhone(contact); @@ -134,7 +138,7 @@ export function ContactContextMenu({ onBatchAddToGroup?.())} disabled={!onBatchAddToGroup} /> @@ -179,12 +183,12 @@ export function ContactContextMenu({ onClick={handle(handleCall)} /> )} - {onEdit && } + {onEdit && !readOnly && } {!inDetail && } {onAddToGroup && ( )} @@ -195,7 +199,7 @@ export function ContactContextMenu({ onClick={handle(onDuplicate)} /> )} - {onMoveTo && ( + {onMoveTo && !readOnly && ( )} {email && ( @@ -225,13 +229,18 @@ export function ContactContextMenu({ onClick={handle(handlePrint)} /> )} - - + {!readOnly && ( + <> + + + + )} ); } diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 77edb2cdc..66a3969e2 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -35,6 +35,8 @@ interface ContactDetailProps { onPrint?: () => void; isMobile?: boolean; className?: string; + /** In an address book shared read-only: no Edit, Move or Delete. */ + readOnly?: boolean; } // Contexts and phone features the contact form offers, shown with the same @@ -74,7 +76,7 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null { } -export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className }: ContactDetailProps) { +export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) { const t = useTranslations("contacts"); const tCommon = useTranslations("common"); const locale = useLocale(); @@ -173,7 +175,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli )} {/* A phone's row has no room for this label: the ⋮ fell onto a line of its own. */} - {isMobile ? ( + {readOnly ? null : isMobile ? ( @@ -207,6 +209,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli onClose={moreMenu.closeContextMenu} menuRef={moreMenu.menuRef} inDetail + readOnly={readOnly} onCompose={onCompose} onDelete={onDelete} onAddToGroup={onAddToGroup} diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 6a11b7031..c3eef9c7a 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -105,6 +105,8 @@ function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; la interface ContactListProps { contacts: ContactCard[]; + /** A contact in an address book shared read-only: its menu has no Edit, Move or Delete. */ + isContactReadOnly?: (contact: ContactCard) => boolean; selectedContactId: string | null; searchQuery: string; onSearchChange: (query: string) => void; @@ -144,6 +146,7 @@ interface ContactListProps { export function ContactList({ contacts, + isContactReadOnly, selectedContactId, searchQuery, onSearchChange, @@ -788,6 +791,7 @@ export function ContactList({ onBatchExport={onBulkExport} onBatchAddToGroup={onBulkAddToGroup} onBatchDelete={onBulkDelete} + readOnly={isContactReadOnly?.(contextMenu.data) ?? false} /> )}
diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index a28c84ea1..83e2619d7 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -487,6 +487,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = // Address books contacts can be moved to; "Move to…" needs a second one. const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]); const canMoveContacts = !!client && supportsSync && writableBooks.length > 1; + // A contact in an address book shared read-only can't be changed: its menus leave out Edit, Move and Delete. + const isContactReadOnly = useCallback((contact: ContactCard) => Object.entries(contact.addressBookIds ?? {}) + .some(([id, inBook]) => inBook && addressBooks.find((b) => b.id === id)?.myRights?.mayWrite === false), [addressBooks]); const handleMoveContacts = useCallback((ids: string[]) => { if (ids.length > 0) setMovingContactIds(ids); }, []); @@ -1144,6 +1147,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined} onPrint={selectedContact && !printOff ? () => { void printContact(selectedContact); } : undefined} isMobile={isMobile} + readOnly={selectedContact ? isContactReadOnly(selectedContact) : false} /> ); } @@ -1398,6 +1402,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = ) : ( Date: Wed, 4 Nov 2026 05:35:44 +0100 Subject: [PATCH 15/20] fix: let a long submenu use the window's height, and draw one focus ring round the folder filter --- components/email/move-target-list.tsx | 9 +++++---- components/ui/context-menu.tsx | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/components/email/move-target-list.tsx b/components/email/move-target-list.tsx index 51395ccb0..34632ca49 100644 --- a/components/email/move-target-list.tsx +++ b/components/email/move-target-list.tsx @@ -189,8 +189,9 @@ export function MoveFolderList({
{showFilter && (
-
)} diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index 8241bd4d0..e0468679b 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -781,7 +781,8 @@ export function ContextMenuSubMenu({ className="min-w-[180px] max-w-[min(20rem,calc(100vw-20px))] bg-background rounded-md shadow-lg border border-border" role="menu" > -
+ {/* As tall as the window allows (it's moved up to fit), then it scrolls. */} +
{children}
From 4508eddccb89c50213fbc7564f29f1072cbd6976 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:35:57 +0100 Subject: [PATCH 16/20] fix: keep a tall menu under its button and scroll it, instead of covering the button --- .../__tests__/context-menu-placement.test.tsx | 19 +++++++++++++++ components/ui/context-menu.tsx | 24 +++++++++++++++---- lib/menu-placement.ts | 17 +++++++++---- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/components/ui/__tests__/context-menu-placement.test.tsx b/components/ui/__tests__/context-menu-placement.test.tsx index a856a4268..d1345b377 100644 --- a/components/ui/__tests__/context-menu-placement.test.tsx +++ b/components/ui/__tests__/context-menu-placement.test.tsx @@ -98,6 +98,25 @@ describe('a menu from a button', () => { expect(openAt({ x: 0, y: 0, anchor: { left: 100, top: 600, right: 132, bottom: 632 } }).top).toBe(600 - 4 - MENU.height); }); + it('too tall for either side: stays under the button, as tall as the room, and scrolls', () => { + // The ⋮ menu at 150 % zoom: 480 CSS pixels of window. + Object.defineProperty(window, 'innerHeight', { value: 480, configurable: true }); + MENU.height = 460; + try { + render( + {}}> + {}} /> + , + ); + const menu = screen.getByRole('menu'); + // Clamped instead, it sat at 10 and covered its own button. + expect(menu.style.top).toBe('48px'); + expect((menu.firstElementChild as HTMLElement).style.maxHeight).toBe(`${480 - 10 - 48 - 2}px`); + } finally { + MENU.height = 300; + } + }); + it('mirrors in RTL: start is the right edge', () => { document.documentElement.dir = 'rtl'; const at = openAt({ x: 0, y: 0, anchor: { left: 500, top: 40, right: 532, bottom: 72 } }); diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index 84e0c359c..0498a3dc3 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -242,7 +242,7 @@ function useMenuLevel(recentTouch: () => boolean): MenuLevel { export const ContextMenu = forwardRef( ({ isOpen, position, onClose, children, label }, ref) => { const [mounted, setMounted] = useState(false); - const [adjustedPosition, setAdjustedPosition] = useState(null); + const [adjustedPosition, setAdjustedPosition] = useState<(Position & { maxHeight?: number }) | null>(null); const localRef = useRef(null); const onCloseRef = useRef(onClose); useEffect(() => { @@ -326,18 +326,25 @@ export const ContextMenu = forwardRef( const clamp = () => { const rect = node.getBoundingClientRect(); + // The height it would have uncapped: a cap set last time mustn't decide the next. + const list = node.firstElementChild as HTMLElement | null; + const height = list && list.scrollHeight > list.clientHeight + ? rect.height + list.scrollHeight - list.clientHeight + : rect.height; // Next to the button it opened from, or at the pointer (lib/menu-placement.ts). - const { x, y } = placeMenu({ + const { x, y, maxHeight } = placeMenu({ point: { x: position.x, y: position.y }, anchor, - size: { width: rect.width, height: rect.height }, + size: { width: rect.width, height }, viewport: { width: window.innerWidth, height: window.innerHeight }, rtl: getComputedStyle(node).direction === "rtl", margin: VIEWPORT_MARGIN, gap: ANCHOR_GAP, }); - setAdjustedPosition((prev) => (prev && prev.x === x && prev.y === y ? prev : { x, y })); + setAdjustedPosition((prev) => + prev && prev.x === x && prev.y === y && prev.maxHeight === maxHeight ? prev : { x, y, maxHeight } + ); }; clamp(); if (typeof ResizeObserver === "undefined") return; @@ -528,7 +535,14 @@ export const ContextMenu = forwardRef( }} > {/* Taller than the window: scroll inside the menu. */} -
+
{children}
diff --git a/lib/menu-placement.ts b/lib/menu-placement.ts index a8397d1ee..146c6b391 100644 --- a/lib/menu-placement.ts +++ b/lib/menu-placement.ts @@ -50,8 +50,9 @@ export function placeMenu({ rtl = false, margin = MENU_VIEWPORT_MARGIN, gap = MENU_ANCHOR_GAP, -}: MenuPlacementInput): { x: number; y: number } { - const { width: w, height: h } = size; +}: MenuPlacementInput): { x: number; y: number; maxHeight?: number } { + const { width: w } = size; + let h = size.height; const { width: vw, height: vh } = viewport; const fitsX = (x: number) => x >= margin && x + w <= vw - margin; const fitsY = (y: number) => y >= margin && y + h <= vh - margin; @@ -69,8 +70,16 @@ export function placeMenu({ const above = anchor.top - gap - h; if (fitsY(below)) y = below; else if (fitsY(above)) y = above; - // Neither fits whole: the side with more room, and the menu scrolls. - else y = vh - anchor.bottom >= anchor.top ? below : above; + else { + // Neither fits whole: the side with more room, as tall as that room, and + // the menu scrolls. Clamped instead, it would cover its own button. + const roomBelow = vh - margin - below; + const roomAbove = anchor.top - gap - margin; + const maxHeight = Math.max(roomBelow, roomAbove); + h = Math.min(h, maxHeight); + y = roomBelow >= roomAbove ? below : anchor.top - gap - h; + return { x: clamp(x, w, vw, margin), y: Math.max(margin, y), maxHeight }; + } } else { const towardEnd = rtl ? point.x - w : point.x; const towardStart = rtl ? point.x : point.x - w; From 53eb91ab0ef35b2c0f0ddd90d4e47f938619df29 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:58:47 +0100 Subject: [PATCH 17/20] fix: keep the message details panel readable in a narrow reader, with addresses, dates and sign-in results that fit --- .../__tests__/viewer-details-fit.test.tsx | 84 +++++++++++++++++++ components/email/email-viewer.tsx | 51 ++++++----- components/email/recipient-popover.tsx | 6 +- components/ui/breakable-address.tsx | 17 ++++ lib/__tests__/text-breaks.test.ts | 27 ++++++ lib/text-breaks.ts | 34 ++++++++ 6 files changed, 198 insertions(+), 21 deletions(-) create mode 100644 components/email/__tests__/viewer-details-fit.test.tsx create mode 100644 components/ui/breakable-address.tsx create mode 100644 lib/__tests__/text-breaks.test.ts create mode 100644 lib/text-breaks.ts diff --git a/components/email/__tests__/viewer-details-fit.test.tsx b/components/email/__tests__/viewer-details-fit.test.tsx new file mode 100644 index 000000000..580262959 --- /dev/null +++ b/components/email/__tests__/viewer-details-fit.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// The message details panel in a narrow reader: the two columns followed the +// window's width, not the panel's, so they stayed side by side in a narrow pane; +// the sender's address went letter by letter down a sliver of a column, dates took +// three lines, and long sign-in results ran off the side. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const ADDRESS = 'no-reply-x8@notifications.mail.example-service.example.net'; +const SPF_DOMAIN = 'bounces+31859940-0dcc@em9672.notifications.example-service.example.net'; + +const email = { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Example Service', email: ADDRESS }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Hello', sentAt: '2026-11-04T04:33:50Z', receivedAt: '2026-11-04T04:33:53Z', preview: 'Hello', + hasAttachment: false, size: 100, textBody: [], htmlBody: [], attachments: [], bodyValues: {}, + authenticationResults: { spf: { result: 'pass', domain: SPF_DOMAIN } }, +} as unknown as Email; + +function openDetails() { + render(); + act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); }); + const heading = screen.getByText('details.recipients_routing'); + return heading.closest('section')!; +} + +describe('message details panel', () => { + it('lays out by its own width, not the window', () => { + const routing = openDetails(); + const grid = routing.parentElement!; + expect(grid.className).toMatch(/@3xl:grid-cols-2/); + expect(grid.className).not.toMatch(/\blg:grid-cols-2/); + expect(grid.parentElement!.className).toMatch(/@container/); + // The value column may shrink below its content's width. + expect(routing.querySelector('dl')!.className).toContain('minmax(0,1fr)'); + }); + + it('shows the sender name and address apart, the address breakable at its dots', () => { + const routing = openDetails(); + expect(routing.textContent).not.toContain(`<${ADDRESS}>`); + const address = routing.querySelector(`[title="${ADDRESS}"]`)!; + expect(address.textContent).toBe(ADDRESS); + expect(address.querySelectorAll('wbr').length).toBeGreaterThan(2); + }); + + it('shows dates short, with the full date on hover', () => { + const routing = openDetails(); + const times = routing.querySelectorAll('time'); + expect(times.length).toBe(2); + for (const time of Array.from(times)) { + expect(time.getAttribute('title')!.length).toBeGreaterThan(time.textContent!.length); + } + }); + + it('keeps sign-in result chips inside the panel, the long part cut with the whole on hover', () => { + openDetails(); + const domain = screen.getByText(SPF_DOMAIN); + expect(domain.className).toContain('truncate'); + expect(domain.getAttribute('title')).toBe(SPF_DOMAIN); + expect(domain.parentElement!.className).toContain('max-w-full'); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0b1b5fac7..eb050d200 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -155,6 +155,7 @@ import { useTour } from "@/components/tour/tour-provider"; import { useMenuNavigation } from "@/hooks/use-menu-navigation"; import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation"; import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover"; +import { BreakableAddress } from "@/components/ui/breakable-address"; import { mentionFromLink } from "@/lib/recipient-mentions"; import { MailtoLink } from "@/components/ui/mailto-link"; import { requestInternalMailto } from "@/lib/protocol-handlers/mailto"; @@ -5413,6 +5414,11 @@ ${printWatermark ? printWatermark.html : ''} const fullDate = (iso?: string) => iso ? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' }) : '-'; + // A long date wraps over three lines in the narrow column: the short one + // shows, the full one is on hover. + const shownDate = (iso?: string) => iso + ? + : '-'; const auth = email.authenticationResults; const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0); const topMimeType = email.bodyStructure?.type; @@ -5423,7 +5429,7 @@ ${printWatermark ? printWatermark.html : ''} ); const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => ( <> -
{label}
+
{label}
- {name} - + {name} + {translateAuthResult(result)} {extra && ( <> · - {extra} + {extra} )} @@ -5478,17 +5484,24 @@ ${printWatermark ? printWatermark.html : ''} ); return ( -
-
+
+
{t('details.recipients_routing')} -
+
` : undefined} + label={sender?.name && sender?.email ? ( + <> + {sender.name} + + + + + ) : undefined} onViewContact={handleViewContactSidebar} className="text-sm text-start" /> @@ -5542,10 +5555,10 @@ ${printWatermark ? printWatermark.html : ''} )} {email.sentAt && ( - {fullDate(email.sentAt)} + {shownDate(email.sentAt)} )} - {fullDate(email.receivedAt)} + {shownDate(email.receivedAt)} {deliveryDeltaMs > 60000 && ( · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()} )} @@ -5590,7 +5603,7 @@ ${printWatermark ? printWatermark.html : ''} )} {email.spamScore !== undefined && ( 5 ? "bg-red-500/[0.07] border-red-500/30" : email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" : "bg-green-500/[0.07] border-green-500/30", @@ -5613,7 +5626,7 @@ ${printWatermark ? printWatermark.html : ''} {email.spamStatus && ( <> · - {email.spamStatus} + {email.spamStatus} )} @@ -5644,9 +5657,9 @@ ${printWatermark ? printWatermark.html : ''} {hasIdentifiers && (
{t('details.identifiers_threading')} -
+
{email.messageId && ( - {email.messageId} + )} {email.inReplyTo && email.inReplyTo.length > 0 && ( @@ -5678,7 +5691,7 @@ ${printWatermark ? printWatermark.html : ''}
{t('details.message_properties')} -
+
{email.subject !== undefined && ( {email.subject || {t('details.no_subject')}} )} @@ -5701,9 +5714,9 @@ ${printWatermark ? printWatermark.html : ''}
{hasListInfo && ( -
+
{t('details.mailing_list')} -
+
{listHeaders?.listId && ( {listHeaders.listId} )} @@ -5730,7 +5743,7 @@ ${printWatermark ? printWatermark.html : ''} {/* Plugin-supplied category. Plugins whose shouldShow accepts `category === null` render their own titled section here. */} {hasDetailsSlotOffers && ( -
+
)} diff --git a/components/email/recipient-popover.tsx b/components/email/recipient-popover.tsx index caaf8bc78..1ea559311 100644 --- a/components/email/recipient-popover.tsx +++ b/components/email/recipient-popover.tsx @@ -251,12 +251,14 @@ interface RecipientPopoverProps { email: string; /** Display label override (e.g. "me") */ displayLabel?: string; + /** Rich content in place of the text label, such as a name over its address. */ + label?: React.ReactNode; /** Called when user clicks "View contact" - receives the contact and email */ onViewContact?: (contact: ContactCard | null, email: string) => void; className?: string; } -export function RecipientPopover({ name, email, displayLabel, onViewContact, className }: RecipientPopoverProps) { +export function RecipientPopover({ name, email, displayLabel, label, onViewContact, className }: RecipientPopoverProps) { const [anchor, setAnchor] = useState(null); const triggerRef = useRef(null); const shownEmail = useOwnDomainAddress()(email); @@ -280,7 +282,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla className )} > - {displayLabel || name || shownEmail} + {label ?? {displayLabel || name || shownEmail}} {anchor && ( diff --git a/components/ui/breakable-address.tsx b/components/ui/breakable-address.tsx new file mode 100644 index 000000000..c5e703132 --- /dev/null +++ b/components/ui/breakable-address.tsx @@ -0,0 +1,17 @@ +import { Fragment } from "react"; +import { addressBreakPieces } from "@/lib/text-breaks"; + +/** An address that wraps at its dots and after its @, not anywhere (lib/text-breaks.ts). */ +export function BreakableAddress({ text }: { text: string }) { + const pieces = addressBreakPieces(text); + return ( + <> + {pieces.map((piece, index) => ( + + {index > 0 && } + {piece} + + ))} + + ); +} diff --git a/lib/__tests__/text-breaks.test.ts b/lib/__tests__/text-breaks.test.ts new file mode 100644 index 000000000..4224f665d --- /dev/null +++ b/lib/__tests__/text-breaks.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { addressBreakPieces } from '../text-breaks'; + +describe('addressBreakPieces', () => { + it('breaks after the @ and before dots', () => { + expect(addressBreakPieces('dana.with.long@subdomain.example.net')).toEqual(['dana', '.with', '.long@', 'subdomain', '.example.net']); + }); + + it('breaks after + - and _', () => { + expect(addressBreakPieces('bounces+318-0dcc_x@em.example.org')).toEqual(['bounces+', '318-', '0dcc_', 'x@em', '.example.org']); + }); + + it('keeps short runs whole and loses no character', () => { + const text = 'a.b.c@d.io'; + const pieces = addressBreakPieces(text); + expect(pieces.join('')).toBe(text); + expect(pieces.every((piece) => piece.length >= 3)).toBe(true); + }); + + it('never leaves the top-level domain alone on a line', () => { + expect(addressBreakPieces('someone@notifications.mail.example-service.example.net').at(-1)).toBe('.example.net'); + }); + + it('leaves a name without break points as one piece', () => { + expect(addressBreakPieces('Anthropic')).toEqual(['Anthropic']); + }); +}); diff --git a/lib/text-breaks.ts b/lib/text-breaks.ts new file mode 100644 index 000000000..2bb86de85 --- /dev/null +++ b/lib/text-breaks.ts @@ -0,0 +1,34 @@ +/** + * Where a long address may wrap: after "@", "+", "-" and "_", and before a dot, as + * printed addresses do. A browser on its own wraps such a string anywhere once it + * doesn't fit, which leaves ragged two-letter lines in a narrow column. + * + * Returns the pieces; a line break opportunity () goes between each two. + * Pieces are never shorter than MIN_PIECE characters: "a.b.c" stays whole. The + * last few characters (".net") stay with the piece before them. + */ +const MIN_PIECE = 3; +const MAX_TAIL = 4; + +export function addressBreakPieces(text: string): string[] { + const pieces: string[] = []; + let current = ''; + for (const ch of text) { + if (ch === '.' && current.length >= MIN_PIECE) { + pieces.push(current); + current = ch; + continue; + } + current += ch; + if ((ch === '@' || ch === '+' || ch === '-' || ch === '_') && current.length >= MIN_PIECE) { + pieces.push(current); + current = ''; + } + } + if (current) { + // A short tail, such as ".net", joins the piece before it: no line holds only that. + if (current.length <= MAX_TAIL && pieces.length) pieces[pieces.length - 1] += current; + else pieces.push(current); + } + return pieces; +} From ea274b02e995335a730f03fe3f0bec3186a37c44 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:58:48 +0100 Subject: [PATCH 18/20] fix: end a field's text in an ellipsis when it doesn't fit, instead of cutting it mid-word --- app/globals.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/globals.css b/app/globals.css index b9aee76aa..46339ee6e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -191,6 +191,12 @@ * { border-color: var(--color-border); } + + /* A placeholder or value too long for a narrow field ends in "…" instead of + stopping mid-word ("Termine durchs"), so it's clear there's more. */ + input { + text-overflow: ellipsis; + } } body { From eda317721c07795873889b19b026d16fc29fa055 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:58:50 +0100 Subject: [PATCH 19/20] test: find cut-off placeholders, and let the calendar's sideways strip of weeks scroll --- integration/tests/25-do-layout-fit.spec.ts | 7 ++++--- integration/tests/helpers/layout-fit.ts | 20 +++++++++++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/integration/tests/25-do-layout-fit.spec.ts b/integration/tests/25-do-layout-fit.spec.ts index 77ec19577..9fc15ecba 100644 --- a/integration/tests/25-do-layout-fit.spec.ts +++ b/integration/tests/25-do-layout-fit.spec.ts @@ -99,14 +99,15 @@ for (const scenario of SCENARIOS) { await check(page, scenario, 'mail-list', found); await item.click(); await page.getByRole('button', { name: text.showDetails }).first().click(); - const routing = page.locator('section', { has: page.getByText(text.routing, { exact: true }) }).first(); + // Sections nest: the last match is the innermost, the routing section itself. + const routing = page.locator('section', { has: page.getByText(text.routing, { exact: true }) }).last(); await expect(routing).toBeVisible(); await check(page, scenario, 'mail-details', found); // The sender's address gets a column of its own width, not a sliver, and a date // takes one line. Rows: From, To, Sent, Received. - const fit = await routing.evaluate((section) => { - const values = Array.from(section.querySelectorAll('dl > dd')); + const fit = await routing.locator('dl').first().evaluate((list) => { + const values = Array.from(list.querySelectorAll(':scope > dd')); const from = values[0].getBoundingClientRect(); const sent = values[values.length - 2]; const lineHeight = parseFloat(getComputedStyle(sent).lineHeight) || 20; diff --git a/integration/tests/helpers/layout-fit.ts b/integration/tests/helpers/layout-fit.ts index 127b2d785..067a7b8cc 100644 --- a/integration/tests/helpers/layout-fit.ts +++ b/integration/tests/helpers/layout-fit.ts @@ -7,7 +7,8 @@ * Not counted: text that's meant to be cut (text-overflow: ellipsis, line clamp), * text inside a box that scrolls only sideways, text that's wholly outside its box (a * closed drawer, a slide), text in a box 1-2px wide (visually hidden), screen-reader-only text, and anything marked - * data-layout-fit-ignore. Frames (a message body) aren't looked into. + * data-layout-fit-ignore. Frames (a message body) aren't looked into. A field's + * placeholder counts too, unless it ends in an ellipsis. */ import type { Page } from '@playwright/test'; @@ -58,6 +59,10 @@ export async function findCutOffText(page: Page): Promise { if (Number(s.opacity) === 0 || s.visibility === 'hidden') { meant = true; break; } if (!clipsX(s)) continue; if (scrollsX(s)) { meant = true; break; } + // A strip of pages that scrolls both ways (the calendar's weeks side by side): + // the next page peeking in is meant. Text spilling over by accident is a few + // pixels to a column, never half the box again. + if (s.overflowX !== 'hidden' && s.overflowX !== 'clip' && at.scrollWidth > at.clientWidth * 1.5) { meant = true; break; } // A 1px box is the visually-hidden pattern: read out, not shown. if (at.clientWidth <= 2) { meant = true; break; } const r = at.getBoundingClientRect(); @@ -75,6 +80,19 @@ export async function findCutOffText(page: Page): Promise { out.push({ where: describe(el), text: text.slice(0, 60), px: Math.round(px) }); } } + // A placeholder cut off mid-word: "Termine durchs". With text-overflow: ellipsis + // it ends in "…" and says there's more. + const canvas = document.createElement('canvas').getContext('2d'); + for (const input of Array.from(document.querySelectorAll('input[placeholder]'))) { + if (input.value || !input.placeholder || input.closest('[aria-hidden="true"], [data-layout-fit-ignore]')) continue; + const s = getComputedStyle(input); + if (s.display === 'none' || s.visibility === 'hidden' || input.offsetParent === null) continue; + if (s.textOverflow === 'ellipsis' || !canvas) continue; + canvas.font = `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`; + const room = input.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight); + const px = canvas.measureText(input.placeholder).width - room; + if (px > 1.5) out.push({ where: describe(input), text: input.placeholder.slice(0, 60), px: Math.round(px) }); + } return out; }); } From 8bcf5248a4abb5c57934c91a63be503fe5395141 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:17:12 +0100 Subject: [PATCH 20/20] 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'),