mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
feat: name the active account in the browser-tab title
The tab title was "<context> - <app>", so two mailboxes open side by side read the same in the tab strip and in the browser's session-restore list. It is now "<context> - <account> - <app>", the order Gmail and Outlook Web use: browsers cut a long title from the end, so the part that changes comes first and the app name last. There is no setting for it; the account is what tells the tabs apart. The formatting, and the mail view's choice of context (composer, then message, then mailbox), live in lib/tab-title.ts with tests. The mail view sets its title through useDocumentTitle(), which other surfaces can use too. Next renders the root layout's metadata <title> again on every client-side navigation, after the surface has mounted; the hook puts its title back when that happens. Only the surface that set the title last does so, so two titles never fight. Refs #1159 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ccf6bf7706
commit
8ca7dbbe9b
@@ -99,7 +99,8 @@ import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-iden
|
||||
import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft } from "@/components/icons";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { mailTitleContext } from "@/lib/tab-title";
|
||||
import { usePluginStore } from "@/stores/plugin-store";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
@@ -165,7 +166,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const t = useTranslations();
|
||||
const tCommon = useTranslations('common');
|
||||
const tQuote = useTranslations('quote_header');
|
||||
const { appName } = useConfig();
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
// Phones present search full-screen from the header field rather than
|
||||
// giving it a permanent second bar under the header.
|
||||
@@ -943,37 +943,33 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [clearPendingUndoSend, pendingUndoSend]);
|
||||
|
||||
// Update page title based on context
|
||||
useEffect(() => {
|
||||
let title = appName;
|
||||
// Page title: "<context> - <account> - <app>" (see useDocumentTitle)
|
||||
const titleContext = useMemo(() => {
|
||||
// Composing email
|
||||
const composer = showComposer
|
||||
? {
|
||||
compose: t('email_composer.new_message'),
|
||||
reply: t('email_composer.reply'),
|
||||
replyAll: t('email_composer.reply_all'),
|
||||
forward: t('email_composer.forward'),
|
||||
}[composerMode] || t('email_composer.new_message')
|
||||
: null;
|
||||
|
||||
if (showComposer) {
|
||||
// Composing email
|
||||
const modeText = {
|
||||
compose: t('email_composer.new_message'),
|
||||
reply: t('email_composer.reply'),
|
||||
replyAll: t('email_composer.reply_all'),
|
||||
forward: t('email_composer.forward'),
|
||||
}[composerMode] || t('email_composer.new_message');
|
||||
title = `${modeText} - ${appName}`;
|
||||
} else if (selectedEmail) {
|
||||
// Reading email
|
||||
const subject = selectedEmail.subject || t('email_viewer.no_subject');
|
||||
title = `${subject} - ${appName}`;
|
||||
} else if (selectedMailbox && mailboxes.length > 0) {
|
||||
// Mailbox view
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
if (mailbox) {
|
||||
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
|
||||
const unreadCount = mailbox.unreadEmails || 0;
|
||||
title = unreadCount > 0
|
||||
? `${mailboxName} (${unreadCount}) - ${appName}`
|
||||
: `${mailboxName} - ${appName}`;
|
||||
}
|
||||
// Reading email
|
||||
const subject = selectedEmail ? selectedEmail.subject || t('email_viewer.no_subject') : null;
|
||||
|
||||
// Mailbox view
|
||||
let mailboxLine: string | null = null;
|
||||
const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined;
|
||||
if (mailbox) {
|
||||
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
|
||||
const unreadCount = mailbox.unreadEmails || 0;
|
||||
mailboxLine = unreadCount > 0 ? `${mailboxName} (${unreadCount})` : mailboxName;
|
||||
}
|
||||
|
||||
document.title = title;
|
||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
|
||||
return mailTitleContext({ composer, subject, mailbox: mailboxLine });
|
||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t]);
|
||||
useDocumentTitle(titleContext);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Acme Mail' }) }));
|
||||
|
||||
import { useDocumentTitle } from '@/hooks/use-document-title';
|
||||
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
|
||||
|
||||
const account = (id: string, email: string) => ({ id, email }) as AccountEntry;
|
||||
|
||||
/**
|
||||
* What Next does on a client-side navigation: it renders the root layout's
|
||||
* metadata <title> again, after the new surface has mounted.
|
||||
*/
|
||||
function rerenderMetadataTitle(title = 'Acme Mail'): void {
|
||||
document.head.querySelectorAll('title').forEach((el) => el.remove());
|
||||
const el = document.createElement('title');
|
||||
el.textContent = title;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
|
||||
/** Drains MutationObserver callbacks. */
|
||||
async function settle(): Promise<void> {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('useDocumentTitle', () => {
|
||||
beforeEach(() => {
|
||||
document.title = '';
|
||||
useAccountStore.setState({
|
||||
accounts: [account('a', 'jane@example.com'), account('b', 'joe@example.com')],
|
||||
activeAccountId: 'a',
|
||||
});
|
||||
});
|
||||
|
||||
it('names the active account between the context and the app', () => {
|
||||
renderHook(() => useDocumentTitle('Calendar'));
|
||||
expect(document.title).toBe('Calendar - jane@example.com - Acme Mail');
|
||||
});
|
||||
|
||||
it('follows an account switch', () => {
|
||||
renderHook(() => useDocumentTitle('Calendar'));
|
||||
act(() => useAccountStore.setState({ activeAccountId: 'b' }));
|
||||
expect(document.title).toBe('Calendar - joe@example.com - Acme Mail');
|
||||
});
|
||||
|
||||
it('shows the account and the app when the surface adds no context', () => {
|
||||
renderHook(() => useDocumentTitle());
|
||||
expect(document.title).toBe('jane@example.com - Acme Mail');
|
||||
});
|
||||
|
||||
it('shows the context and the app when no account is in view', () => {
|
||||
useAccountStore.setState({ accounts: [], activeAccountId: null });
|
||||
renderHook(() => useDocumentTitle('Settings'));
|
||||
expect(document.title).toBe('Settings - Acme Mail');
|
||||
});
|
||||
|
||||
it('puts its title back when the metadata title is rendered again', async () => {
|
||||
renderHook(() => useDocumentTitle('Settings'));
|
||||
rerenderMetadataTitle();
|
||||
await waitFor(() => expect(document.title).toBe('Settings - jane@example.com - Acme Mail'));
|
||||
});
|
||||
|
||||
it('leaves the title to the surface that set it last', async () => {
|
||||
renderHook(() => useDocumentTitle('Inbox'));
|
||||
renderHook(() => useDocumentTitle('Settings'));
|
||||
rerenderMetadataTitle();
|
||||
await waitFor(() => expect(document.title).toBe('Settings - jane@example.com - Acme Mail'));
|
||||
await settle();
|
||||
expect(document.title).toBe('Settings - jane@example.com - Acme Mail');
|
||||
});
|
||||
|
||||
it('stops re-applying once unmounted', async () => {
|
||||
const { unmount } = renderHook(() => useDocumentTitle('Settings'));
|
||||
unmount();
|
||||
rerenderMetadataTitle('Sign in');
|
||||
await settle();
|
||||
expect(document.title).toBe('Sign in');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { formatTabTitle } from '@/lib/tab-title';
|
||||
|
||||
// The surface that set the title last owns it. One that is still mounted
|
||||
// (during a route transition) stops re-applying, so two titles never fight.
|
||||
let owner: symbol | null = null;
|
||||
|
||||
/**
|
||||
* Sets the browser-tab title for a surface: "<context> - <account> - <app>"
|
||||
* (see formatTabTitle). Pass a context that is already localized, or nothing
|
||||
* for a surface that has nothing to add.
|
||||
*
|
||||
* Next renders the root layout's metadata <title> again on every client-side
|
||||
* navigation, after the new surface has mounted, which puts the bare app name
|
||||
* back. So the title is re-applied whenever <head> changes and it is not ours.
|
||||
*/
|
||||
export function useDocumentTitle(context?: string | null): void {
|
||||
const { appName } = useConfig();
|
||||
// The account actually in view, so two windows on two accounts stay apart.
|
||||
const account = useAccountStore(
|
||||
(s) => s.accounts.find((a) => a.id === s.activeAccountId)?.email || null,
|
||||
);
|
||||
const title = formatTabTitle(context, account, appName);
|
||||
|
||||
useEffect(() => {
|
||||
const self = Symbol('document-title');
|
||||
owner = self;
|
||||
// Termination: setting the title is itself a <head> mutation; on that run
|
||||
// the title is already ours and nothing is written.
|
||||
const apply = () => {
|
||||
if (owner === self && document.title !== title) document.title = title;
|
||||
};
|
||||
apply();
|
||||
const observer = new MutationObserver(apply);
|
||||
observer.observe(document.head, { childList: true, subtree: true, characterData: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (owner === self) owner = null;
|
||||
};
|
||||
}, [title]);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatTabTitle, mailTitleContext } from '../tab-title';
|
||||
|
||||
describe('formatTabTitle', () => {
|
||||
it('puts the context first, the account next and the app name last', () => {
|
||||
expect(formatTabTitle('Inbox (3)', 'jane@example.com', 'Acme Mail')).toBe(
|
||||
'Inbox (3) - jane@example.com - Acme Mail',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves out a missing account', () => {
|
||||
expect(formatTabTitle('Inbox', null, 'Acme Mail')).toBe('Inbox - Acme Mail');
|
||||
expect(formatTabTitle('Inbox', '', 'Acme Mail')).toBe('Inbox - Acme Mail');
|
||||
});
|
||||
|
||||
it('leaves out a missing context', () => {
|
||||
expect(formatTabTitle(undefined, 'jane@example.com', 'Acme Mail')).toBe('jane@example.com - Acme Mail');
|
||||
expect(formatTabTitle('', 'jane@example.com', 'Acme Mail')).toBe('jane@example.com - Acme Mail');
|
||||
});
|
||||
|
||||
it('falls back to the app name alone', () => {
|
||||
expect(formatTabTitle(null, null, 'Acme Mail')).toBe('Acme Mail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailTitleContext', () => {
|
||||
const view = { composer: 'Reply', subject: 'Quarterly report', mailbox: 'Inbox (3)' };
|
||||
|
||||
it('names the composer while it is open', () => {
|
||||
expect(mailTitleContext(view)).toBe('Reply');
|
||||
});
|
||||
|
||||
it('names the open message otherwise', () => {
|
||||
expect(mailTitleContext({ ...view, composer: null })).toBe('Quarterly report');
|
||||
});
|
||||
|
||||
it('names the mailbox when no message is open', () => {
|
||||
expect(mailTitleContext({ mailbox: 'Inbox (3)' })).toBe('Inbox (3)');
|
||||
});
|
||||
|
||||
it('has nothing to add before a mailbox is selected', () => {
|
||||
expect(mailTitleContext({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// Browser-tab titles read "<context> - <account> - <app>", the order Gmail and
|
||||
// Outlook Web use. Browsers cut a long title from the end, so the part that
|
||||
// changes (folder, unread count, subject) goes first and the app name last.
|
||||
// The account sits in between, so two mailboxes open side by side stay apart
|
||||
// in the tab strip and in session restore. Empty parts are left out. (#1159)
|
||||
export function formatTabTitle(
|
||||
context: string | null | undefined,
|
||||
account: string | null | undefined,
|
||||
appName: string,
|
||||
): string {
|
||||
return [context, account, appName].filter(Boolean).join(' - ');
|
||||
}
|
||||
|
||||
/** What the mail view is showing, already localized. */
|
||||
export interface MailTitleView {
|
||||
/** Composer label while the composer is open ("New message", "Reply", ...). */
|
||||
composer?: string | null;
|
||||
/** Subject of the open message. */
|
||||
subject?: string | null;
|
||||
/** Selected mailbox, with its unread count when there is one. */
|
||||
mailbox?: string | null;
|
||||
}
|
||||
|
||||
/** The context part of the mail view's tab title: composer, then message, then mailbox. */
|
||||
export function mailTitleContext(view: MailTitleView): string | null {
|
||||
return view.composer || view.subject || view.mailbox || null;
|
||||
}
|
||||
Reference in New Issue
Block a user