fix: read toasts, the undo bar and the offline banner out on ios

This commit is contained in:
Linus Rath
2026-10-31 03:49:09 +01:00
parent ff5a8c91d6
commit d812caf025
5 changed files with 172 additions and 2 deletions
+7 -1
View File
@@ -6,6 +6,7 @@ import { useOutboxStore } from '../stores/outbox-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { announceConnectivity } from '../lib/announce';
interface OfflineBannerProps {
/** Optional extra notice (e.g. "Showing cached mail"). */
@@ -19,6 +20,11 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
const t = useLocaleStore((s) => s.t);
// Pending offline mutations that will replay once we're back online.
const queued = useOutboxStore((s) => s.entries.length);
const offlineLabel = t('offline.banner', 'You are offline');
// VoiceOver hears the drop too (Android reads the live region below).
React.useEffect(() => {
announceConnectivity(online, offlineLabel);
}, [online, offlineLabel]);
if (online) return null;
const queuedHint = queued > 0
? t(
@@ -28,7 +34,7 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
)
: null;
const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
const label = t('offline.banner', 'You are offline');
const label = offlineLabel;
return (
<View style={styles.bar} accessibilityRole="alert" accessibilityLiveRegion="polite">
<CloudOff size={14} color={c.warningForeground} />
+14 -1
View File
@@ -11,6 +11,7 @@ import { useEmailStore } from '../stores/email-store';
import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../stores/send-undo-store';
import { useLocaleStore } from '../stores/locale-store';
import type { RootStackParamList } from '../navigation/types';
import { announce } from '../lib/announce';
const VISIBLE_MS = 5000;
@@ -70,6 +71,14 @@ export function UndoSnackbar() {
if (current) setShown(current);
}, [current]);
// 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');
React.useEffect(() => {
if (!current) return;
announce(current.label);
}, [current?.createdAt, current?.label]);
// Drive the auto-dismiss off the entry's createdAt so re-renders during
// animation don't reset the timer, and so consecutive actions reset it.
React.useEffect(() => {
@@ -133,13 +142,15 @@ export function UndoSnackbar() {
{ left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom, opacity, transform: [{ translateY: slideY }] },
]}
>
<View style={styles.bar}>
<View style={styles.bar} accessibilityLiveRegion="polite">
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
{shown.send && (
<Pressable
onPress={onSendNow}
hitSlop={8}
disabled={sendBusy}
accessibilityRole="button"
accessibilityLabel={t('email_composer.send_now', 'Send now')}
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
>
<Text style={styles.sendNowText}>{t('email_composer.send_now', 'Send now').toUpperCase()}</Text>
@@ -149,6 +160,8 @@ export function UndoSnackbar() {
onPress={() => { void onUndo(); }}
hitSlop={8}
disabled={sendBusy}
accessibilityRole="button"
accessibilityLabel={undoLabel}
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
>
<Text style={styles.undoText}>{t('common.undo', 'Undo').toUpperCase()}</Text>
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Toasts, the Undo bar and the offline banner were never read out on iOS:
// they relied on accessibilityLiveRegion, which only Android honours.
const { platform, announceWithOptions, announcePlain } = vi.hoisted(() => ({
platform: { OS: 'ios' as 'ios' | 'android' },
announceWithOptions: vi.fn(),
announcePlain: vi.fn(),
}));
vi.mock('react-native', () => ({
Platform: platform,
AccessibilityInfo: {
announceForAccessibility: announcePlain,
announceForAccessibilityWithOptions: announceWithOptions,
},
}));
import { announceConnectivity, resetAnnouncements, toastAnnouncement } from '../announce';
import { toast, useToastStore } from '../../stores/toast-store';
beforeEach(() => {
vi.clearAllMocks();
platform.OS = 'ios';
resetAnnouncements();
useToastStore.getState().clearToasts();
});
describe('toast announcements', () => {
it('reads a toast out on iOS, title and message as one sentence', () => {
toast.success('Draft saved', 'In Drafts');
expect(announceWithOptions).toHaveBeenCalledWith('Draft saved. In Drafts', { queue: true });
});
it('lets an error interrupt', () => {
toast.error('Could not send');
expect(announceWithOptions).toHaveBeenCalledWith('Could not send', { queue: false });
});
it('says the same toast once within a couple of seconds', () => {
toast.info('Copied');
toast.info('Copied');
expect(announceWithOptions).toHaveBeenCalledTimes(1);
});
it('leaves Android to the live region', () => {
platform.OS = 'android';
toast.success('Draft saved');
expect(announceWithOptions).not.toHaveBeenCalled();
expect(announcePlain).not.toHaveBeenCalled();
});
it('names the action', () => {
expect(toastAnnouncement({ title: 'Archived', actionLabel: 'Undo' })).toBe('Archived. Undo');
expect(toastAnnouncement({ title: 'Done!', message: 'All read' })).toBe('Done! All read');
});
});
describe('offline announcement', () => {
it('says the drop once, however many banners see it', () => {
announceConnectivity(true, 'You are offline');
announceConnectivity(false, 'You are offline');
announceConnectivity(false, 'You are offline');
expect(announceWithOptions).toHaveBeenCalledTimes(1);
expect(announceWithOptions).toHaveBeenCalledWith('You are offline', { queue: false });
});
it('says it again after the connection came back and went again', () => {
announceConnectivity(false, 'You are offline');
announceConnectivity(true, 'You are offline');
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 10_000);
announceConnectivity(false, 'You are offline');
expect(announceWithOptions).toHaveBeenCalledTimes(2);
vi.restoreAllMocks();
});
});
+68
View File
@@ -0,0 +1,68 @@
// Screen reader announcements (webmail lib/announce.ts, 36d13b76b,
// e2fc2c750): short status text read out without moving the focus.
//
// Android reads the live regions the toast card, the Undo bar and the
// offline banner carry (accessibilityLiveRegion). iOS has no live regions,
// so nothing of them was ever read out there; VoiceOver is told through
// AccessibilityInfo instead. Errors interrupt what VoiceOver is saying,
// everything else waits its turn. The same text again within a couple of
// seconds is not repeated (two screens' banners, a burst of the same toast).
import { AccessibilityInfo, Platform } from 'react-native';
const REPEAT_WINDOW_MS = 2000;
const lastSaid = new Map<string, number>();
export interface AnnounceOptions {
/** Interrupts VoiceOver. For errors only. */
assertive?: boolean;
}
export function announce(message: string, options: AnnounceOptions = {}): void {
if (Platform.OS !== 'ios') return;
const text = message.replace(/\s+/g, ' ').trim();
if (!text) return;
const now = Date.now();
const last = lastSaid.get(text);
if (last !== undefined && now - last < REPEAT_WINDOW_MS) return;
lastSaid.set(text, now);
if (lastSaid.size > 50) {
for (const [key, at] of lastSaid) if (now - at >= REPEAT_WINDOW_MS) lastSaid.delete(key);
}
try {
if (AccessibilityInfo.announceForAccessibilityWithOptions) {
AccessibilityInfo.announceForAccessibilityWithOptions(text, { queue: !options.assertive });
} else {
AccessibilityInfo.announceForAccessibility(text);
}
} catch {
// Never let an announcement break the action that caused it.
}
}
/** Title and message of a toast as one sentence (webmail toastAnnouncement). */
export function toastAnnouncement(toast: { title: string; message?: string; actionLabel?: string }): string {
const sentence = (a: string, b: string | undefined) => {
const next = b?.trim();
if (!next) return a;
if (!a) return next;
return /[.!?…:]$/.test(a) ? `${a} ${next}` : `${a}. ${next}`;
};
return sentence(sentence(toast.title.trim(), toast.message), toast.actionLabel);
}
// The offline banner sits on several screens at once; the drop is said once,
// when the connection goes, not by every banner that mounts while offline.
let lastOnline: boolean | null = null;
export function announceConnectivity(online: boolean, offlineMessage: string): void {
const wasOnline = lastOnline;
lastOnline = online;
if (!online && wasOnline !== false) announce(offlineMessage, { assertive: true });
}
/** Test hook. */
export function resetAnnouncements(): void {
lastSaid.clear();
lastOnline = null;
}
+6
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { announce, toastAnnouncement } from '../lib/announce';
// Lightweight port of the webmail's stores/toast-store.ts: a queue of typed
// toasts with an optional action, rendered by components/ToastHost. Use it
@@ -45,6 +46,11 @@ export const useToastStore = create<ToastState>((set) => ({
};
// Keep the queue short: a burst of errors should not stack up forever.
set((state) => ({ toasts: [...state.toasts.slice(-2), entry] }));
// iOS reads no live regions; the card's own one covers Android.
announce(
toastAnnouncement({ title: entry.title, message: entry.message, actionLabel: entry.action?.label }),
{ assertive: entry.type === 'error' },
);
return id;
},