fix: follow the typed subject in a Pro compose tab's title

This commit is contained in:
Linus Rath
2026-11-03 07:55:33 +01:00
parent d2f23419e5
commit 09becdfd9d
3 changed files with 36 additions and 25 deletions
+7
View File
@@ -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 () => {
@@ -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(<ProComposeTabBody tabId="t" data={{ sessionId: 1, mode: 'compose', title: 'New message', replyTo: selected }} />);
expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'New message');
beforeEach(() => {
updateTabTitle.mockClear();
composer.onSubjectChange = null;
});
it('keeps the prefix the opener gave a reply', () => {
render(<ProComposeTabBody tabId="t" data={{ sessionId: 1, mode: 'reply', title: 'Re: Invoice August', replyTo: selected }} />);
expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'Re: Invoice August');
it('does not take the title of the message selected in the mail tab', () => {
render(<ProComposeTabBody tabId="t" data={{ sessionId: 1, mode: 'compose', title: 'email_composer.new_message', replyTo: selected }} />);
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(<ProComposeTabBody tabId="t" data={{ sessionId: 1, mode: 'compose', title: 'x', initialData: { subject: 'Lunch?' } as never }} />);
it('follows the subject as it is typed, and falls back when it is emptied', () => {
render(<ProComposeTabBody tabId="t" data={{ sessionId: 1, mode: 'compose', title: 'email_composer.new_message' }} />);
composer.onSubjectChange!('Lunch?');
expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'Lunch?');
composer.onSubjectChange!(' ');
expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'email_composer.new_message');
});
});
+9 -14
View File
@@ -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"
/>
</ErrorBoundary>