diff --git a/src/components/OfflineBanner.tsx b/src/components/OfflineBanner.tsx index 46fe7e32..92352ee7 100644 --- a/src/components/OfflineBanner.tsx +++ b/src/components/OfflineBanner.tsx @@ -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 ( diff --git a/src/components/UndoSnackbar.tsx b/src/components/UndoSnackbar.tsx index 2b11fb81..86a29b41 100644 --- a/src/components/UndoSnackbar.tsx +++ b/src/components/UndoSnackbar.tsx @@ -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 }] }, ]} > - + {shown.label} {shown.send && ( [styles.undoBtn, pressed && styles.undoBtnPressed]} > {t('email_composer.send_now', 'Send now').toUpperCase()} @@ -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]} > {t('common.undo', 'Undo').toUpperCase()} diff --git a/src/lib/__tests__/announce.test.ts b/src/lib/__tests__/announce.test.ts new file mode 100644 index 00000000..35e1a0b5 --- /dev/null +++ b/src/lib/__tests__/announce.test.ts @@ -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(); + }); +}); diff --git a/src/lib/announce.ts b/src/lib/announce.ts new file mode 100644 index 00000000..153b82df --- /dev/null +++ b/src/lib/announce.ts @@ -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(); + +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; +} diff --git a/src/stores/toast-store.ts b/src/stores/toast-store.ts index 82abf79e..53384a37 100644 --- a/src/stores/toast-store.ts +++ b/src/stores/toast-store.ts @@ -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((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; },