From 6a1da49f520ec8d8a7bbd7a917e244fb6dfe6572 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:57:20 +0100 Subject: [PATCH] fix: let accessibility reset bring back the organisation's defaults and cover every option --- .../__tests__/accessibility-settings.test.tsx | 23 ++++++++++++++++ .../settings/accessibility-settings.tsx | 14 +++++----- stores/settings-store.ts | 26 +++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/components/settings/__tests__/accessibility-settings.test.tsx b/components/settings/__tests__/accessibility-settings.test.tsx index d8a73d641..cc876edea 100644 --- a/components/settings/__tests__/accessibility-settings.test.tsx +++ b/components/settings/__tests__/accessibility-settings.test.tsx @@ -99,6 +99,29 @@ describe('reset', () => { // A shared everyday preference is not an accessibility setting to reset. expect(settings().disableThreading).toBe(true); }); + + it("gives back the organisation's defaults instead of writing Bulwark's as the user's choice", () => { + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, defaults: { appFont: 'atkinson', fontSize: 'large' } }, loaded: true }); + settings().applyAdminPolicy(); + settings().updateSetting('appFont', 'lexend'); + settings().updateSetting('fontSize', 'huge'); + render(); + fireEvent.click(screen.getByRole('button', { name: 'settings.accessibility.reset.button' })); + expect(settings().appFont).toBe('atkinson'); + expect(settings().fontSize).toBe('large'); + // Nothing is left marked as chosen, so a later change of the default reaches the user. + expect(settings().explicitSettings).not.toContain('appFont'); + expect(settings().explicitSettings).not.toContain('fontSize'); + }); + + it('also resets the plain text font and the send confirmation', () => { + settings().updateSetting('plainTextFont', 'mono'); + settings().updateSetting('sendConfirmation', true); + render(); + fireEvent.click(screen.getByRole('button', { name: 'settings.accessibility.reset.button' })); + expect(settings().plainTextFont).toBe('sans'); + expect(settings().sendConfirmation).toBe(false); + }); }); describe('toast duration', () => { diff --git a/components/settings/accessibility-settings.tsx b/components/settings/accessibility-settings.tsx index 371f96ac8..3068655e0 100644 --- a/components/settings/accessibility-settings.tsx +++ b/components/settings/accessibility-settings.tsx @@ -14,7 +14,6 @@ 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, @@ -37,6 +36,9 @@ import type { Density, SendDelaySeconds } from '@/stores/settings-store'; type SettingKey = Parameters['updateSetting']>[0]; +/** What Reset gives back: the settings only this page has. */ +const RESET_KEYS: SettingKey[] = [...ACCESSIBILITY_KEYS, 'fontSize', 'plainTextFont', 'sendConfirmation']; + /** 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 })); @@ -64,7 +66,7 @@ export function AccessibilitySettings() { const tNotifications = useTranslations('settings.notifications'); const tCalendar = useTranslations('calendar.settings'); const settings = useSettingsStore(); - const { updateSetting } = settings; + const { updateSetting, resetSettings } = settings; const { isSettingLocked, isSettingHidden, getRestriction } = usePolicyStore(); const faviconsAllowed = !useNoThirdPartyTraffic(); const [applied, setApplied] = useState<{ id: AccessibilityPresetId; changes: PresetChange[] } | null>(null); @@ -91,12 +93,10 @@ export function AccessibilitySettings() { setApplied(null); }; + // Forgets the user's own values, so the organisation's defaults (or + // Bulwark's) apply again. Shared everyday settings shown here too stay. const resetAll = () => { - for (const key of ACCESSIBILITY_KEYS) { - if (isSettingLocked(key)) continue; - set(key, ACCESSIBILITY_DEFAULTS[key]); - } - set('fontSize', 'medium'); + resetSettings(RESET_KEYS.filter((key) => !isSettingLocked(key))); setApplied(null); setResetDone(true); }; diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 082ce5896..584864ea1 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -804,6 +804,12 @@ interface SettingsState extends AccessibilitySettings { opts?: { internal?: boolean } ) => void; resetToDefaults: () => void; + /** + * Forgets the user's own value for each of `keys`: the setting loses its + * "chosen by the user" mark and takes the admin default (policy.defaults) + * again, or the built-in one when the admin set none. + */ + resetSettings: (keys: readonly string[]) => void; /** * Applies the admin policy (#1248): `policy.defaults` to every setting the * user has not chosen, and a locked restriction's value to its setting @@ -1495,6 +1501,26 @@ export const useSettingsStore = create()( void settingsHooks.onSettingsReset.emit({}); }, + resetSettings: (keys) => { + const own = keys.filter((key) => hasOwn(DEFAULT_SETTINGS, key) && key !== 'explicitSettings' && !BOOKKEEPING_SETTING_KEYS.has(key)); + if (own.length === 0) return; + const before = get() as unknown as Record; + const previous = Object.fromEntries(own.map((key) => [key, before[key]])); + const dropped = new Set(own); + set({ + ...Object.fromEntries(own.map((key) => [key, builtInDefault(key)])), + explicitSettings: get().explicitSettings.filter((key) => !dropped.has(key)), + } as Partial); + applyAdminPolicyPatch(); + applyVisualSettings(); + const after = get() as unknown as Record; + for (const key of own) { + if (after[key] !== previous[key]) { + void settingsHooks.onSettingChange.emit({ key, value: after[key], previous: previous[key] }); + } + } + }, + exportSettings: (opts) => { const json = buildExport(opts); if (!opts?.forSync) {