From df62335545c7a5e3eef00f8620690ca82e023fa2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:25:33 +0100 Subject: [PATCH] feat: name the desktop app's windows in the Window menu by folder and account, subject or New message --- components/calendar/calendar-app.tsx | 2 + components/contacts/contacts-app.tsx | 2 + components/files/files-app.tsx | 2 + .../mail-app-menu-selection.test.tsx | 12 ++++++ components/mail/mail-app.tsx | 7 ++++ components/popout/popout-compose-app.tsx | 5 +++ components/popout/popout-message-app.tsx | 3 ++ components/settings/settings-app.tsx | 2 + lib/desktop/__tests__/window-name.test.tsx | 41 +++++++++++++++++++ lib/desktop/types.ts | 4 ++ lib/desktop/window-name.ts | 26 ++++++++++++ 11 files changed, 106 insertions(+) create mode 100644 lib/desktop/__tests__/window-name.test.tsx create mode 100644 lib/desktop/window-name.ts diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 35f12e35f..7ffe209f7 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -114,6 +114,7 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; import { IS_LITE } from "@/lib/lite"; import { findMeetingLink } from "@/lib/event-links"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; @@ -160,6 +161,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const tSidebar = useTranslations("sidebar"); const tOffline = useTranslations("calendar_offline"); useDocumentTitle(tSidebar("calendar")); + useDesktopWindowName(tSidebar("calendar"), { withAccount: true }); const skeletonLabels = useSkeletonLabels(); const tWebcalAction = useTranslations("calendar.webcal_action"); const { demoMode } = useConfig(); diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 3a3b7b004..ea5acafad 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -57,6 +57,7 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { ContactsSkeleton, ContactsSkeletonBody } from "@/components/boot-skeleton/contacts-skeleton"; import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels"; import { useRegisterCommands } from "@/lib/command-registry"; @@ -104,6 +105,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const tCommon = useTranslations("common"); const tShell = useTranslations("shell"); useDocumentTitle(tSidebar("contacts")); + useDesktopWindowName(tSidebar("contacts"), { withAccount: true }); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index a4bdf9456..dc3851cf1 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -42,6 +42,7 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; import { FilesSkeleton, FilesSkeletonBody } from "@/components/boot-skeleton/files-skeleton"; @@ -60,6 +61,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const tSidebar = useTranslations("sidebar"); const tShell = useTranslations("shell"); useDocumentTitle(tSidebar("files")); + useDesktopWindowName(tSidebar("files"), { withAccount: true }); const tDeepLink = useTranslations("deep_link"); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); diff --git a/components/mail/__tests__/mail-app-menu-selection.test.tsx b/components/mail/__tests__/mail-app-menu-selection.test.tsx index 62424ebbf..9234ef35e 100644 --- a/components/mail/__tests__/mail-app-menu-selection.test.tsx +++ b/components/mail/__tests__/mail-app-menu-selection.test.tsx @@ -88,6 +88,7 @@ const message = (id: string) => ({ id, threadId: `t-${id}`, subject: `Subject ${ const [a, b, c] = ['a', 'b', 'c'].map(message); let reported: Record = {}; +let named: string[] = []; let batchMoveToMailbox: ReturnType; beforeEach(() => { @@ -95,10 +96,12 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} }))); reported = {}; + named = []; (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: (state: Record) => { reported = state; }, onCommand: () => () => {} }, + windows: { setMenuName: (name: string) => { named.push(name); } }, }; usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); @@ -169,3 +172,12 @@ describe('the Message menu with three messages selected', () => { expect(batchMoveToMailbox).not.toHaveBeenCalled(); }); }); + +describe('the Window menu name of the main window', () => { + it('is the folder, not the message open in it, so it never reads like the window of that message', async () => { + useEmailStore.setState({ selectedEmailKeys: new Set() } as never); + render(); + await waitFor(() => expect(named.at(-1)).toBe('sidebar.mailboxes.inbox')); + expect(named).not.toContain('Subject a'); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 9c3ffb395..d51bce35e 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -146,6 +146,7 @@ import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-iden import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload, Star } from "@/components/icons"; import { useRegisterCommands } from "@/lib/command-registry"; import { MoveToDialog, MoveToDialogHost, useMoveDialogStore } from "@/components/email/move-to-dialog"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { SearchResultCount } from "@/components/email/search-result-count"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; @@ -1524,6 +1525,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return mailTitleContext({ composer, subject, mailbox: mailboxLine }, { showSubject: subjectInTabTitle }); }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle]); useDocumentTitle(titleContext); + // The Window menu names this window by its folder, never by the open message (desktop-p2-5). + const windowView = useMemo(() => { + const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined; + return mailbox ? localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`)) : t('sidebar.mail'); + }, [selectedMailbox, mailboxes, t]); + useDesktopWindowName(windowView, { withAccount: true }); // When this page is rendered inside the Pro shell as the Mail tab body, // we hoist every "show composer" intent into its own Pro tab and reset diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 5f8f5433b..798e7517b 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer"; import { ErrorBoundary, ComposerErrorFallback } from "@/components/error"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; @@ -313,6 +314,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: { const [sessionKey] = useState(() => `popout-${handoffId ?? Date.now().toString(36)}`); const mode = payload.initialData?.mode ?? payload.mode; + // The Window menu names a composer by its subject, or "New message" before there is one. + const [subject, setSubject] = useState(payload.initialData?.subject ?? ""); + useDesktopWindowName(subject.trim() || t("email_composer.new_message")); useDocumentTitle( mode === "reply" ? t("email_composer.reply") : mode === "replyAll" ? t("email_composer.reply_all") @@ -523,6 +527,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: { ownWindow={ownWindow} popOut={mainHoldsLogin ? backToMain : undefined} popOutToMainLabel={mainHoldsLogin ? tQuick("back_to_main") : undefined} + onSubjectChange={setSubject} className="min-h-0 flex-1" /> diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx index 0c68ce71a..80d81fb16 100644 --- a/components/popout/popout-message-app.tsx +++ b/components/popout/popout-message-app.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { ProEmailView } from "@/components/pro/pro-email-tab-body"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { usePolicyStore } from "@/stores/policy-store"; @@ -105,6 +106,8 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email const [subject, setSubject] = useState(null); const subjectInTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled("tabTitleSubjectEnabled")); useDocumentTitle(subjectInTitle ? subject : null); + // Kept out where the organisation keeps subjects out of titles: the shell then says "Message". + useDesktopWindowName(subjectInTitle ? subject : null); // Folders first: without them Delete has no Trash to move to, Archive has no // button yet, and the toolbar moves under the pointer once they arrive. diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 4e4a21d55..e0d617729 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -95,6 +95,7 @@ import { useAccountStore } from '@/stores/account-store'; import { useMediaQuery, atLeast, PHONE_BELOW } from '@/hooks/use-media-query'; import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments'; import { useDocumentTitle } from '@/hooks/use-document-title'; +import { useDesktopWindowName } from '@/lib/desktop/window-name'; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; import { scrollBehavior } from '@/lib/reduced-motion'; @@ -816,6 +817,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = ? `${activeTabLabel} - ${tSidebar('settings')}` : tSidebar('settings'), ); + useDesktopWindowName(tSidebar('settings'), { withAccount: true }); const handleTabSelect = (tabId: SettingsTabId) => { setActiveTab(tabId); diff --git a/lib/desktop/__tests__/window-name.test.tsx b/lib/desktop/__tests__/window-name.test.tsx new file mode 100644 index 000000000..2dd5a4c51 --- /dev/null +++ b/lib/desktop/__tests__/window-name.test.tsx @@ -0,0 +1,41 @@ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { useDesktopWindowName, windowMenuName } from '../window-name'; +import { useAccountStore } from '@/stores/account-store'; + +// The desktop app's Window menu: each window by what it shows (usability pass 2, desktop-p2-5). + +function Named({ view, withAccount }: { view: string | null; withAccount?: boolean }) { + useDesktopWindowName(view, { withAccount }); + return null; +} + +afterEach(() => { + cleanup(); + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('the Window menu name', () => { + it('is the view and the account for the main window, the view alone otherwise', () => { + expect(windowMenuName('Inbox', 'a@example.org')).toBe('Inbox - a@example.org'); + expect(windowMenuName('Quarterly budget', null)).toBe('Quarterly budget'); + expect(windowMenuName(' ', null)).toBeNull(); + }); + + it('goes to the shell, with the account in view, and again when it changes', () => { + const named: string[] = []; + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', windows: { setMenuName: (name: string) => named.push(name) } }; + useAccountStore.setState({ accounts: [{ id: 'acc-1', email: 'a@example.org' }], activeAccountId: 'acc-1' } as never); + const { rerender } = render(); + expect(named).toEqual(['Inbox - a@example.org']); + rerender(); + expect(named).toEqual(['Inbox - a@example.org', 'Sent - a@example.org']); + rerender(); + expect(named.at(-1)).toBe('A subject'); + }); + + it('is nothing to an older shell, or in a browser', () => { + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', windows: {} }; + expect(() => render()).not.toThrow(); + }); +}); diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 23044b5de..467f918d1 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -17,6 +17,8 @@ export interface DesktopWindowInfo { id: number; type: DesktopWindowType; title: string; + /** What the page calls the window in the Window menu (`setMenuName`), if it said. */ + menuName?: string; focused: boolean; /** False for a main window hidden while pop-outs are open. */ visible?: boolean; @@ -46,6 +48,8 @@ export interface DesktopWindowsBridge { /** What this window's open drafts come back as at the next start: pop-out composer addresses. */ setRestore?: (urls: string[]) => void; setRestoreEnabled?: (enabled: boolean) => void; + /** This window's name in the Window menu: lib/desktop/window-name.ts. */ + setMenuName?: (name: string) => void; /** Signed in, with a session to lend: windows from last time can open. */ sessionReady?: () => void; /** Nobody is signed in any more: a pop-out would find no session. */ diff --git a/lib/desktop/window-name.ts b/lib/desktop/window-name.ts new file mode 100644 index 000000000..413421f90 --- /dev/null +++ b/lib/desktop/window-name.ts @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect } from 'react'; +import { getDesktopBridge } from './bridge'; +import { useAccountStore } from '@/stores/account-store'; + +/** + * What the desktop app's Window menu calls this window (the shell numbers two alike and + * cuts long ones). The main window goes by its view and account ("Inbox - a@example.org"), + * not by the message open in it, so it never reads like a message window; a message window + * by its subject; a composer by its subject or "New message". Nothing in a browser. + */ +export function windowMenuName(view: string | null | undefined, account: string | null | undefined): string | null { + const parts = [view?.trim(), account?.trim()].filter(Boolean); + return parts.length ? parts.join(' - ') : null; +} + +/** Tells the shell this window's Window menu name. `withAccount` adds the account in view, for the main window. */ +export function useDesktopWindowName(view: string | null | undefined, { withAccount = false }: { withAccount?: boolean } = {}): void { + const account = useAccountStore((s) => (withAccount ? s.accounts.find((a) => a.id === s.activeAccountId)?.email ?? null : null)); + const name = windowMenuName(view, account); + useEffect(() => { + if (!name) return; + getDesktopBridge()?.windows?.setMenuName?.(name); + }, [name]); +}