mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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 { useLocaleStore } from '../stores/locale-store';
|
||||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
|
import { announceConnectivity } from '../lib/announce';
|
||||||
|
|
||||||
interface OfflineBannerProps {
|
interface OfflineBannerProps {
|
||||||
/** Optional extra notice (e.g. "Showing cached mail"). */
|
/** Optional extra notice (e.g. "Showing cached mail"). */
|
||||||
@@ -19,6 +20,11 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
|
|||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
// Pending offline mutations that will replay once we're back online.
|
// Pending offline mutations that will replay once we're back online.
|
||||||
const queued = useOutboxStore((s) => s.entries.length);
|
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;
|
if (online) return null;
|
||||||
const queuedHint = queued > 0
|
const queuedHint = queued > 0
|
||||||
? t(
|
? t(
|
||||||
@@ -28,7 +34,7 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
|
const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
|
||||||
const label = t('offline.banner', 'You are offline');
|
const label = offlineLabel;
|
||||||
return (
|
return (
|
||||||
<View style={styles.bar} accessibilityRole="alert" accessibilityLiveRegion="polite">
|
<View style={styles.bar} accessibilityRole="alert" accessibilityLiveRegion="polite">
|
||||||
<CloudOff size={14} color={c.warningForeground} />
|
<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 { useSendUndoStore, restoreUndoneSend, 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';
|
||||||
|
|
||||||
const VISIBLE_MS = 5000;
|
const VISIBLE_MS = 5000;
|
||||||
|
|
||||||
@@ -70,6 +71,14 @@ export function UndoSnackbar() {
|
|||||||
if (current) setShown(current);
|
if (current) setShown(current);
|
||||||
}, [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
|
// 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.
|
// animation don't reset the timer, and so consecutive actions reset it.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -133,13 +142,15 @@ export function UndoSnackbar() {
|
|||||||
{ left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom, opacity, transform: [{ translateY: slideY }] },
|
{ 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>
|
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
|
||||||
{shown.send && (
|
{shown.send && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onSendNow}
|
onPress={onSendNow}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
disabled={sendBusy}
|
disabled={sendBusy}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('email_composer.send_now', 'Send now')}
|
||||||
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
|
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
|
||||||
>
|
>
|
||||||
<Text style={styles.sendNowText}>{t('email_composer.send_now', 'Send now').toUpperCase()}</Text>
|
<Text style={styles.sendNowText}>{t('email_composer.send_now', 'Send now').toUpperCase()}</Text>
|
||||||
@@ -149,6 +160,8 @@ export function UndoSnackbar() {
|
|||||||
onPress={() => { void onUndo(); }}
|
onPress={() => { void onUndo(); }}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
disabled={sendBusy}
|
disabled={sendBusy}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={undoLabel}
|
||||||
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
|
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
|
||||||
>
|
>
|
||||||
<Text style={styles.undoText}>{t('common.undo', 'Undo').toUpperCase()}</Text>
|
<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 { create } from 'zustand';
|
||||||
|
import { announce, toastAnnouncement } from '../lib/announce';
|
||||||
|
|
||||||
// Lightweight port of the webmail's stores/toast-store.ts: a queue of typed
|
// 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
|
// 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.
|
// Keep the queue short: a burst of errors should not stack up forever.
|
||||||
set((state) => ({ toasts: [...state.toasts.slice(-2), entry] }));
|
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;
|
return id;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user