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:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user