fix user theme updates, toggles, manual theme saving

This commit is contained in:
2026-01-24 17:36:13 +00:00
parent d569eb72db
commit f50a8ea463
6 changed files with 74 additions and 24 deletions

View File

@ -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<MePatchSuccessAction>({
type: ME_PATCH_SUCCESS,

View File

@ -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<HTMLSelectElement>, 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 = () => {
</div>
</ListItem>
<ListItem label={<FormattedMessage id='preferences.fields.theme.display_background_gradient' defaultMessage='Display background gradient' />}>
<SettingToggle settings={settings} settingPath={['theme', 'backgroundGradient']} defaultValue onChange={onToggleChange} />
<SettingToggle settings={settings} settingPath={['theme', 'backgroundGradient']} defaultValue onChange={onThemeToggleChange} />
</ListItem>
{settings.themeMode === 'system' && (
<ListItem
@ -225,16 +228,19 @@ const Preferences = () => {
className='max-w-[200px]'
items={systemDarkThemePreferenceOptions}
defaultValue={settings.theme?.systemDarkThemePreference || 'black'}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onSelectChange(event, ['theme', 'systemDarkThemePreference'])}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => onThemeSelectChange(event, ['theme', 'systemDarkThemePreference'])}
/>
</ListItem>
)}
</List>
<HStack justifyContent='end'>
<HStack justifyContent='end' space={2}>
<Button theme='secondary' onClick={onThemeReset}>
<FormattedMessage id='preferences.fields.theme_reset' defaultMessage='Reset theme' />
</Button>
<Button theme='primary' onClick={onThemeSave}>
<FormattedMessage id='preferences.fields.theme_save' defaultMessage='Save theme' />
</Button>
</HStack>
<List>

View File

@ -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 (

View File

@ -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",

View File

@ -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 = <T>(schema: v.BaseSchema<any, T, v.BaseIssue<unknown>>) =>
v.pipe(
@ -23,4 +35,4 @@ const coerceObject = <T extends v.ObjectEntries>(shape: T): v.ObjectSchema<T, un
{},
) as any;
export { filteredArray, coerceObject };
export { filteredArray, coerceObject, coerceBoolean };

View File

@ -25,6 +25,21 @@ const messages = defineMessages({
const settingsSchemaPartial = v.partial(settingsSchema);
/** Recursively coerce string 'true'/'false' to boolean values. */
const coerceStringBooleans = (obj: any): any => {
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<string, any> = {};
for (const key of Object.keys(obj)) {
result[key] = coerceStringBooleans(obj[key]);
}
return result;
}
return obj;
};
type State = {
defaultSettings: Settings;
userSettings: Partial<Settings>;
@ -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<keyof Settings>) {
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<State>()(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<State>()(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);
}),