fix: save the open message as a draft before Compose, Reply all or Forward opens another

This commit is contained in:
Linus Rath
2026-11-04 11:26:02 +01:00
parent 79fde240ac
commit 890c49169b
2 changed files with 88 additions and 31 deletions
@@ -172,3 +172,55 @@ describe('Compose while a reply is docked (compose-p2-1)', () => {
});
}
});
describe('another composer while one is open (compose-p2-2)', () => {
it('closes the reply through its own close (save, "Draft saved") before Compose opens', async () => {
stub.close = 'saves';
render(<MailApp />);
fireEvent.click(await screen.findByText('stub reply'));
await screen.findByTestId('stub-composer');
fireEvent.click(screen.getByText('stub compose'));
await waitFor(() => expect(composerMode()).toBe('compose'));
expect(stub.closes).toEqual(['reply']);
});
for (const [action, mode] of [['stub reply all', 'replyAll'], ['stub forward', 'forward']] as const) {
it(`closes the reply through its own close before ${action} opens`, async () => {
stub.close = 'saves';
render(<MailApp />);
fireEvent.click(await screen.findByText('stub reply'));
await screen.findByTestId('stub-composer');
fireEvent.click(screen.getByText(action));
await waitFor(() => expect(composerMode()).toBe(mode));
expect(stub.closes).toEqual(['reply']);
});
}
it('closes a new message before another new message opens', async () => {
stub.close = 'saves';
render(<MailApp />);
fireEvent.click(screen.getByText('stub compose'));
await screen.findByTestId('stub-composer');
const mounted = stub.mounts.length;
fireEvent.click(screen.getByText('stub compose'));
await waitFor(() => expect(stub.mounts.length).toBe(mounted + 1));
expect(stub.closes).toEqual(['compose']);
});
it('keeps the open composer when its save fails', async () => {
stub.close = 'fails';
render(<MailApp />);
fireEvent.click(await screen.findByText('stub reply'));
await screen.findByTestId('stub-composer');
const mounted = stub.mounts.length;
fireEvent.click(screen.getByText('stub compose'));
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
expect(stub.closes).toEqual(['reply']);
expect(stub.mounts.length).toBe(mounted);
expect(composerMode()).toBe('reply');
});
});
+36 -31
View File
@@ -319,6 +319,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// already uses the same seam), so entry points that replace the session can
// ask before discarding unsaved text.
const composerRequestCloseRef = useRef<((afterClose?: () => void) => void) | null>(null);
// One composer at a time (compose-p2-2). Whatever opens a composer closes
// the one on screen first, the way its X does: it saves and says "Draft
// saved" with Discard, then the new one opens. When that save fails the old
// composer stays with its question, and this never settles: nothing opens.
const closeOpenComposer = useCallback(() => new Promise<void>((resolve) => {
const requestClose = composerRequestCloseRef.current;
if (showComposerRef.current && requestClose) requestClose(resolve);
else resolve();
}), []);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
@@ -1176,12 +1185,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
},
onCompose: () => {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
});
void handleCompose();
},
onFocusSearch: () => {
if (isScheduledView) return;
@@ -2824,11 +2828,24 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setPendingDraft(null);
}, []);
// A new message, from every Compose and New message there is.
const handleCompose = async (onOpen?: () => void) => {
await closeOpenComposer();
await withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
onOpen?.();
});
};
// A minimised composer's edits live only in pendingDraft. Before an
// automatic sign-out it is opened again and saves them itself.
useMinimisedComposerFlush(!showComposer && pendingDraft?.unsaved === true, () => setShowComposer(true));
const handleReply = async (draftText?: string) => {
await closeOpenComposer();
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
const enrichedEmail = await emailHooks.onBeforeComposeOpenToReply.transform(selectedEmail);
@@ -2867,6 +2884,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
: selectedEmail;
let draft = draftCandidate;
if (!draft) return;
await closeOpenComposer();
// The login and account the draft lives in (resolveDraftSource). The
// composer removes the old version there when it saves, sends or
@@ -3121,6 +3139,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t, tComposer, tViewer]);
const handleReplyAll = async (draftText?: string) => {
await closeOpenComposer();
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
const enrichedEmail = await emailHooks.onBeforeComposeOpenToReplyAll.transform(selectedEmail);
@@ -3210,6 +3229,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [isEmbedded]);
const handleForward = async () => {
await closeOpenComposer();
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
const enrichedEmail = await emailHooks.onBeforeComposeOpenToForward.transform(selectedEmail);
@@ -3256,6 +3276,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// unselected row. See the list context-menu wiring below.
const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => {
if (!email) return;
await closeOpenComposer();
email = await emailHooks.onBeforeComposeOpenToForwardAsAttachment.transform(email);
// Same filename options "Export as .eml" uses (see emailFilenameOptions
// in email-viewer.tsx), so the two actions produce consistent filenames
@@ -3324,6 +3345,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const handleBatchForwardAsAttachment = async () => {
const selected = resolveSelectedEmails(useEmailStore.getState());
if (selected.length < 2) return; // menu only offers this for >1; guards future wiring
await closeOpenComposer();
const {
emailDownloadTemplate,
@@ -4817,14 +4839,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onImportEmail: handleImportEmailFromContextMenu,
onRefreshMailboxes: handleRefreshMailboxes,
onCompose: () => {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) {
setSidebarOpen(false);
setActiveView('viewer');
}
void handleCompose(() => {
if (isMobile) setSidebarOpen(false);
});
},
onSidebarClose: () => setSidebarOpen(false),
@@ -5155,6 +5171,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Handle reply from conversation view
const handleConversationReply = async (email: Email) => {
await closeOpenComposer();
email = await emailHooks.onBeforeComposeOpenToReply.transform(email);
selectEmail(email);
await prepareComposerQuoteHeader(email, 'reply');
@@ -5167,6 +5184,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
};
const handleConversationReplyAll = async (email: Email) => {
await closeOpenComposer();
email = await emailHooks.onBeforeComposeOpenToReplyAll.transform(email);
selectEmail(email);
await prepareComposerQuoteHeader(email, 'replyAll');
@@ -5179,6 +5197,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
};
const handleConversationForward = async (email: Email) => {
await closeOpenComposer();
email = await emailHooks.onBeforeComposeOpenToForward.transform(email);
selectEmail(email);
await prepareComposerQuoteHeader(email, 'forward');
@@ -5888,12 +5907,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}}
onClearSearch={() => void handleClearSearch()}
onCompose={() => {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
});
void handleCompose();
}}
onMarkAsRead={async (email, read) => {
await markReadWithUndo(email, read).catch(toastUpdateFailure);
@@ -5936,12 +5950,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
button no longer sits on the list's rows. */}
{!mvShell && (isMobile || isEmbedded) && <Button
onClick={() => {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
});
void handleCompose();
}}
className={cn(
// Under the drawer's backdrop (z-40), which comes earlier in the page.
@@ -6216,11 +6225,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}}
onCompose={() => {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
});
void handleCompose();
}}
emptyPaneContent={emptyPaneBesideList && selectedEmailKeys.size > 0 && !isScheduledView && !mvShell
? <div ref={setSelectionPaneHost} className="m-auto p-8" />