mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
feat: name the desktop app's windows in the Window menu by folder and account, subject or New message
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user