feat: keep undo send in the pop-out or quick compose window until the time is over

This commit is contained in:
Linus Rath
2026-11-04 06:44:56 +01:00
parent c0300bcb7f
commit 7d2859e064
2 changed files with 244 additions and 6 deletions
@@ -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<typeof useEmailStore.getState>;
let sendAt: string;
let cancelUndoSend: ReturnType<typeof vi.fn>;
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<unknown>; 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(<PopoutComposeApp />);
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<PopoutMessage, { type: 'sent' }> | 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<PopoutMessage, { type: 'sent' }> | undefined;
expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1');
});
});
+156 -6
View File
@@ -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<PopoutSentNotice["pendingUndoSend"]>;
/** 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 (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center" data-testid="popout-sending">
<Send className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<p role="status" className="text-sm text-foreground">{tViewer("sending_in", { seconds })}</p>
{!over && (
<Button onClick={() => void undo()} disabled={busy} data-testid="popout-undo-send">
{tViewer("undo_send")}
</Button>
)}
</div>
);
}
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<HeldSend | null>(null);
const [held, setHeld] = useState<HeldSend | null>(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 <SendingWithUndo held={held} onUndone={heldUndone} onGone={heldGone} />;
return (
<ErrorBoundary fallback={ComposerErrorFallback}>
<EmailComposer
@@ -405,6 +534,26 @@ export function PopoutComposeApp() {
// draft is the newest there is. An ordinary reload remembers as it unloads.
const [handoffId] = useState(() => (named && draftId && !rememberedComposePayload(named) ? null : named));
const [payload, setPayload] = useState<ComposePopoutPayload | null>(null);
// After Undo: the message back in Drafts, opened again in a new composer here.
const [reopen, setReopen] = useState<ReopenTarget | null>(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 (
<PopoutShell kind="compose" accountId={accountId}>
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} />
{payload ? <PopoutComposer key={generation} payload={payload} handoffId={generation === 0 ? handoffId : null} onReopen={handleReopen} />
: reopen ? <RestoredDraft draftId={reopen.draftId} accountId={reopen.accountId} ownerAccountId={reopen.ownerAccountId} onReady={reopened} />
: handoffId ? <PopoutSkeleton label={t("opening")} />
: draftId ? <RestoredDraft draftId={draftId} accountId={accountId} ownerAccountId={ownerAccountId} onReady={setPayload} />
: <NewMessageGate onReady={setPayload} />}