fix user theme updates, toggles, manual theme saving
This commit is contained in:
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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);
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user