mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: count down the seconds left on the undo-send bar
This commit is contained in:
@@ -8,7 +8,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
|
|||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { undoSnackbarColors } from './undo-snackbar-colors';
|
import { undoSnackbarColors } from './undo-snackbar-colors';
|
||||||
import { useEmailStore } from '../stores/email-store';
|
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 { useLocaleStore } from '../stores/locale-store';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { announce } from '../lib/announce';
|
import { announce } from '../lib/announce';
|
||||||
@@ -56,7 +56,8 @@ export function UndoSnackbar() {
|
|||||||
: null;
|
: null;
|
||||||
const send: Shown | null = pendingSend
|
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,
|
createdAt: pendingSend.createdAt,
|
||||||
visibleMs: Math.max(1000, pendingSend.delaySeconds * 1000 - 1000),
|
visibleMs: Math.max(1000, pendingSend.delaySeconds * 1000 - 1000),
|
||||||
send: pendingSend,
|
send: pendingSend,
|
||||||
@@ -74,6 +75,16 @@ export function UndoSnackbar() {
|
|||||||
if (current) setShown(current);
|
if (current) setShown(current);
|
||||||
}, [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
|
// 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.
|
// bar's live region. Once per entry, not on every countdown re-render.
|
||||||
const undoLabel = t('common.undo', 'Undo');
|
const undoLabel = t('common.undo', 'Undo');
|
||||||
@@ -148,7 +159,11 @@ export function UndoSnackbar() {
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.bar} accessibilityLiveRegion="polite">
|
<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 && (
|
{shown.send && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onSendNow}
|
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\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
interface SendUndoState {
|
||||||
pending: PendingUndoSend | null;
|
pending: PendingUndoSend | null;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user