fix: count down the seconds left on the undo-send bar

This commit is contained in:
Linus Rath
2026-10-31 13:52:37 +01:00
parent 47b922cab0
commit d1c44f52ba
3 changed files with 74 additions and 3 deletions
+18 -3
View File
@@ -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() {
]}
>
<View style={styles.bar} accessibilityLiveRegion="polite">
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
{/* The spoken label stays the first one, so the live region is not
read out again every second of the countdown. */}
<Text style={styles.label} numberOfLines={1} accessibilityLabel={shown.label}>
{shown.send ? undoSendLabel(shown.send, now) : shown.label}
</Text>
{shown.send && (
<Pressable
onPress={onSendNow}
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
vi.mock('../../api/jmap-client', () => ({
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\}/);
});
});
+16
View File
@@ -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<PendingUndoSend, 'createdAt' | 'delaySeconds'>, 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<PendingUndoSend, 'createdAt' | 'delaySeconds'>, 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;