mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: read toasts, the undo bar and the offline banner out on ios
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user