From 7d2859e064b0ca2d33650ccc3b7b6f05a31ffdd6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:44:56 +0100 Subject: [PATCH] feat: keep undo send in the pop-out or quick compose window until the time is over --- .../__tests__/popout-compose-app.test.tsx | 88 ++++++++++ components/popout/popout-compose-app.tsx | 162 +++++++++++++++++- 2 files changed, 244 insertions(+), 6 deletions(-) diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 6ceedf1fa..d92e90439 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -4,6 +4,7 @@ import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-ch import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { createComposeHandoff, rememberComposePayload, rememberedComposePayload } from '@/lib/popout/compose-handoff'; import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; import { useIdentityStore } from '@/stores/identity-store'; import { usePluginStore } from '@/stores/plugin-store'; import { emailHooks } from '@/lib/plugin-hooks'; @@ -547,3 +548,90 @@ describe('after this page crashed', () => { expect(rememberedComposePayload('h2')).toBeNull(); }); }); + +describe('a send held for undo', () => { + const closeSelf = vi.fn(); + const MAILBOXES = [{ id: 'mDrafts', role: 'drafts' }, { id: 'mSent', role: 'sent' }]; + const startDraft = { + id: 'd0', subject: 'Before sending', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [], + }; + const restoredDraft = { + id: 'e1', subject: 'Back again', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [], + }; + let originalEmail: ReturnType; + let sendAt: string; + let cancelUndoSend: ReturnType; + + beforeEach(() => { + closeSelf.mockClear(); + originalEmail = useEmailStore.getState(); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf, setRestore: vi.fn() } } as never; + useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never); + identityMocks.multi = { enabled: false, allIdentities: [], groups: [] }; + const client = { getEmail: vi.fn(async (id: string) => (id === 'e1' ? restoredDraft : startDraft)), getMailboxes: vi.fn(async () => MAILBOXES) }; + useAuthStore.setState({ client: client as never, getClientForAccount: (id: string) => (id === 'alice' ? client : undefined) } as never); + cancelUndoSend = vi.fn(async () => restoredDraft); + useEmailStore.setState({ + sendEmail: vi.fn(async () => { + useEmailStore.setState({ pendingUndoSend: { submissionId: 'sub1', emailId: 'e1', identityId: 'id-1', sendAt, isSmime: false } }); + return { scheduled: true, emailSubmissionId: 'sub1', emailId: 'e1', sendAt }; + }), + cancelUndoSend, + } as never); + }); + + afterEach(() => { + useEmailStore.setState(originalEmail); + window.bulwarkDesktop = undefined; + }); + + type Sending = { onSend: (data: object) => Promise; onClose: () => void }; + const DATA = { to: ['a@example.org'], cc: [], bcc: [], subject: 'Hi', body: 'Text', localAccountId: 'alice' }; + + /** Opens the window, sends from it and lets the composer close, as it does after a send. */ + async function sendFromWindow(seconds: number) { + sendAt = new Date(Date.now() + seconds * 1000).toISOString(); + window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice'); + render(); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending')); + const props = composer as unknown as Sending; + await act(async () => { await props.onSend(DATA); }); + act(() => props.onClose()); + } + + it('stays open with "Sending in" and Undo, and does not hand the undo to the main window', async () => { + await sendFromWindow(5); + expect(screen.getByTestId('popout-sending').textContent).toContain('sending_in'); + expect(screen.getByRole('button', { name: 'undo_send' })).toBeInTheDocument(); + expect(closeSelf).not.toHaveBeenCalled(); + const sent = heard.find((message) => message.type === 'sent') as Extract | undefined; + expect(sent?.notice.pendingUndoSend ?? null).toBeNull(); + }); + + it('closes once the undo time is over', async () => { + await sendFromWindow(1.2); + expect(closeSelf).not.toHaveBeenCalled(); + await waitFor(() => expect(closeSelf).toHaveBeenCalled(), { timeout: 4000 }); + }); + + it('Undo takes the send back and opens the message again in this window', async () => { + await sendFromWindow(30); + fireEvent.click(screen.getByRole('button', { name: 'undo_send' })); + await waitFor(() => expect(cancelUndoSend).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ submissionId: 'sub1', localAccountId: 'alice' }))); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Back again')); + expect(closeSelf).not.toHaveBeenCalled(); + expect(new URLSearchParams(window.location.search).get('draft')).toBe('e1'); + }); + + it('a message scheduled for later still goes to the main window, and the window closes', async () => { + await sendFromWindow(3600); + expect(screen.queryByTestId('popout-sending')).toBeNull(); + expect(closeSelf).toHaveBeenCalled(); + const sent = heard.find((message) => message.type === 'sent') as Extract | undefined; + expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1'); + }); +}); diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 4b9f058a8..e29b8810d 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer"; import { ErrorBoundary, ComposerErrorFallback } from "@/components/error"; @@ -13,8 +13,9 @@ import { toast } from "@/stores/toast-store"; import { formatRejectedRecipients } from "@/lib/jmap/client"; import { debug } from "@/lib/debug"; import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; -import { announceSent } from "@/lib/plugin-after-send"; -import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type RescuedDraft } from "@/lib/popout/channel"; +import { announceSent, cancelAnnouncedSend, releaseAnnouncedSend } from "@/lib/plugin-after-send"; +import { cancelFailureToast } from "@/lib/send-failure-message"; +import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type PopoutSentNotice, type RescuedDraft } from "@/lib/popout/channel"; import { acknowledgeComposeHandoff, forgetComposePayload, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; import { returnComposerToMain, type ReturnResult } from "@/lib/popout/return-to-main"; @@ -30,6 +31,7 @@ import { emailHooks } from "@/lib/plugin-hooks"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { useIdentityStore } from "@/stores/identity-store"; import type { Email } from "@/lib/jmap/types"; +import { Send } from "@/components/icons"; import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell"; /** A warning with no main window left to show it stays this long before the window goes. */ @@ -49,6 +51,91 @@ const MAIN_CHECK_MS = 1000; /** How long a reloaded window waits to hear what became of the text it handed over on the way out. */ const RESCUE_ANSWER_MS = 2000; +/** A send held this long or less is an undo window ("Sending in 5 s"), not a message scheduled for later. */ +const UNDO_WINDOW_MS = 120_000; +/** Undo goes this long before the server lets the message go, as in the main window. */ +const UNDO_MARGIN_MS = 1000; +/** What the plugins are told when the hold runs out gets this long before the window goes. */ +const RELEASE_WAIT_MS = 1000; + +type HeldSend = NonNullable; + +/** A draft to open again in this window, after Undo brought the message back to Drafts. */ +interface ReopenTarget { + draftId: string; + accountId: string; + ownerAccountId?: string; +} + +/** + * A send held for undo, shown in the window it was sent from: that's where the + * user is looking, and quick compose is used with the main window out of sight. + * The window stays until the hold runs out, then goes. + */ +function SendingWithUndo({ held, onUndone, onGone }: { + held: HeldSend; + /** The send was taken back; the message, back in Drafts, when there is one to open. */ + onUndone: (restored: Email | null) => void; + onGone: () => void; +}) { + const tViewer = useTranslations("email_viewer"); + const [now, setNow] = useState(() => Date.now()); + const [busy, setBusy] = useState(false); + const sendAt = Date.parse(held.sendAt); + const over = !Number.isFinite(sendAt) || now >= sendAt - UNDO_MARGIN_MS; + + useEffect(() => { + if (over) return; + const timer = setInterval(() => setNow(Date.now()), 250); + return () => clearInterval(timer); + }, [over]); + + // Too late to undo: the plugins hear of the send now, and the window goes. + useEffect(() => { + if (!over || busy) return; + releaseAnnouncedSend(held.submissionId); + const timer = setTimeout(onGone, RELEASE_WAIT_MS); + return () => clearTimeout(timer); + }, [over, busy, held.submissionId, onGone]); + + const undo = async () => { + const auth = useAuthStore.getState(); + // Never another login: the submission id names another submission there. + const client = held.localAccountId ? auth.getClientForAccount(held.localAccountId) : auth.client; + if (!client) { + toast.error(tViewer("undo_send_signed_out")); + return; + } + setBusy(true); + let restored: Email | null; + try { + restored = await useEmailStore.getState().cancelUndoSend(client, held); + } catch (error) { + debug.error("Failed to undo the send:", error); + const { title, message } = cancelFailureToast(error, tViewer, tViewer("undo_send_failed")); + toast.error(title, message ? { message } : undefined); + setBusy(false); + return; + } + // Never left the server: plugins don't hear about it. + cancelAnnouncedSend(held.submissionId); + onUndone(held.isSmime ? null : restored); + }; + + const seconds = Math.max(0, Math.ceil((sendAt - now) / 1000)); + return ( +
+
+ ); +} + function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } { if (typeof window === "undefined") return { handoffId: null }; const search = new URLSearchParams(window.location.search); @@ -214,7 +301,12 @@ function NewMessageGate({ onReady }: { onReady: (payload: ComposePopoutPayload) } /** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */ -function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; handoffId: string | null }) { +function PopoutComposer({ payload, handoffId, onReopen }: { + payload: ComposePopoutPayload; + handoffId: string | null; + /** Undo brought the message back to Drafts: open it again here. */ + onReopen: (target: ReopenTarget) => void; +}) { const t = useTranslations(); const tQuick = useTranslations("desktopQuickCompose"); const client = useAuthStore((s) => s.client); @@ -267,6 +359,16 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; const accountId = data.localAccountId ?? useAuthStore.getState().activeAccountId; const pending = useEmailStore.getState().pendingUndoSend; + const withLogin = pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null; + // An undo window stays here, where Send was pressed: the window shows it + // and closes when it runs out. A message scheduled for later goes to the + // main window as before. + const remaining = withLogin ? Date.parse(withLogin.sendAt) - Date.now() : NaN; + const keepHere = result.scheduled && withLogin && Number.isFinite(remaining) && remaining <= UNDO_WINDOW_MS ? withLogin : null; + if (keepHere) { + heldRef.current = keepHere; + useEmailStore.setState({ pendingUndoSend: null }); + } const rejectedRecipients = result.rejectedRecipients?.length ? formatRejectedRecipients(result.rejectedRecipients) : undefined; const taken = await announceSentToMainWindow({ accountId, @@ -274,7 +376,7 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; queued: !!result.queued, filingError: !!result.filingError, rejectedRecipients, - pendingUndoSend: pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null, + pendingUndoSend: keepHere ? null : withLogin, }); // No main window to say so, and this is the only notice these get (#1123). if (!taken && (result.filingError || rejectedRecipients)) { @@ -301,11 +403,36 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; } }, [client, t]); + // A send held for undo, kept in this window until it runs out. + const heldRef = useRef(null); + const [held, setHeld] = useState(null); + const handleClose = useCallback(() => { + announceMailChanged(useAuthStore.getState().activeAccountId); + if (heldRef.current) { + setHeld(heldRef.current); + return; + } + closePopoutWindow(); + }, []); + + const heldGone = useCallback(() => { announceMailChanged(useAuthStore.getState().activeAccountId); closePopoutWindow(); }, []); + const heldUndone = useCallback((restored: Email | null) => { + const sent = heldRef.current; + heldRef.current = null; + const login = sent?.localAccountId ?? useAuthStore.getState().activeAccountId; + announceMailChanged(login); + if (!restored || !login) { + closePopoutWindow(); + return; + } + onReopen({ draftId: restored.id, accountId: login, ownerAccountId: sent?.submissionAccountId }); + }, [onReopen]); + const [mainWindow] = useState(watchMainWindow); useEffect(() => () => mainWindow.stop(), [mainWindow]); @@ -373,6 +500,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; }, }), [handoffId, mainWindow, payload]); + if (held) return ; + return ( (named && draftId && !rememberedComposePayload(named) ? null : named)); const [payload, setPayload] = useState(null); + // After Undo: the message back in Drafts, opened again in a new composer here. + const [reopen, setReopen] = useState(null); + const [generation, setGeneration] = useState(0); + const handleReopen = useCallback((target: ReopenTarget) => { + // A reload starts from the draft, as after a save. + const url = new URL(window.location.href); + url.searchParams.delete("handoff"); + url.searchParams.set("draft", target.draftId); + url.searchParams.set("account", target.accountId); + if (target.ownerAccountId) url.searchParams.set("owner", target.ownerAccountId); + else url.searchParams.delete("owner"); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); + setPayload(null); + setReopen(target); + }, []); + const reopened = useCallback((next: ComposePopoutPayload) => { + setReopen(null); + setGeneration((n) => n + 1); + setPayload(next); + }, []); useEffect(() => { if (!handoffId) return; @@ -438,7 +587,8 @@ export function PopoutComposeApp() { return ( - {payload ? + {payload ? + : reopen ? : handoffId ? : draftId ? : }