From 8ca7dbbe9be0369d9f196087e959983182169816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuz=20Y=C4=B1lmaz?= <33634879+oguzyilmaznet@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:04:54 +0300 Subject: [PATCH] feat: name the active account in the browser-tab title The tab title was " - ", 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 " - - ", 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 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> --- components/mail/mail-app.tsx | 56 +++++++-------- hooks/__tests__/use-document-title.test.tsx | 80 +++++++++++++++++++++ hooks/use-document-title.ts | 45 ++++++++++++ lib/__tests__/tab-title.test.ts | 44 ++++++++++++ lib/tab-title.ts | 27 +++++++ 5 files changed, 222 insertions(+), 30 deletions(-) create mode 100644 hooks/__tests__/use-document-title.test.tsx create mode 100644 hooks/use-document-title.ts create mode 100644 lib/__tests__/tab-title.test.ts create mode 100644 lib/tab-title.ts diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c145bb99a..c64d8a1bd 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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 diff --git a/hooks/__tests__/use-document-title.test.tsx b/hooks/__tests__/use-document-title.test.tsx new file mode 100644 index 000000000..91bd8f569 --- /dev/null +++ b/hooks/__tests__/use-document-title.test.tsx @@ -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'); + }); +}); diff --git a/hooks/use-document-title.ts b/hooks/use-document-title.ts new file mode 100644 index 000000000..841410bc9 --- /dev/null +++ b/hooks/use-document-title.ts @@ -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]); +} diff --git a/lib/__tests__/tab-title.test.ts b/lib/__tests__/tab-title.test.ts new file mode 100644 index 000000000..d8c67c4c0 --- /dev/null +++ b/lib/__tests__/tab-title.test.ts @@ -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(); + }); +}); diff --git a/lib/tab-title.ts b/lib/tab-title.ts new file mode 100644 index 000000000..37bd7e216 --- /dev/null +++ b/lib/tab-title.ts @@ -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; +}