From c9b3bdfea0cc42485b4f47abec138e2fc586cd9f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:15:55 +0100 Subject: [PATCH] feat: add an accessibility page with presets, larger text sizes and a notification duration --- scripts/generate-icons.mjs | 1 + src/components/ToastHost.tsx | 5 + src/components/UndoSnackbar.tsx | 7 +- src/components/icons/index.ts | 2 + .../settings/AccessibilitySettings.tsx | 203 ++++++++++++++++++ .../settings/AppearanceSettings.tsx | 29 +-- .../__tests__/accessibility-presets.test.ts | 62 ++++++ src/lib/__tests__/settings-search.test.ts | 20 +- src/lib/__tests__/toast-duration.test.ts | 51 +++++ src/lib/accessibility-presets.ts | 66 ++++++ src/lib/screen-reader.ts | 31 +++ src/lib/settings-search.ts | 17 +- src/lib/settings-summary.ts | 1 + src/lib/toast-duration.ts | 55 +++++ src/screens/SettingsScreen.tsx | 5 +- src/stores/settings-store.ts | 19 +- src/stores/toast-store.ts | 5 +- src/theme/dynamic.ts | 16 +- 18 files changed, 553 insertions(+), 42 deletions(-) create mode 100644 src/components/settings/AccessibilitySettings.tsx create mode 100644 src/lib/__tests__/accessibility-presets.test.ts create mode 100644 src/lib/__tests__/toast-duration.test.ts create mode 100644 src/lib/accessibility-presets.ts create mode 100644 src/lib/screen-reader.ts create mode 100644 src/lib/toast-duration.ts diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index 62d86ffe..a66acec7 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -21,6 +21,7 @@ const SOURCE = process.argv[2] // App name -> Tabler outline name. Where the webmail (components/icons) maps // the same name, this uses its choice. const MAP = { + Accessibility: 'accessible', AlarmClock: 'alarm', AlertCircle: 'alert-circle', AlertTriangle: 'alert-triangle', diff --git a/src/components/ToastHost.tsx b/src/components/ToastHost.tsx index e2ece7aa..36aec1e2 100644 --- a/src/components/ToastHost.tsx +++ b/src/components/ToastHost.tsx @@ -80,6 +80,11 @@ function ToastCard({ toast }: { toast: Toast }) { // screen change remounts the card). The countdown line runs out with it; // with animations off it stays full instead of moving. React.useEffect(() => { + // "Until I close them": no timer, and the line stays full. + if (toast.duration <= 0) { + remainingShare.setValue(1); + return undefined; + } const remaining = Math.max(0, toast.duration - (Date.now() - toast.createdAt)); const timer = setTimeout(() => removeToast(toast.id), remaining); if (!animate) { diff --git a/src/components/UndoSnackbar.tsx b/src/components/UndoSnackbar.tsx index 86a29b41..bffe0447 100644 --- a/src/components/UndoSnackbar.tsx +++ b/src/components/UndoSnackbar.tsx @@ -12,7 +12,10 @@ import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../st import { useLocaleStore } from '../stores/locale-store'; import type { RootStackParamList } from '../navigation/types'; import { announce } from '../lib/announce'; +import { lastingToastDuration } from '../lib/toast-duration'; +// At least this long; the "Notification duration" setting can make it +// longer, or keep the bar until it is replaced (lib/toast-duration). const VISIBLE_MS = 5000; interface Shown { @@ -49,7 +52,7 @@ export function UndoSnackbar() { // the bar; the newer one wins. const current = React.useMemo(() => { const list: Shown | null = entry - ? { label: entry.label, createdAt: entry.createdAt, visibleMs: VISIBLE_MS } + ? { label: entry.label, createdAt: entry.createdAt, visibleMs: lastingToastDuration(VISIBLE_MS) } : null; const send: Shown | null = pendingSend ? { @@ -97,6 +100,8 @@ export function UndoSnackbar() { Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }), ]).start(); + // Kept until replaced or used ("Until I close them"). + if (current.visibleMs <= 0) return undefined; const elapsed = Date.now() - current.createdAt; const remaining = Math.max(0, current.visibleMs - elapsed); const id = setTimeout(() => { diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index 09fd504a..6d52fb2c 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -6,6 +6,7 @@ import { makeIcon } from './icon'; export type { AppIcon, IconProps } from './icon'; const G = { + 'accessible': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M10 16.5l2 -3l2 3m-2 -3v-2l3 -1m-6 0l3 1","M11.5 7.5a.5 .5 0 1 0 1 0a.5 .5 0 1 0 -1 0"], 'address-book': ["M20 6v12a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2","M10 16h6","M11 11a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M4 8h3","M4 12h3","M4 16h3"], 'alarm': ["M5 13a7 7 0 1 0 14 0a7 7 0 1 0 -14 0","M12 10l0 3l2 0","M7 4l-2.75 2","M17 4l2.75 2"], 'alert-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 8v4","M12 16h.01"], @@ -176,6 +177,7 @@ const G = { 'zoom-scan': ["M8 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0","M16 16l-2.5 -2.5","M3 7v-2a2 2 0 0 1 2 -2h2","M3 17v2a2 2 0 0 0 2 2h2","M17 3h2a2 2 0 0 1 2 2v2","M17 21h2a2 2 0 0 0 2 -2v-2"], } as const; +export const Accessibility = makeIcon('Accessibility', G['accessible']); export const AlarmClock = makeIcon('AlarmClock', G['alarm']); export const AlertCircle = makeIcon('AlertCircle', G['alert-circle']); export const AlertTriangle = makeIcon('AlertTriangle', G['alert-triangle']); diff --git a/src/components/settings/AccessibilitySettings.tsx b/src/components/settings/AccessibilitySettings.tsx new file mode 100644 index 00000000..cce54933 --- /dev/null +++ b/src/components/settings/AccessibilitySettings.tsx @@ -0,0 +1,203 @@ +import React from 'react'; +import { View } from 'react-native'; +import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; +import Button from '../Button'; +import { spacing } from '../../theme/tokens'; +import { useLocaleStore } from '../../stores/locale-store'; +import { FONT_SIZES, useSettingsStore, type FontSize } from '../../stores/settings-store'; +import { toast } from '../../stores/toast-store'; +import { + ACCESSIBILITY_PRESET_IDS, + applyPresetChanges, + presetChanges, + undoPresetChanges, + type AccessibilityPresetId, +} from '../../lib/accessibility-presets'; +import { TOAST_DURATION_CHOICES } from '../../lib/toast-duration'; + +// Percentages read the same in every language the app has. +const TEXT_SIZE_LABELS: Record = { + small: '87.5%', + medium: '100%', + large: '112.5%', + 'x-large': '125%', + 'xx-large': '150%', + 'xxx-large': '175%', + huge: '200%', +}; + +type Translate = (key: string, fallback?: string, params?: Record) => string; + +// Literal keys, so the harvest script and the key test see them. +function presetCopy(id: AccessibilityPresetId, t: Translate): { label: string; description: string } { + switch (id) { + case 'screen-reader': + return { + label: t('settings.accessibility.presets.screen-reader.label', 'Screen reader'), + description: t('settings.accessibility.presets.screen-reader.description_mobile', 'No conversation grouping, so every message is a row of its own.'), + }; + case 'low-vision': + return { + label: t('settings.accessibility.presets.low-vision.label', 'Low vision'), + description: t('settings.accessibility.presets.low-vision.description_mobile', 'Text at 150% and labels under the toolbar icons.'), + }; + case 'motion': + return { + label: t('settings.accessibility.presets.motion.label', 'Motion and seizure safe'), + description: t('settings.accessibility.presets.motion.description_mobile', 'No animations or sliding transitions.'), + }; + } +} + +/** + * Settings > Accessibility (webmail components/settings/accessibility-settings.tsx): + * the presets a phone can apply, text size, motion, and the timing and + * confirmation options. Reduce motion also follows the system setting + * (theme/dynamic useShouldAnimate). + */ +export function AccessibilitySettings() { + const t = useLocaleStore((s) => s.t); + const hydrated = useSettingsStore((s) => s.hydrated); + const hydrate = useSettingsStore((s) => s.hydrate); + const update = useSettingsStore((s) => s.updateSetting); + const fontSize = useSettingsStore((s) => s.fontSize); + const animationsEnabled = useSettingsStore((s) => s.animationsEnabled); + const toastDuration = useSettingsStore((s) => s.toastDuration); + const sendConfirmation = useSettingsStore((s) => s.sendConfirmation); + const confirmDelete = useSettingsStore((s) => s.confirmDelete); + const confirmArchive = useSettingsStore((s) => s.confirmArchive); + + React.useEffect(() => { + if (!hydrated) void hydrate(); + }, [hydrated, hydrate]); + + const presetName = (id: AccessibilityPresetId) => presetCopy(id, t).label; + + const applyPreset = (id: AccessibilityPresetId) => { + const name = presetName(id); + const changes = presetChanges(id, useSettingsStore.getState()); + if (changes.length === 0) { + toast.info(t('settings.accessibility.presets.nothing_changed', '{name}: your settings already match.', { name })); + return; + } + applyPresetChanges(changes, update); + toast.success( + t('settings.accessibility.presets.applied_mobile', '{name} changed {count, plural, one {# setting} other {# settings}}', { + name, + count: changes.length, + }), + { + action: { + label: t('settings.accessibility.presets.undo', 'Undo'), + onPress: () => undoPresetChanges(changes, useSettingsStore.getState(), update), + }, + }, + ); + }; + + const durationLabel = (value: number) => { + if (value < 0) return t('settings.accessibility.toast_duration.automatic', 'Automatic (longer with a screen reader)'); + if (value === 0) return t('settings.accessibility.toast_duration.until_closed', 'Until I close them'); + return t('settings.accessibility.toast_duration.seconds', '{seconds} seconds', { seconds: value }); + }; + + return ( + + + {ACCESSIBILITY_PRESET_IDS.map((id, i) => ( + + + + ))} + + + + + update('toastDuration', Number(v))} + options={TOAST_DURATION_CHOICES.map((value) => ({ value: String(value), label: durationLabel(value) }))} + /> + + + update('sendConfirmation', v)} + accessibilityLabel={t('settings.accessibility.confirm_send.label', 'Confirm before sending')} + /> + + + update('confirmDelete', v)} + accessibilityLabel={t('settings.accessibility.confirm_delete.label', 'Confirm before deleting')} + /> + + + update('confirmArchive', v)} + accessibilityLabel={t('settings.accessibility.confirm_archive.label', 'Confirm before archiving')} + /> + + + + + ); +} diff --git a/src/components/settings/AppearanceSettings.tsx b/src/components/settings/AppearanceSettings.tsx index 64506bd2..afb958c7 100644 --- a/src/components/settings/AppearanceSettings.tsx +++ b/src/components/settings/AppearanceSettings.tsx @@ -3,8 +3,9 @@ import { View, Text, StyleSheet } from 'react-native'; import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useSettingsStore, type ThemeMode, type FontSize, type Density } from '../../stores/settings-store'; +import { useSettingsStore, type ThemeMode, type Density } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; +import { setPendingSettingsTab } from '../../navigation/pending-settings-tab'; type Theme = ThemeMode; @@ -85,14 +86,10 @@ export function AppearanceSettings() { const theme = useSettingsStore((s) => s.theme); const t = useLocaleStore((s) => s.t); const setTheme = useSettingsStore((s) => s.setTheme); - const fontSize = useSettingsStore((s) => s.fontSize); - const setFontSize = useSettingsStore((s) => s.setFontSize); const density = useSettingsStore((s) => s.density); const setDensity = useSettingsStore((s) => s.setDensity); const showToolbarLabels = useSettingsStore((s) => s.showToolbarLabels); const setShowToolbarLabels = useSettingsStore((s) => s.setShowToolbarLabels); - const animationsEnabled = useSettingsStore((s) => s.animationsEnabled); - const setAnimationsEnabled = useSettingsStore((s) => s.setAnimationsEnabled); const senderFavicons = useSettingsStore((s) => s.senderFavicons); const setSenderFavicons = useSettingsStore((s) => s.setSenderFavicons); const hydrated = useSettingsStore((s) => s.hydrated); @@ -119,17 +116,13 @@ export function AppearanceSettings() { /> - - setFontSize(v as FontSize)} - options={[ - { value: 'small', label: t('settings.appearance.font_size.small', "Small") }, - { value: 'medium', label: t('settings.appearance.font_size.medium', "Medium") }, - { value: 'large', label: t('settings.appearance.font_size.large', "Large") }, - ]} - /> - + {/* Text size and motion moved to Settings > Accessibility. */} + setPendingSettingsTab('accessibility')} + link="page" + /> @@ -151,10 +144,6 @@ export function AppearanceSettings() { - - - - Accessibility (webmail 1.19, 8008393e5, 0731d24f6): presets that +// set several options at once and can be undone, and text size from 87.5% +// to 200% (the app had 92/100/112%). + +function store(initial: Partial) { + const state: Partial = { ...initial }; + const update = (key: K, value: PersistedSettings[K]) => { + state[key] = value; + }; + return { state, update }; +} + +describe('accessibility presets', () => { + it('changes only what differs, and Undo puts it back', () => { + const { state, update } = store({ fontSize: 'medium', showToolbarLabels: true, animationsEnabled: true }); + const changes = presetChanges('low-vision', state); + expect(changes).toEqual([{ key: 'fontSize', from: 'medium', to: 'xx-large' }]); + + applyPresetChanges(changes, update); + expect(state.fontSize).toBe('xx-large'); + undoPresetChanges(changes, state, update); + expect(state.fontSize).toBe('medium'); + }); + + it('keeps a value the user changed again after applying', () => { + const { state, update } = store({ animationsEnabled: true }); + const changes = presetChanges('motion', state); + applyPresetChanges(changes, update); + expect(state.animationsEnabled).toBe(false); + state.animationsEnabled = true; + state.fontSize = 'huge'; + undoPresetChanges(changes, state, update); + expect(state.animationsEnabled).toBe(true); + }); + + it('turns off conversation grouping for screen readers', () => { + expect(presetChanges('screen-reader', { disableThreading: false })).toEqual([ + { key: 'disableThreading', from: false, to: true }, + ]); + expect(presetChanges('screen-reader', { disableThreading: true })).toEqual([]); + }); +}); + +describe('text size', () => { + it('runs from 87.5% to 200%', () => { + expect(FONT_SCALE.small).toBe(0.875); + expect(FONT_SCALE.huge).toBe(2); + expect(Object.keys(FONT_SCALE)).toHaveLength(7); + }); + + it('keeps the larger sizes and the notification duration when the settings are read back', () => { + const merged = mergeWithDefaults({ fontSize: 'huge', toastDuration: 20 } as Partial); + expect(merged.fontSize).toBe('huge'); + expect(merged.toastDuration).toBe(20); + expect(mergeWithDefaults({ toastDuration: 7 } as Partial).toastDuration).toBe(-1); + }); +}); diff --git a/src/lib/__tests__/settings-search.test.ts b/src/lib/__tests__/settings-search.test.ts index 1b31344f..5623cac1 100644 --- a/src/lib/__tests__/settings-search.test.ts +++ b/src/lib/__tests__/settings-search.test.ts @@ -80,6 +80,7 @@ describe('settings search index (English catalog)', () => { account: 'AccountSettings.tsx', language: 'LanguageSettings.tsx', notifications: 'NotificationSettings.tsx', + accessibility: 'AccessibilitySettings.tsx', appearance: 'AppearanceSettings.tsx', layout: 'LayoutSettings.tsx', reading: 'ReadingSettings.tsx', @@ -113,7 +114,10 @@ describe('settings search index (English catalog)', () => { } it('lists settings where the native app shows them', () => { - expect(labelsFor('appearance')).toContain('Font Size'); + // Text size moved to Accessibility (webmail 1.19), with the presets. + expect(labelsFor('accessibility')).toContain('Text size'); + expect(labelsFor('accessibility')).toContain('Low vision'); + expect(labelsFor('appearance')).not.toContain('Font Size'); expect(labelsFor('layout')).toContain('Message list order'); expect(labelsFor('notifications')).toContain('Parse email invitations'); expect(labelsFor('calendar')).toContain('Show week numbers'); @@ -142,9 +146,9 @@ describe('settings search index (English catalog)', () => { describe('buildSettingsSearchIndex', () => { it('translates labels through t() for the current locale', () => { const index = buildSettingsSearchIndex(en, (key) => translate('de', key)); - const fontSize = translate('de', 'settings.appearance.font_size.label'); - expect(index.subResults.appearance?.map((r) => r.label)).toContain(fontSize); - expect(index.haystacks.appearance).toContain(fontSize.toLowerCase()); + const textSize = translate('de', 'settings.accessibility.text_size.label'); + expect(index.subResults.accessibility?.map((r) => r.label)).toContain(textSize); + expect(index.haystacks.accessibility).toContain(textSize.toLowerCase()); }); it('adds extra sub-results to the pane and its haystack', () => { @@ -178,11 +182,11 @@ describe('matching', () => { }); it('returns the matching settings of a pane, by label or description', () => { - expect(subResultsForQuery(index, 'appearance', 'font')).toEqual([ - { label: 'Font Size', description: 'Adjust text size for better readability' }, + expect(subResultsForQuery(index, 'accessibility', 'text size')).toEqual([ + { label: 'Text size', description: "Scales the app's text on top of your phone's text size. 100% is your phone's size." }, ]); - expect(subResultsForQuery(index, 'appearance', 'readability').map((r) => r.label)).toEqual(['Font Size']); - expect(subResultsForQuery(index, 'appearance', '')).toEqual([]); + expect(subResultsForQuery(index, 'accessibility', 'archived').map((r) => r.label)).toEqual(['Confirm before archiving']); + expect(subResultsForQuery(index, 'accessibility', '')).toEqual([]); }); it('caps the settings listed under one pane', () => { diff --git a/src/lib/__tests__/toast-duration.test.ts b/src/lib/__tests__/toast-duration.test.ts new file mode 100644 index 00000000..ba551616 --- /dev/null +++ b/src/lib/__tests__/toast-duration.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useSettingsStore } from '../../stores/settings-store'; +import { toast, useToastStore } from '../../stores/toast-store'; +import { setScreenReaderOnForTests } from '../screen-reader'; +import { effectiveToastDuration, lastingToastDuration, userToastSeconds } from '../toast-duration'; + +// Toasts and the Undo bar left after a fixed 5 s, errors after 10 s, which +// is short for someone reaching the Undo button with a screen reader +// (webmail lib/toast-duration.ts, WCAG 2.2.1). + +beforeEach(() => { + setScreenReaderOnForTests(false); + useSettingsStore.setState({ toastDuration: -1 }); + useToastStore.getState().clearToasts(); +}); + +describe('notification duration', () => { + it('is 5 s by default and 20 s while a screen reader is on', () => { + expect(userToastSeconds()).toBe(5); + setScreenReaderOnForTests(true); + expect(userToastSeconds()).toBe(20); + expect(lastingToastDuration(5000)).toBe(20_000); + }); + + it('follows the setting, never going below the time the caller picked', () => { + useSettingsStore.setState({ toastDuration: 60 }); + expect(lastingToastDuration(5000)).toBe(60_000); + useSettingsStore.setState({ toastDuration: 5 }); + expect(lastingToastDuration(10_000)).toBe(10_000); + }); + + it('keeps toasts until they are closed when set to 0', () => { + useSettingsStore.setState({ toastDuration: 0 }); + expect(lastingToastDuration(5000)).toBe(0); + }); + + it('lengthens toasts with an action and errors, not plain notices', () => { + useSettingsStore.setState({ toastDuration: 20 }); + expect(effectiveToastDuration({ type: 'success', action: { label: 'Undo' } })).toBe(20_000); + expect(effectiveToastDuration({ type: 'error' })).toBe(20_000); + expect(effectiveToastDuration({ type: 'success' })).toBe(5000); + }); + + it('is what the toast store gives a new toast', () => { + setScreenReaderOnForTests(true); + toast.success('Archived', { action: { label: 'Undo', onPress: () => undefined } }); + toast.error('Could not move'); + toast.info('Copied'); + expect(useToastStore.getState().toasts.map((t) => t.duration)).toEqual([20_000, 20_000, 5000]); + }); +}); diff --git a/src/lib/accessibility-presets.ts b/src/lib/accessibility-presets.ts new file mode 100644 index 00000000..ca63da8a --- /dev/null +++ b/src/lib/accessibility-presets.ts @@ -0,0 +1,66 @@ +// Presets on Settings > Accessibility (webmail lib/accessibility-presets.ts, +// 8008393e5, 0731d24f6). A preset is a list of values applied once: +// afterwards they are ordinary settings the user can change, and presets can +// be combined. Nothing remembers which preset was used. Applying one can be +// undone, which puts back exactly the values it changed. +// +// The phone takes the presets whose settings it has: screen reader (no +// conversation grouping), low vision (150% text, labels under the toolbar +// icons) and motion (no animations). The webmail's others need settings the +// app doesn't have (reading fonts, spacing, focus outlines, hover, sounds). + +import type { PersistedSettings } from '../stores/settings-store'; + +export type AccessibilityPresetId = 'screen-reader' | 'low-vision' | 'motion'; + +export const ACCESSIBILITY_PRESET_IDS: readonly AccessibilityPresetId[] = ['screen-reader', 'low-vision', 'motion']; + +type PresetValues = Partial; + +export const ACCESSIBILITY_PRESETS: Record = { + // The webmail's: no conversation grouping (its reading-pane and announce + // options have no phone counterpart). + 'screen-reader': { + disableThreading: true, + }, + 'low-vision': { + fontSize: 'xx-large', + showToolbarLabels: true, + }, + motion: { + animationsEnabled: false, + }, +}; + +export interface PresetChange { + key: keyof PersistedSettings; + from: unknown; + to: unknown; +} + +/** The changes a preset would make to `state`: only values that differ. */ +export function presetChanges(id: AccessibilityPresetId, state: Partial): PresetChange[] { + const changes: PresetChange[] = []; + for (const [key, to] of Object.entries(ACCESSIBILITY_PRESETS[id]) as Array<[keyof PersistedSettings, unknown]>) { + if (!(key in state) || state[key] === to) continue; + changes.push({ key, from: state[key], to }); + } + return changes; +} + +type Update = (key: K, value: PersistedSettings[K]) => void; + +/** Applies a preset's changes; returns them so they can be undone. */ +export function applyPresetChanges(changes: PresetChange[], update: Update): void { + for (const change of changes) update(change.key, change.to as never); +} + +/** + * Puts back what a preset changed. A setting the user changed again since + * keeps the newer value. + */ +export function undoPresetChanges(changes: PresetChange[], state: Partial, update: Update): void { + for (const change of changes) { + if (state[change.key] === change.to) update(change.key, change.from as never); + } +} diff --git a/src/lib/screen-reader.ts b/src/lib/screen-reader.ts new file mode 100644 index 00000000..f95a59b3 --- /dev/null +++ b/src/lib/screen-reader.ts @@ -0,0 +1,31 @@ +// Whether TalkBack or VoiceOver is on, readable without a hook (the toast +// store decides a toast's duration when it is queued). Starts watching on +// first use; until the first answer arrives it reads as off. + +import { AccessibilityInfo } from 'react-native'; + +let screenReaderOn = false; +let watching = false; + +function watch(): void { + if (watching) return; + watching = true; + const info = AccessibilityInfo as typeof AccessibilityInfo | undefined; + try { + void info?.isScreenReaderEnabled?.().then((on) => { screenReaderOn = on; }).catch(() => undefined); + info?.addEventListener?.('screenReaderChanged', (on: boolean) => { screenReaderOn = on; }); + } catch { + // No accessibility bridge (tests, a headless task): stays off. + } +} + +export function isScreenReaderOn(): boolean { + watch(); + return screenReaderOn; +} + +/** Test hook. */ +export function setScreenReaderOnForTests(on: boolean): void { + watching = true; + screenReaderOn = on; +} diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts index 47901d50..a8927529 100644 --- a/src/lib/settings-search.ts +++ b/src/lib/settings-search.ts @@ -14,7 +14,7 @@ import { supportsDeviceSync } from './platform-capabilities'; export type SettingsTabId = - | 'account' | 'language' | 'notifications' + | 'account' | 'language' | 'notifications' | 'accessibility' | 'appearance' | 'layout' | 'reading' | 'composing' | 'identities' | 'vacation' | 'filters' | 'templates' | 'folders' | 'keywords' | 'downloads' @@ -57,12 +57,22 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.notifications.calendar.enabled', 'settings.notifications.calendar.invitation_parsing', ], + accessibility: [ + 'settings.accessibility.presets.screen-reader', + 'settings.accessibility.presets.low-vision', + 'settings.accessibility.presets.motion', + 'settings.accessibility.text_size', + 'settings.accessibility.reduce_motion', + 'settings.accessibility.toast_duration', + 'settings.accessibility.confirm_send', + 'settings.accessibility.confirm_delete', + 'settings.accessibility.confirm_archive', + ], appearance: [ 'settings.appearance.theme', - 'settings.appearance.font_size', + 'settings.appearance.accessibility_link', 'settings.appearance.list_density', 'settings.appearance.toolbar_labels', - 'settings.appearance.animations', // The leaf: the catalog's subtree also has the webmail's refresh button. 'settings.advanced.sender_favicons.label', ], @@ -248,6 +258,7 @@ export const SETTINGS_SEARCH_KEYWORDS: Record = { account: 'profile email password user signin signout reorder switch default multi-account shared storage quota', language: 'locale region date time format translation', notifications: 'alert push badge unifiedpush relay device reminder', + accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm', appearance: 'theme dark light font size text color animation density', layout: 'swipe gesture quick actions toolbar order sort unified folder count avatar', reading: 'mark read preview thread conversation archive delete attachment open monospace mono font plain text receipt', diff --git a/src/lib/settings-summary.ts b/src/lib/settings-summary.ts index 49d88687..cd7fe42c 100644 --- a/src/lib/settings-summary.ts +++ b/src/lib/settings-summary.ts @@ -33,6 +33,7 @@ const WEEKDAY: Record<0 | 1 | 6, [string, string]> = { }; const STATIC: Partial> = { + accessibility: ['settings.summary.accessibility', 'Text size, motion, confirmations'], layout: ['settings.summary.layout', 'Quick actions, swipes, list order'], reading: ['settings.summary.reading', 'Read status, delete, attachments'], composing: ['settings.summary.composing', 'Signature, send delay, drafts'], diff --git a/src/lib/toast-duration.ts b/src/lib/toast-duration.ts new file mode 100644 index 00000000..ab5ec647 --- /dev/null +++ b/src/lib/toast-duration.ts @@ -0,0 +1,55 @@ +// How long toasts and the Undo bar stay (webmail lib/toast-duration.ts, +// 8d2ebccf0, WCAG 2.2.1). One setting, Settings > Accessibility > +// Notification duration, covers toasts with an action, errors and the Undo +// bar: 5 to 60 seconds, or until closed. Plain notices keep their own short +// time. The app's default is automatic: 5 s, or 20 s while TalkBack or +// VoiceOver is on, since reaching an Undo button by swiping takes longer +// than tapping it. + +import { useSettingsStore } from '../stores/settings-store'; +import { isScreenReaderOn } from './screen-reader'; + +/** How long a toast stays when nothing else says so. */ +export const DEFAULT_TOAST_MS = 5000; +/** Errors stay longer than other toasts. */ +export const DEFAULT_ERROR_TOAST_MS = 10000; +/** The automatic duration while a screen reader is on, in seconds. */ +export const SCREEN_READER_TOAST_SECONDS = 20; +/** The setting's values: -1 automatic, 0 until closed, else seconds. */ +export const TOAST_DURATION_CHOICES = [-1, 5, 10, 20, 60, 0] as const; + +/** The setting in seconds; 0 keeps toasts until they are closed. */ +export function userToastSeconds(): number { + let stored = -1; + try { + const value = useSettingsStore.getState().toastDuration; + if (typeof value === 'number') stored = value; + } catch { + // A store that isn't there yet: automatic. + } + if (stored >= 0) return stored; + return isScreenReaderOn() ? SCREEN_READER_TOAST_SECONDS : DEFAULT_TOAST_MS / 1000; +} + +/** + * How long something that must not be missed stays: a toast with an action, + * an error, the Undo bar. Never shorter than `baseMs`, the time the caller + * picked; 0 when it stays until it is closed. + */ +export function lastingToastDuration(baseMs: number): number { + const seconds = userToastSeconds(); + if (seconds === 0 || baseMs <= 0) return 0; + return Math.max(baseMs, seconds * 1000); +} + +/** The duration a new toast gets. A plain notice keeps its own time. */ +export function effectiveToastDuration(toast: { + type: string; + duration?: number; + action?: unknown; +}): number { + const base = toast.duration ?? (toast.type === 'error' ? DEFAULT_ERROR_TOAST_MS : DEFAULT_TOAST_MS); + if (base <= 0) return base; + const lasting = toast.type === 'error' || !!toast.action; + return lasting ? lastingToastDuration(base) : base; +} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 488fe5d3..3232fae5 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -4,7 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useLayout } from '../lib/use-layout'; import { confirmSignOut } from '../lib/sign-out-confirm'; import { - ArrowLeft, LogOut, Search, X, ChevronRight, + Accessibility, ArrowLeft, LogOut, Search, X, ChevronRight, Palette, User, Shield, UserPen, Palmtree, Calendar, Filter, FileText, FolderOpen, Tags, HardDrive, BookUser, PanelLeftClose, Bell, RefreshCw, @@ -30,6 +30,7 @@ import Badge from '../components/Badge'; import SenderAvatar from '../components/SenderAvatar'; import { UpdatesSettings } from '../components/settings/UpdatesSettings'; import { AppearanceSettings } from '../components/settings/AppearanceSettings'; +import { AccessibilitySettings } from '../components/settings/AccessibilitySettings'; import { CalendarSettings } from '../components/settings/CalendarSettings'; import { ContactsSettings } from '../components/settings/ContactsSettings'; import { FilesSettings } from '../components/settings/FilesSettings'; @@ -99,6 +100,7 @@ const TABS: TabDef[] = [ { id: 'account', label: 'Account', icon: User, group: 'general', implemented: true }, { id: 'language', label: 'Language', icon: Languages, group: 'general', implemented: true }, { id: 'notifications', label: 'Notifications', icon: Bell, group: 'general', implemented: true }, + { id: 'accessibility', label: 'Accessibility', icon: Accessibility, group: 'general', implemented: true }, // Appearance { id: 'appearance', label: 'Appearance', icon: Palette, group: 'appearance', implemented: true }, @@ -150,6 +152,7 @@ const TAB_COMPONENTS: Partial>> = { account: AccountSettings, language: LanguageSettings, notifications: NotificationSettings, + accessibility: AccessibilitySettings, appearance: AppearanceSettings, layout: LayoutSettings, reading: ReadingSettings, diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index bb0ffb33..29ab8a10 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -8,7 +8,9 @@ import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type Notification export type ExternalContentPolicy = 'allow' | 'block' | 'ask'; export type ThemeMode = 'light' | 'dark' | 'system'; -export type FontSize = 'small' | 'medium' | 'large'; +// Text size, 87.5% to 200% (webmail lib/accessibility-settings.ts FONT_SIZES). +export type FontSize = 'small' | 'medium' | 'large' | 'x-large' | 'xx-large' | 'xxx-large' | 'huge'; +export const FONT_SIZES: readonly FontSize[] = ['small', 'medium', 'large', 'x-large', 'xx-large', 'xxx-large', 'huge']; export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent'; export type MailAttachmentAction = 'preview' | 'download'; @@ -124,7 +126,7 @@ export interface SidebarApp { showOnMobile: boolean; } -interface PersistedSettings { +export interface PersistedSettings { // Privacy & content externalContentPolicy: ExternalContentPolicy; trustedSenders: string[]; @@ -309,6 +311,12 @@ interface PersistedSettings { // Hard cap on the on-disk body cache, in megabytes. When a sync pushes the // cache past this, the oldest messages are evicted to fit. offlineCacheMaxMB: number; + + // platform: Settings > Accessibility. How long toasts with an action + // (Undo) and errors stay, in seconds (webmail toastDuration, 5 to 60, 0 = + // until closed); -1 = automatic: 5 s, or 20 s while a screen reader is + // on (lib/toast-duration). + toastDuration: number; } const DEFAULT_PERSISTED: PersistedSettings = { @@ -463,6 +471,9 @@ const DEFAULT_PERSISTED: PersistedSettings = { offlineCacheEnabled: false, offlineCacheDays: 7, offlineCacheMaxMB: 50, + + // platform: + toastDuration: -1, }; export interface SettingsState extends PersistedSettings { @@ -576,7 +587,7 @@ const VALIDATORS: Partial boolea messageListOrderScope: oneOf(['inbox', 'all']), // Levels are sanitized by the consumer (lib/message-list-order sanitizeSortLevels). messageListOrder: (v) => Array.isArray(v), - fontSize: oneOf(['small', 'medium', 'large']), + fontSize: oneOf(FONT_SIZES), density: oneOf(['extra-compact', 'compact', 'regular', 'comfortable']), attachmentReminderKeywords: stringArray, // Same set the webmail accepts (stores/settings-store.ts importSettings). @@ -612,6 +623,8 @@ const VALIDATORS: Partial boolea exportSpaceReplacement: oneOf(['keep', 'underscore', 'dash']), offlineCacheDays: intBetween(1, 3650), offlineCacheMaxMB: intBetween(1, 100000), + // platform: + toastDuration: oneOf([-1, 0, 5, 10, 20, 60]), sidebarApps: (v) => Array.isArray(v) && v.every((a) => a && typeof a === 'object' && typeof (a as SidebarApp).id === 'string' diff --git a/src/stores/toast-store.ts b/src/stores/toast-store.ts index 53384a37..f2d2a47b 100644 --- a/src/stores/toast-store.ts +++ b/src/stores/toast-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { announce, toastAnnouncement } from '../lib/announce'; +import { effectiveToastDuration } from '../lib/toast-duration'; // 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 @@ -20,6 +21,8 @@ export interface Toast { message?: string; action?: ToastAction; // ms; errors default to 10 s like the webmail, everything else to 5 s. + // Errors and toasts with an action follow the "Notification duration" + // setting (lib/toast-duration). 0 stays until it is swiped away. duration: number; createdAt: number; } @@ -42,7 +45,7 @@ export const useToastStore = create((set) => ({ ...toast, id, createdAt: Date.now(), - duration: toast.duration ?? (toast.type === 'error' ? 10_000 : 5_000), + duration: effectiveToastDuration(toast), }; // Keep the queue short: a burst of errors should not stack up forever. set((state) => ({ toasts: [...state.toasts.slice(-2), entry] })); diff --git a/src/theme/dynamic.ts b/src/theme/dynamic.ts index 705da87e..8dd1933d 100644 --- a/src/theme/dynamic.ts +++ b/src/theme/dynamic.ts @@ -5,16 +5,22 @@ import { useEffect, useMemo, useState } from 'react'; import { AccessibilityInfo } from 'react-native'; -import { useSettingsStore } from '../stores/settings-store'; +import { useSettingsStore, type FontSize } from '../stores/settings-store'; import { typography as baseTypography, spacing as baseSpacing } from './tokens'; -export type FontScale = 'small' | 'medium' | 'large'; +export type FontScale = FontSize; export type DensityKind = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; -const FONT_SCALE: Record = { - small: 0.92, +// The webmail's text sizes (lib/accessibility-settings.ts FONT_SIZE_SCALE), +// 87.5% to 200%, on top of the system font scale RN applies to Text anyway. +export const FONT_SCALE: Record = { + small: 0.875, medium: 1, - large: 1.12, + large: 1.125, + 'x-large': 1.25, + 'xx-large': 1.5, + 'xxx-large': 1.75, + huge: 2, }; // How tall an email-list-style row should be. Mirrors the webmail