mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: add an accessibility page with presets, larger text sizes and a notification duration
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user