feat: add an accessibility page with presets, larger text sizes and a notification duration

This commit is contained in:
Linus Rath
2026-10-31 04:15:55 +01:00
parent 25f9b810f2
commit c9b3bdfea0
18 changed files with 553 additions and 42 deletions
+1
View File
@@ -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',
+5
View File
@@ -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) {
+6 -1
View File
@@ -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<Shown | null>(() => {
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(() => {
+2
View File
@@ -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']);
@@ -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<FontSize, string> = {
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, string | number>) => 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 (
<View style={{ gap: spacing.xxxl }}>
<SettingsSection
title={t('settings.accessibility.presets.title', 'Presets')}
description={t('settings.accessibility.presets.description', 'Each preset changes several settings at once. Afterwards they are normal settings you can change one by one.')}
>
{ACCESSIBILITY_PRESET_IDS.map((id, i) => (
<SettingItem
key={id}
label={presetName(id)}
description={presetCopy(id, t).description}
noBorder={i === ACCESSIBILITY_PRESET_IDS.length - 1}
>
<Button
size="sm"
variant="outline"
onPress={() => applyPreset(id)}
accessibilityLabel={t('settings.accessibility.presets.apply_named', 'Apply {name}', { name: presetName(id) })}
>
{t('settings.accessibility.presets.apply', 'Apply')}
</Button>
</SettingItem>
))}
</SettingsSection>
<SettingsSection title={t('settings.accessibility.sections.text', 'Text and reading')}>
<SettingItem
label={t('settings.accessibility.text_size.label', 'Text size')}
description={t('settings.accessibility.text_size.description_mobile', "Scales the app's text on top of your phone's text size. 100% is your phone's size.")}
noBorder
>
<Select
value={fontSize}
onChange={(v) => update('fontSize', v as FontSize)}
options={FONT_SIZES.map((size) => ({ value: size, label: TEXT_SIZE_LABELS[size] }))}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('settings.accessibility.sections.motion', 'Motion')}>
<SettingItem
label={t('settings.accessibility.reduce_motion.label', 'Reduce motion')}
description={t('settings.accessibility.reduce_motion.description_mobile', "Turns off sliding panels and transitions. The app also does this when reduced motion is on in your phone's settings.")}
noBorder
>
<ToggleSwitch
checked={!animationsEnabled}
onChange={(reduce) => update('animationsEnabled', !reduce)}
accessibilityLabel={t('settings.accessibility.reduce_motion.label', 'Reduce motion')}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('settings.accessibility.sections.timing', 'Timing and mistakes')}>
<SettingItem
label={t('settings.accessibility.toast_duration.label', 'Notification duration')}
description={t('settings.accessibility.toast_duration.description', 'How long messages such as "Moved to Archive" with their Undo button stay on screen.')}
>
<Select
value={String(toastDuration)}
onChange={(v) => update('toastDuration', Number(v))}
options={TOAST_DURATION_CHOICES.map((value) => ({ value: String(value), label: durationLabel(value) }))}
/>
</SettingItem>
<SettingItem
label={t('settings.accessibility.confirm_send.label', 'Confirm before sending')}
description={t('settings.accessibility.confirm_send.description', 'Asks before a message is sent.')}
>
<ToggleSwitch
checked={sendConfirmation}
onChange={(v) => update('sendConfirmation', v)}
accessibilityLabel={t('settings.accessibility.confirm_send.label', 'Confirm before sending')}
/>
</SettingItem>
<SettingItem
label={t('settings.accessibility.confirm_delete.label', 'Confirm before deleting')}
description={t('settings.accessibility.confirm_delete.description', 'Asks before a message is deleted, also for single messages.')}
>
<ToggleSwitch
checked={confirmDelete}
onChange={(v) => update('confirmDelete', v)}
accessibilityLabel={t('settings.accessibility.confirm_delete.label', 'Confirm before deleting')}
/>
</SettingItem>
<SettingItem
label={t('settings.accessibility.confirm_archive.label', 'Confirm before archiving')}
description={t('settings.accessibility.confirm_archive.description', 'Asks before a message is archived.')}
noBorder
>
<ToggleSwitch
checked={confirmArchive}
onChange={(v) => update('confirmArchive', v)}
accessibilityLabel={t('settings.accessibility.confirm_archive.label', 'Confirm before archiving')}
/>
</SettingItem>
</SettingsSection>
</View>
);
}
+9 -20
View File
@@ -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() {
/>
</SettingItem>
<SettingItem label={t('settings.appearance.font_size.label', "Font Size")} description={t('settings.appearance.font_size.description', "Adjust text size for better readability")}>
<RadioGroup
value={fontSize}
onChange={(v) => 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") },
]}
/>
</SettingItem>
{/* Text size and motion moved to Settings > Accessibility. */}
<SettingItem
label={t('settings.appearance.accessibility_link.label', 'Text size and motion')}
description={t('settings.appearance.accessibility_link.description_mobile', 'Text size, reduced motion and the accessibility presets are on the Accessibility page.')}
onPress={() => setPendingSettingsTab('accessibility')}
link="page"
/>
<View>
<SettingItem label={t('settings.appearance.list_density.label', "Density")} description={t('settings.appearance.list_density.description', "Control spacing and padding across the UI")} noBorder />
@@ -151,10 +144,6 @@ export function AppearanceSettings() {
<ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} />
</SettingItem>
<SettingItem label={t('settings.appearance.animations.label', "Enable Animations")} description={t('settings.appearance.animations.description', "Show smooth transitions and effects")}>
<ToggleSwitch checked={animationsEnabled} onChange={setAnimationsEnabled} />
</SettingItem>
<SettingItem
label={t('settings.advanced.sender_favicons.label', 'Sender Favicons')}
description={t('settings.advanced.sender_favicons.description', 'Show website icons as profile pictures for business senders')}
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { mergeWithDefaults, type PersistedSettings } from '../../stores/settings-store';
import { FONT_SCALE } from '../../theme/dynamic';
import { applyPresetChanges, presetChanges, undoPresetChanges } from '../accessibility-presets';
// Settings > 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<PersistedSettings>) {
const state: Partial<PersistedSettings> = { ...initial };
const update = <K extends keyof PersistedSettings>(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<PersistedSettings>);
expect(merged.fontSize).toBe('huge');
expect(merged.toastDuration).toBe(20);
expect(mergeWithDefaults({ toastDuration: 7 } as Partial<PersistedSettings>).toastDuration).toBe(-1);
});
});
+12 -8
View File
@@ -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', () => {
+51
View File
@@ -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]);
});
});
+66
View File
@@ -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<PersistedSettings>;
export const ACCESSIBILITY_PRESETS: Record<AccessibilityPresetId, PresetValues> = {
// 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<PersistedSettings>): 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 = <K extends keyof PersistedSettings>(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<PersistedSettings>, update: Update): void {
for (const change of changes) {
if (state[change.key] === change.to) update(change.key, change.from as never);
}
}
+31
View File
@@ -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;
}
+14 -3
View File
@@ -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<SettingsTabId, string[]> = {
'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<SettingsTabId, string> = {
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',
+1
View File
@@ -33,6 +33,7 @@ const WEEKDAY: Record<0 | 1 | 6, [string, string]> = {
};
const STATIC: Partial<Record<SettingsTabId, [string, string]>> = {
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'],
+55
View File
@@ -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;
}
+4 -1
View File
@@ -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<Record<Tab, React.ComponentType<any>>> = {
account: AccountSettings,
language: LanguageSettings,
notifications: NotificationSettings,
accessibility: AccessibilitySettings,
appearance: AppearanceSettings,
layout: LayoutSettings,
reading: ReadingSettings,
+16 -3
View File
@@ -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<Record<keyof PersistedSettings, (v: unknown) => 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<Record<keyof PersistedSettings, (v: unknown) => 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'
+4 -1
View File
@@ -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<ToastState>((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] }));
+11 -5
View File
@@ -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<FontScale, number> = {
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<FontScale, number> = {
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