feat: name the desktop app's windows in the Window menu by folder and account, subject or New message

This commit is contained in:
Linus Rath
2026-11-04 12:25:33 +01:00
parent 5e34dc59ab
commit df62335545
11 changed files with 106 additions and 0 deletions
+2
View File
@@ -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();
+2
View File
@@ -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();
+2
View File
@@ -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();
@@ -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<string, unknown> = {};
let named: string[] = [];
let batchMoveToMailbox: ReturnType<typeof vi.fn>;
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<string, unknown>) => { 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(<MailApp />);
await waitFor(() => expect(named.at(-1)).toBe('sidebar.mailboxes.inbox'));
expect(named).not.toContain('Subject a');
});
});
+7
View File
@@ -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
+5
View File
@@ -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"
/>
</ErrorBoundary>
+3
View File
@@ -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<string | null>(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.
+2
View File
@@ -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);
@@ -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(<Named view="Inbox" withAccount />);
expect(named).toEqual(['Inbox - a@example.org']);
rerender(<Named view="Sent" withAccount />);
expect(named).toEqual(['Inbox - a@example.org', 'Sent - a@example.org']);
rerender(<Named view="A subject" />);
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(<Named view="Inbox" />)).not.toThrow();
});
});
+4
View File
@@ -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. */
+26
View File
@@ -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]);
}