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
@@ -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')}