fix: count a pop-out's undo send from the delay the user picked

This commit is contained in:
Linus Rath
2026-11-04 13:16:44 +01:00
parent f05af5a590
commit 6ec867b4e0
3 changed files with 28 additions and 8 deletions
@@ -7,6 +7,7 @@ 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 { useSettingsStore } from '@/stores/settings-store';
import { emailHooks } from '@/lib/plugin-hooks';
// The composer's window: when it tells the opening window to let go, and what
@@ -614,6 +615,15 @@ describe('a send held for undo', () => {
expect(sent?.notice.releasesAt).toBe(sendAt);
});
// The server's time runs past the delay: "Sending in 6 seconds" for 5 s.
it('never counts from more than the delay the user picked', async () => {
const before = useSettingsStore.getState().sendDelaySeconds;
useSettingsStore.setState({ sendDelaySeconds: 5 } as never);
await sendFromWindow(5.8);
expect(screen.getByTestId('popout-sending')).toHaveAttribute('data-seconds', '5');
useSettingsStore.setState({ sendDelaySeconds: before } as never);
});
// compose-24: the window sat with a greyed Send until the server answered.
it('says "Sending…" as soon as Send goes through, before the server answers', async () => {
let answer!: () => void;
+7 -4
View File
@@ -10,6 +10,8 @@ import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePluginStore } from "@/stores/plugin-store";
import { toast } from "@/stores/toast-store";
import { useSettingsStore } from "@/stores/settings-store";
import { secondsUntilSend } from "@/lib/outgoing/send-progress-toast";
import { formatRejectedRecipients } from "@/lib/jmap/client";
import { debug } from "@/lib/debug";
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
@@ -68,9 +70,9 @@ interface ReopenTarget {
}
/** "Sending…" or "Sent", where the composer was, with the send icon. */
function SendStatus({ text, children }: { text: string; children?: React.ReactNode }) {
function SendStatus({ text, seconds, children }: { text: string; seconds?: number; children?: React.ReactNode }) {
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">
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center" data-testid="popout-sending" data-seconds={seconds}>
<Send className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<p role="status" className="text-sm text-foreground">{text}</p>
{children}
@@ -134,11 +136,12 @@ function SendingWithUndo({ held, onUndone, onGone }: {
onUndone(held.isSmime ? null : restored);
};
const seconds = Math.max(1, Math.ceil((sendAt - now) / 1000));
// As the main window's toast counts: never past the delay the user picked.
const seconds = secondsUntilSend(sendAt, now, useSettingsStore.getState().sendDelaySeconds);
// Once Undo is over, "Sent" for the moment before the window goes, as the
// main window's toast says it.
return (
<SendStatus text={over && !busy ? tSend("sent") : tViewer("sending_in", { seconds })}>
<SendStatus text={over && !busy ? tSend("sent") : tViewer("sending_in", { seconds })} seconds={over ? undefined : seconds}>
{!over && (
<Button onClick={() => void undo()} disabled={busy} data-testid="popout-undo-send">
{tViewer("undo_send")}
+11 -4
View File
@@ -71,13 +71,20 @@ export interface UndoCountdownOptions {
* word that the message went. Stops by itself once the toast is gone (Undo,
* Send now, closed). Returns a stop for the caller.
*/
/**
* Whole seconds until a held send goes, at least 1 and never more than the
* delay the user picked: the server's time runs a moment past it, which
* showed "Sending in 6 seconds" for a 5 s delay.
*/
export function secondsUntilSend(sendAt: number, now: number, maxSeconds?: number): number {
const left = Math.max(1, Math.ceil((sendAt - now) / 1000));
return maxSeconds && maxSeconds > 0 ? Math.min(left, maxSeconds) : left;
}
export function runUndoSendCountdown(id: string, options: UndoCountdownOptions): () => void {
const now = options.now ?? Date.now;
const present = () => useToastStore.getState().toasts.some((t) => t.id === id);
const secondsLeft = () => {
const left = Math.max(1, Math.ceil((options.sendAt - now()) / 1000));
return options.maxSeconds && options.maxSeconds > 0 ? Math.min(left, options.maxSeconds) : left;
};
const secondsLeft = () => secondsUntilSend(options.sendAt, now(), options.maxSeconds);
let shown = -1;
const tick = () => {
if (!present()) return stop();