"use client"; import { useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import { usePolicyStore } from '@/stores/policy-store'; import { SettingsSection, SettingItem, RadioGroup, Select, ToggleSwitch } from './settings-section'; import { Button } from '@/components/ui/button'; import { ChevronRight, Keyboard, Volume2 } from '@/components/icons'; import { IS_LITE } from '@/lib/lite'; import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode'; import { openKeyboardShortcuts } from '@/lib/app-commands'; import { voicesForLocale } from '@/lib/read-aloud'; import { useSpeechVoices } from '@/hooks/use-read-aloud'; import { NOTIFICATION_SOUNDS, playNotificationSound, type NotificationSoundChoice } from '@/lib/notification-sound'; import { ACCESSIBILITY_DEFAULTS, ACCESSIBILITY_KEYS, FONT_SIZES, MAIL_MIN_FONT_SIZES, MAIL_TEXT_SCALES, READ_ALOUD_RATES, TOAST_DURATIONS, toastDurationAtLeast, type FontSize, } from '@/lib/accessibility-settings'; import { ACCESSIBILITY_PRESET_IDS, PRESET_SETTING_LABELS, presetChanges, type AccessibilityPresetId, type PresetChange, } from '@/lib/accessibility-presets'; import { AccessibilityPresetPreview } from './accessibility-preset-preview'; import { FONT_SIZES as COMPOSE_FONT_SIZES } from '@/components/email/font-size'; import type { Density, SendDelaySeconds } from '@/stores/settings-store'; type SettingKey = Parameters['updateSetting']>[0]; /** Opens another settings page (and section) the way deep links do. */ function goToSettings(tab: string, section?: string) { window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: section ? { tab, section } : tab })); } function LinkButton({ label, onClick }: { label: string; onClick: () => void }) { return ( ); } /** * Settings > Accessibility: presets on top, then the options by topic. Rows * that are also everyday preferences (conversation view, undo send, sounds…) * are the same setting shown on their own page too, not a copy. */ export function AccessibilitySettings() { const t = useTranslations('settings.accessibility'); const tRoot = useTranslations(); const tEmail = useTranslations('settings.email_behavior'); const tAppearance = useTranslations('settings.appearance'); const tNotifications = useTranslations('settings.notifications'); const tCalendar = useTranslations('calendar.settings'); const settings = useSettingsStore(); const { updateSetting } = settings; const { isSettingLocked, isSettingHidden, getRestriction } = usePolicyStore(); const faviconsAllowed = !useNoThirdPartyTraffic(); const [applied, setApplied] = useState<{ id: AccessibilityPresetId; changes: PresetChange[] } | null>(null); const [resetDone, setResetDone] = useState(false); const locale = useLocale(); const speechVoices = useSpeechVoices(); const toastMin = getRestriction('toastDuration')?.min; const allowsValue = (key: string, value: unknown) => key !== 'toastDuration' || typeof toastMin !== 'number' || toastDurationAtLeast(value as number, toastMin); const set = (key: string, value: unknown) => updateSetting(key as SettingKey, value as never); const applyPreset = (id: AccessibilityPresetId) => { const changes = presetChanges(id, useSettingsStore.getState() as unknown as Record, isSettingLocked, allowsValue); for (const change of changes) set(change.key, change.to); setApplied({ id, changes }); setResetDone(false); }; const undoPreset = () => { if (!applied) return; for (const change of applied.changes) set(change.key, change.from); setApplied(null); }; const resetAll = () => { for (const key of ACCESSIBILITY_KEYS) { if (isSettingLocked(key)) continue; set(key, ACCESSIBILITY_DEFAULTS[key]); } set('fontSize', 'medium'); setApplied(null); setResetDone(true); }; const systemToggle = (key: 'reduceMotion' | 'reduceTransparency' | 'pauseAnimatedImages' | 'largeTargets') => ( set(key, value)} options={[ { value: 'system', label: t('system') }, { value: 'on', label: t('on') }, { value: 'off', label: t('off') }, ]} /> ); // Voices for the interface language, plus the one already chosen even if // this browser doesn't have it (another device may). const localeVoices = voicesForLocale(speechVoices, locale); const readAloudVoiceOptions = [ { value: '', label: t('read_aloud_voice.default') }, ...localeVoices.map((voice) => ({ value: voice.voiceURI, label: voice.name })), ...(settings.readAloudVoice && !localeVoices.some((voice) => voice.voiceURI === settings.readAloudVoice) ? [{ value: settings.readAloudVoice, label: speechVoices.find((voice) => voice.voiceURI === settings.readAloudVoice)?.name ?? settings.readAloudVoice }] : []), ]; const toastOptions = TOAST_DURATIONS .filter((value) => allowsValue('toastDuration', value)) .map((value) => ({ value: String(value), label: value === 0 ? t('toast_duration.until_closed') : t('toast_duration.seconds', { seconds: value }), })); return (
{ACCESSIBILITY_PRESET_IDS.map((id) => ( ))}
{applied && (

{applied.changes.length === 0 ? t('presets.nothing_changed', { name: t(`presets.${applied.id}.label`) }) : t('presets.applied', { name: t(`presets.${applied.id}.label`), count: applied.changes.length })}

{applied.changes.length > 0 && (
    {applied.changes.map((change) => (
  • {PRESET_SETTING_LABELS[change.key] ? tRoot(PRESET_SETTING_LABELS[change.key]) : change.key}
  • ))}
)}
{applied.changes.length > 0 && ( )} {!settings.showAccessibilityButton && ( )}
)}
set('showAccessibilityButton', checked)} />
set('appFont', value)} options={[ { value: 'default', label: t('font.default') }, { value: 'atkinson', label: 'Atkinson Hyperlegible' }, { value: 'lexend', label: 'Lexend' }, { value: 'opendyslexic', label: 'OpenDyslexic' }, ]} /> set('density', value as Density)} options={[ { value: 'extra-compact', label: tAppearance('list_density.extra_compact') }, { value: 'compact', label: tAppearance('list_density.compact') }, { value: 'regular', label: tAppearance('list_density.regular') }, { value: 'comfortable', label: tAppearance('list_density.comfortable') }, ]} /> )} set('showToolbarLabels', checked)} /> set('contrast', value)} options={[ { value: 'system', label: t('system') }, { value: 'more', label: t('contrast.more') }, { value: 'normal', label: t('contrast.normal') }, ]} /> {systemToggle('reduceTransparency')} set('underlineLinks', checked)} /> set('showColorNames', checked)} /> set('colorBlindPalette', checked)} /> set('emailAlwaysLightMode', checked)} /> goToSettings('appearance', 'theme')} /> {systemToggle('reduceMotion')} {systemToggle('pauseAnimatedImages')} {!isSettingHidden('singleKeyShortcuts') && ( set('singleKeyShortcuts', checked)} /> )} set('focusRing', value)} options={[ { value: 'normal', label: t('focus_ring.normal') }, { value: 'thick', label: t('focus_ring.thick') }, { value: 'extra', label: t('focus_ring.extra') }, ]} /> set('focusAlwaysVisible', checked)} /> {systemToggle('largeTargets')} set('largeCursor', checked)} /> set('openOnHover', checked)} /> set('mailTextScale', Number(value))} options={MAIL_TEXT_SCALES.map((scale) => ({ value: String(scale), label: scale === 0 ? t('mail_text_size.follow') : `${Math.round(scale * 100)} %`, }))} /> set('mailOverrideStyles', value)} options={[ { value: 'never', label: t('mail_override.never') }, { value: 'high-contrast', label: t('mail_override.high_contrast') }, { value: 'always', label: t('mail_override.always') }, ]} /> set('mailFitWidth', checked)} /> set('readAloudRate', Number(value))} options={READ_ALOUD_RATES.map((rate) => ({ value: String(rate), label: `${rate}×` }))} /> set('composeFontFamily', value)} options={[ { value: '', label: t('compose_font.default') }, { value: 'sans-serif', label: t('compose_font.sans') }, { value: 'serif', label: t('compose_font.serif') }, { value: 'monospace', label: t('compose_font.mono') }, ]} /> set('markAsReadDelay', parseInt(value))} options={[ { value: '0', label: tEmail('mark_read.instant') }, { value: '3000', label: tEmail('mark_read.delay_3s') }, { value: '5000', label: tEmail('mark_read.delay_5s') }, { value: '-1', label: tEmail('mark_read.never') }, ]} /> )} set('returnToListAfterAction', checked)} /> set('sendDelaySeconds', Number(value) as SendDelaySeconds)} options={[ { value: '0', label: tEmail('send_delay.off') }, { value: '10', label: tEmail('send_delay.seconds', { seconds: 10 }) }, { value: '30', label: tEmail('send_delay.seconds', { seconds: 30 }) }, { value: '60', label: tEmail('send_delay.seconds', { seconds: 60 }) }, ]} /> {!isSettingHidden('sendConfirmation') && ( set('sendConfirmation', checked)} /> )} set('confirmDelete', checked)} /> set('confirmArchive', checked)} /> set('confirmBulkDelete', checked)} /> goToSettings('composing', 'sending')} /> set('visualAlerts', checked)} /> set('emailNotificationSound', checked)} /> set('calendarNotificationSound', checked)} />