diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d7a9bd994..b2e4b5aff 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -348,6 +348,8 @@ interface EmailComposerProps { */ onDiscardDraft?: (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => void; onSaveState?: (data: ComposerDraftData) => void; + /** The subject as typed, for a host that shows it (a Pro compose tab's title). */ + onSubjectChange?: (subject: string) => void; className?: string; initialDraftText?: string; initialData?: ComposerDraftData | null; @@ -494,6 +496,7 @@ export function EmailComposer({ requestCloseRef, onDiscardDraft, onSaveState, + onSubjectChange, className, initialDraftText: initialDraftTextProp, initialData: initialDataProp, @@ -1828,6 +1831,10 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveSessionKey]); + useEffect(() => { + onSubjectChange?.(subject); + }, [onSubjectChange, subject]); + // Auto-save state on unmount (when user navigates away without explicitly closing) useEffect(() => { return () => { diff --git a/components/pro/__tests__/pro-compose-tab-title.test.tsx b/components/pro/__tests__/pro-compose-tab-title.test.tsx index d2da40b07..8d1246393 100644 --- a/components/pro/__tests__/pro-compose-tab-title.test.tsx +++ b/components/pro/__tests__/pro-compose-tab-title.test.tsx @@ -5,7 +5,13 @@ import React from 'react'; // The mail tab hands every compose tab the selected message as replyTo, a new // message too. A new message was titled after whatever message was open. -vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => React.createElement('div') })); +const composer = vi.hoisted(() => ({ onSubjectChange: null as ((s: string) => void) | null })); +vi.mock('@/components/email/email-composer', () => ({ + EmailComposer: (props: { onSubjectChange: (s: string) => void }) => { + composer.onSubjectChange = props.onSubjectChange; + return React.createElement('div'); + }, +})); vi.mock('@/components/error', () => ({ ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), ComposerErrorFallback: () => null, @@ -35,20 +41,23 @@ import { ProComposeTabBody } from '../pro-compose-tab-body'; const selected = { subject: 'Invoice August' }; describe('Pro compose tab title', () => { - beforeEach(() => updateTabTitle.mockClear()); - - it('stays the new-message title with a message selected in the mail tab', () => { - render(); - expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'New message'); + beforeEach(() => { + updateTabTitle.mockClear(); + composer.onSubjectChange = null; }); - it('keeps the prefix the opener gave a reply', () => { - render(); - expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'Re: Invoice August'); + it('does not take the title of the message selected in the mail tab', () => { + render(); + composer.onSubjectChange!(''); + expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'email_composer.new_message'); + expect(updateTabTitle).not.toHaveBeenCalledWith('t', 'Invoice August'); }); - it('follows the subject of a reopened draft', () => { - render(); + it('follows the subject as it is typed, and falls back when it is emptied', () => { + render(); + composer.onSubjectChange!('Lunch?'); expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'Lunch?'); + composer.onSubjectChange!(' '); + expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'email_composer.new_message'); }); }); diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index 33d2ba483..49e30379b 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -195,21 +195,15 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { const handleSaveState = useCallback((state: ComposerDraftData) => { updateComposeDraft(tabIdRef.current, state); - // Keep the tab title in sync with the working subject. - const subject = state.subject?.trim() || t('email_composer.new_message'); - updateTabTitle(tabIdRef.current, subject); - }, [updateComposeDraft, updateTabTitle, t]); + }, [updateComposeDraft]); - // On first mount, ensure the tab title matches whatever subject we were - // initialised with. The opener's title already says "Re: …" or "Fwd: …" for - // a reply or forward. replyTo is no source for it: the mail tab fills that - // in from the selected message for a new message too. - useEffect(() => { - const title = data.initialData?.subject?.trim() || data.title || t('email_composer.new_message'); - updateTabTitle(tabIdRef.current, title); - // Run once on mount only. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + // The tab's title is the subject as it is typed, so several open compose + // tabs can be told apart. replyTo is no source for it: the mail tab fills + // that in from the selected message for a new message too. + const newMessageTitle = t('email_composer.new_message'); + const handleSubjectChange = useCallback((subject: string) => { + updateTabTitle(tabIdRef.current, subject.trim() || newMessageTitle); + }, [updateTabTitle, newMessageTitle]); // Mirrors the standard shell (see the same prop in `components/mail/mail-app.tsx`). // Without it `mode === 'compose'` resolves `findComposeIdentityId(identities, @@ -252,6 +246,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { requestCloseRef={requestCloseRef} onDiscardDraft={handleDiscardDraft} onSaveState={handleSaveState} + onSubjectChange={handleSubjectChange} className="flex-1" />