From d1c44f52ba1cb3960424b2b751e75cbc7beb9287 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:22:19 +0100 Subject: [PATCH] fix: count down the seconds left on the undo-send bar --- src/components/UndoSnackbar.tsx | 21 ++++++++-- .../__tests__/undo-send-countdown.test.ts | 40 +++++++++++++++++++ src/stores/send-undo-store.ts | 16 ++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 src/components/__tests__/undo-send-countdown.test.ts diff --git a/src/components/UndoSnackbar.tsx b/src/components/UndoSnackbar.tsx index bffe0447..050729c3 100644 --- a/src/components/UndoSnackbar.tsx +++ b/src/components/UndoSnackbar.tsx @@ -8,7 +8,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens' import { useColors } from '../theme/colors'; import { undoSnackbarColors } from './undo-snackbar-colors'; import { useEmailStore } from '../stores/email-store'; -import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../stores/send-undo-store'; +import { useSendUndoStore, restoreUndoneSend, undoSendLabel, type PendingUndoSend } from '../stores/send-undo-store'; import { useLocaleStore } from '../stores/locale-store'; import type { RootStackParamList } from '../navigation/types'; import { announce } from '../lib/announce'; @@ -56,7 +56,8 @@ export function UndoSnackbar() { : null; const send: Shown | null = pendingSend ? { - label: t('email_composer.undo_send_label', 'Sending in {seconds}s…', { seconds: pendingSend.delaySeconds }), + // The text at the start; the bar counts down from it (below). + label: undoSendLabel(pendingSend, pendingSend.createdAt), createdAt: pendingSend.createdAt, visibleMs: Math.max(1000, pendingSend.delaySeconds * 1000 - 1000), send: pendingSend, @@ -74,6 +75,16 @@ export function UndoSnackbar() { if (current) setShown(current); }, [current]); + // A held send counts down: "Sending in 10s…", "Sending in 9s…". + const [now, setNow] = React.useState(() => Date.now()); + const counting = current?.send; + React.useEffect(() => { + if (!counting) return undefined; + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 250); + return () => clearInterval(id); + }, [counting]); + // Read the bar out on iOS, which has no live regions; Android reads the // bar's live region. Once per entry, not on every countdown re-render. const undoLabel = t('common.undo', 'Undo'); @@ -148,7 +159,11 @@ export function UndoSnackbar() { ]} > - {shown.label} + {/* The spoken label stays the first one, so the live region is not + read out again every second of the countdown. */} + + {shown.send ? undoSendLabel(shown.send, now) : shown.label} + {shown.send && ( ({ + jmapClient: { accountId: 'primary', connectedAccountId: 'primary', isConnected: true }, + RequestTimeoutError: class RequestTimeoutError extends Error {}, +})); + +import { undoSendLabel, undoSendSecondsLeft } from '../../stores/send-undo-store'; + +// The undo-send bar said "Sending in 10s…" for the whole delay: its text was +// built once from the delay. It counts down now. No React Native renderer in +// the test env, so the bar's wiring is checked in its source. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); +const HELD = { createdAt: 1_000_000, delaySeconds: 10 }; + +describe('undo-send countdown', () => { + it('counts the seconds left down from the delay', () => { + expect(undoSendSecondsLeft(HELD, HELD.createdAt)).toBe(10); + expect(undoSendSecondsLeft(HELD, HELD.createdAt + 400)).toBe(10); + expect(undoSendSecondsLeft(HELD, HELD.createdAt + 3_500)).toBe(7); + expect(undoSendSecondsLeft(HELD, HELD.createdAt + 9_000)).toBe(1); + // Never 0 or less, never more than the delay (a clock set back). + expect(undoSendSecondsLeft(HELD, HELD.createdAt + 20_000)).toBe(1); + expect(undoSendSecondsLeft(HELD, HELD.createdAt - 5_000)).toBe(10); + }); + + it('builds the bar text for the moment it is drawn', () => { + expect(undoSendLabel(HELD, HELD.createdAt)).toBe('Sending in 10s…'); + expect(undoSendLabel(HELD, HELD.createdAt + 4_200)).toBe('Sending in 6s…'); + }); + + it('redraws the bar text from a ticking clock', () => { + const src = read('components/UndoSnackbar.tsx'); + expect(src).toMatch(/setInterval\(\(\) => setNow\(Date\.now\(\)\), \d+\)/); + expect(src).toMatch(/\{shown\.send \? undoSendLabel\(shown\.send, now\) : shown\.label\}/); + }); +}); diff --git a/src/stores/send-undo-store.ts b/src/stores/send-undo-store.ts index 7b3b90ef..b14f5e33 100644 --- a/src/stores/send-undo-store.ts +++ b/src/stores/send-undo-store.ts @@ -77,6 +77,22 @@ function refuseOtherLogin(entry: PendingUndoSend): void { ); } +/** + * Whole seconds until a held send goes out, counted on this device's clock + * from when the delay started: the bar counts down "Sending in 10s…", + * "Sending in 9s…" and so on. Never below 1, since the bar closes a second + * before the release. + */ +export function undoSendSecondsLeft(entry: Pick, now: number): number { + const left = Math.ceil((entry.createdAt + entry.delaySeconds * 1000 - now) / 1000); + return Math.max(1, Math.min(entry.delaySeconds, left)); +} + +/** The undo bar's text for a held send at `now`. */ +export function undoSendLabel(entry: Pick, now: number): string { + return t('email_composer.undo_send_label', 'Sending in {seconds}s…', { seconds: undoSendSecondsLeft(entry, now) }); +} + interface SendUndoState { pending: PendingUndoSend | null; busy: boolean;