From f50a8ea4637c47f140284cc17dd225942dfb88ab Mon Sep 17 00:00:00 2001 From: matty Date: Sat, 24 Jan 2026 17:36:13 +0000 Subject: [PATCH] fix user theme updates, toggles, manual theme saving --- packages/pl-fe/src/actions/me.ts | 3 ++ .../pl-fe/src/features/preferences/index.tsx | 36 +++++++++------- .../features/ui/components/theme-toggle.tsx | 2 +- packages/pl-fe/src/locales/en.json | 1 + packages/pl-fe/src/schemas/utils.ts | 14 ++++++- packages/pl-fe/src/stores/settings.ts | 42 +++++++++++++++---- 6 files changed, 74 insertions(+), 24 deletions(-) diff --git a/packages/pl-fe/src/actions/me.ts b/packages/pl-fe/src/actions/me.ts index b36a9548e..4ef373480 100644 --- a/packages/pl-fe/src/actions/me.ts +++ b/packages/pl-fe/src/actions/me.ts @@ -108,6 +108,9 @@ interface MePatchSuccessAction { const patchMeSuccess = (me: CredentialAccount) => (dispatch: AppDispatch) => { + // Reload user settings from the server response to keep Zustand store in sync + useSettingsStore.getState().actions.loadUserSettings(me.settings_store?.[FE_NAME]); + dispatch(importEntities({ accounts: [me] })); dispatch({ type: ME_PATCH_SUCCESS, diff --git a/packages/pl-fe/src/features/preferences/index.tsx b/packages/pl-fe/src/features/preferences/index.tsx index 44a745f93..62ea80b67 100644 --- a/packages/pl-fe/src/features/preferences/index.tsx +++ b/packages/pl-fe/src/features/preferences/index.tsx @@ -1,4 +1,3 @@ -import debounce from 'lodash/debounce'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; @@ -23,8 +22,6 @@ import { isStandalone } from 'pl-fe/utils/state'; import ThemeToggle from '../ui/components/theme-toggle'; -import type { AppDispatch } from 'pl-fe/store'; - const languages = { en: 'English', ar: 'العربية', @@ -111,10 +108,6 @@ const messages = defineMessages({ black: { id: 'theme_toggle.black', defaultMessage: 'Black' }, }); -const debouncedSave = debounce((dispatch: AppDispatch) => { - dispatch(saveSettings({ showAlert: true })); -}, 1000); - const Preferences = () => { const intl = useIntl(); const dispatch = useAppDispatch(); @@ -130,6 +123,10 @@ const Preferences = () => { dispatch(changeSetting(path, event.target.value, { showAlert: true })); }; + const onThemeSelectChange = (event: React.ChangeEvent, path: string[]) => { + dispatch(changeSetting(path, event.target.value, { save: false })); + }; + const onSelectMultiple = (selectedList: string[], path: string[]) => { dispatch(changeSetting(path, selectedList.toSorted((a, b) => a.localeCompare(b)), { showAlert: true })); }; @@ -138,21 +135,27 @@ const Preferences = () => { dispatch(changeSetting(key, checked)); }; + const onThemeToggleChange = (key: string[], checked: boolean) => { + dispatch(changeSetting(key, checked, { save: false })); + }; + const onBrandColorChange = (newBrandColor: string) => { if (newBrandColor === brandColor) return; - dispatch(changeSetting(['theme', 'brandColor'], newBrandColor, { showAlert: true, save: false })); - debouncedSave(dispatch); + dispatch(changeSetting(['theme', 'brandColor'], newBrandColor, { save: false })); }; const onInterfaceSizeChange = (value: number) => { - dispatch(changeSetting(['theme', 'interfaceSize'], INTERFACE_SIZES[value], { showAlert: true, save: false })); - debouncedSave(dispatch); + dispatch(changeSetting(['theme', 'interfaceSize'], INTERFACE_SIZES[value], { save: false })); + }; + + const onThemeSave = () => { + dispatch(saveSettings({ showAlert: true })); }; const onThemeReset = () => { dispatch(changeSetting(['themeMode'], plFeConfig.defaultSettings.themeMode, { save: false })); - dispatch(changeSetting(['theme'], plFeConfig.defaultSettings.theme, { showAlert: true })); + dispatch(changeSetting(['theme'], plFeConfig.defaultSettings.theme, { save: false })); }; const displayMediaOptions = React.useMemo(() => ({ @@ -214,7 +217,7 @@ const Preferences = () => { }> - + {settings.themeMode === 'system' && ( { className='max-w-[200px]' items={systemDarkThemePreferenceOptions} defaultValue={settings.theme?.systemDarkThemePreference || 'black'} - onChange={(event: React.ChangeEvent) => onSelectChange(event, ['theme', 'systemDarkThemePreference'])} + onChange={(event: React.ChangeEvent) => onThemeSelectChange(event, ['theme', 'systemDarkThemePreference'])} /> )} - + + diff --git a/packages/pl-fe/src/features/ui/components/theme-toggle.tsx b/packages/pl-fe/src/features/ui/components/theme-toggle.tsx index 5c3443d97..22719be09 100644 --- a/packages/pl-fe/src/features/ui/components/theme-toggle.tsx +++ b/packages/pl-fe/src/features/ui/components/theme-toggle.tsx @@ -12,7 +12,7 @@ const ThemeToggle: React.FC = () => { const { themeMode } = useSettings(); const handleChange = (themeMode: string) => { - dispatch(changeSetting(['themeMode'], themeMode)); + dispatch(changeSetting(['themeMode'], themeMode, { save: false })); }; return ( diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index 24ddbb784..a1e285863 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -1473,6 +1473,7 @@ "preferences.fields.theme.dark_theme_preference_label": "Dark theme preference", "preferences.fields.theme.display_background_gradient": "Display background gradient", "preferences.fields.theme_reset": "Reset theme", + "preferences.fields.theme_save": "Save theme", "preferences.fields.underline_links_label": "Always underline links in posts", "preferences.fields.unfollow_modal_label": "Show confirmation dialog before unfollowing someone", "preferences.fields.web_layout_label": "Layout of the web view of your profile", diff --git a/packages/pl-fe/src/schemas/utils.ts b/packages/pl-fe/src/schemas/utils.ts index d94ba71fa..2a00bf7e6 100644 --- a/packages/pl-fe/src/schemas/utils.ts +++ b/packages/pl-fe/src/schemas/utils.ts @@ -1,5 +1,17 @@ import * as v from 'valibot'; +/** Coerces string 'true'/'false' to boolean, passes through actual booleans. */ +const coerceBoolean = v.pipe( + v.any(), + v.transform((input) => { + if (typeof input === 'boolean') return input; + if (input === 'true') return true; + if (input === 'false') return false; + return input; + }), + v.boolean(), +); + /** Validates individual items in an array, dropping any that aren't valid. */ const filteredArray = (schema: v.BaseSchema>) => v.pipe( @@ -23,4 +35,4 @@ const coerceObject = (shape: T): v.ObjectSchema { + if (obj === 'true') return true; + if (obj === 'false') return false; + if (Array.isArray(obj)) return obj.map(coerceStringBooleans); + if (obj !== null && typeof obj === 'object') { + const result: Record = {}; + for (const key of Object.keys(obj)) { + result[key] = coerceStringBooleans(obj[key]); + } + return result; + } + return obj; +}; + type State = { defaultSettings: Settings; userSettings: Partial; @@ -41,21 +56,34 @@ type State = { }; } -const changeSetting = (object: APIEntity, path: string[], value: any, root?: Settings) => { +const changeSetting = (object: APIEntity, path: string[], value: any) => { if (path.length === 1) { object[path[0]] = value; return; } if (typeof object[path[0]] !== 'object') { - const value = root && root[path[0] as keyof Settings] as APIEntity || {}; - object[path[0]] = value; + object[path[0]] = {}; } return changeSetting(object[path[0]], path.slice(1), value); }; const mergeSettings = (state: State, updating = false) => { - const mergedSettings = { ...state.defaultSettings, ...state.userSettings }; + // Deep merge for nested objects to preserve all properties + const mergedSettings = { ...state.defaultSettings }; + + for (const key of Object.keys(state.userSettings) as Array) { + const userValue = state.userSettings[key]; + const defaultValue = state.defaultSettings[key]; + + // Deep merge objects (but not arrays) + if (userValue !== undefined && typeof userValue === 'object' && !Array.isArray(userValue) && + typeof defaultValue === 'object' && !Array.isArray(defaultValue)) { + (mergedSettings as any)[key] = { ...defaultValue, ...userValue }; + } else if (userValue !== undefined) { + (mergedSettings as any)[key] = userValue; + } + } if (updating) { const me = lazyStore?.getState().me; if (me) { @@ -100,14 +128,14 @@ const useSettingsStore = create()(mutative((set) => ({ loadDefaultSettings: (settings: APIEntity) => set((state: State) => { if (typeof settings !== 'object') return; - state.defaultSettings = v.parse(settingsSchema, settings); + state.defaultSettings = v.parse(settingsSchema, coerceStringBooleans(settings)); mergeSettings(state); }), loadUserSettings: (settings?: APIEntity) => set((state: State) => { if (typeof settings !== 'object') return; - state.userSettings = v.parse(settingsSchemaPartial, settings); + state.userSettings = v.parse(settingsSchemaPartial, coerceStringBooleans(settings)); const me = lazyStore?.getState().me; if (me) { @@ -158,7 +186,7 @@ const useSettingsStore = create()(mutative((set) => ({ changeSetting: (path: string[], value: any) => set((state: State) => { state.userSettings.saved = false; - changeSetting(state.userSettings, path, value, state.defaultSettings); + changeSetting(state.userSettings, path, value); mergeSettings(state, true); }),