From 7512cac7f0ffd374538b30ad74d15b92113bfbd9 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 9 May 2026 17:49:48 -0500 Subject: [PATCH] add deploy setting defaults --- .changeset/deploy-settings-defaults.md | 5 + README.md | 22 +++ src/app/hooks/useClientConfig.ts | 4 + src/app/pages/App.tsx | 50 ++++-- src/app/state/settings.defaults.test.ts | 67 +++++++ src/app/state/settings.ts | 226 ++++++++++++++++++++++-- src/app/utils/settingsSync.test.ts | 41 ++++- 7 files changed, 376 insertions(+), 39 deletions(-) create mode 100644 .changeset/deploy-settings-defaults.md create mode 100644 src/app/state/settings.defaults.test.ts diff --git a/.changeset/deploy-settings-defaults.md b/.changeset/deploy-settings-defaults.md new file mode 100644 index 000000000..d478585e3 --- /dev/null +++ b/.changeset/deploy-settings-defaults.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Self-hosted deployments can set optional `settingsDefaults` in `config.json` to override built-in client settings. See the README for details. diff --git a/README.md b/README.md index 2a1f7d853..6a2434955 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,28 @@ After that, you can copy the dist/ directory to your server and serve it. * In the [`config.json`](config.json), you can modify the default homeservers, feature rooms/spaces, toggle the account switcher, and toggle experimental simplified slilding sync support. +#### Optional default client settings + +While the default settings are recommended for most users, you can optionally add a top-level `"settingsDefaults"` object whose keys match [client settings](src/app/state/settings.ts) (only fields you want to override) to override them. The default settings for any new logins will match these. Existing keys in local storage or users who chose to sync settings with their account data will still have their settings set. + +For example: + +```json +{ + "settingsDefaults": { + "hour24Clock": true, + "pageZoom": 110, + "messageLayout": 2, + "rightSwipeAction": "members", + "captionPosition": "below", + "renderUserCards": "both", + "jumboEmojiSize": "large" + } +} +``` + +Invalid or unknown keys are ignored. + * To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts). * For example, if you want to deploy on `https://sable.moe/app`, then set `base: '/app'`. diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 4a47ae868..6cb2a9ad3 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -1,5 +1,7 @@ import { createContext, useContext } from 'react'; +import type { Settings } from '$state/settings'; + export type HashRouterConfig = { enabled?: boolean; basename?: string; @@ -46,6 +48,8 @@ export type ClientConfig = { themeCatalogBaseUrl?: string; themeCatalogManifestUrl?: string; themeCatalogApprovedHostPrefixes?: string[]; + + settingsDefaults?: Partial; }; const ClientConfigContext = createContext(null); diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 60424b924..e4bf0d773 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -1,13 +1,16 @@ -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useRef } from 'react'; import { Provider as JotaiProvider } from 'jotai'; +import { createStore } from 'jotai/vanilla'; import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; import { ClientConfigLoader } from '$components/ClientConfigLoader'; +import type { ClientConfig } from '$hooks/useClientConfig'; import { ClientConfigProvider } from '$hooks/useClientConfig'; import { setMatrixToBase } from '$plugins/matrix-to'; +import type { ScreenSize } from '$hooks/useScreenSize'; import { ScreenSizeProvider, useScreenSize } from '$hooks/useScreenSize'; import { useCompositionEndTracking } from '$hooks/useComposingCheck'; import { ErrorPage } from '$components/DefaultErrorPage'; @@ -15,6 +18,7 @@ import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig'; import { FeatureCheck } from './FeatureCheck'; import { createRouter } from './Router'; import { isReactQueryDevtoolsEnabled } from './reactQueryDevtoolsGate'; +import { bootstrapSettingsStore } from '$state/settings'; const queryClient = new QueryClient(); const ReactQueryDevtools = lazy(async () => { @@ -23,11 +27,38 @@ const ReactQueryDevtools = lazy(async () => { return { default: Devtools }; }); +type BootstrappedAppShellProps = { + clientConfig: ClientConfig; + screenSize: ScreenSize; +}; + +function BootstrappedAppShell({ clientConfig, screenSize }: BootstrappedAppShellProps) { + const jotaiStoreRef = useRef>(); + if (!jotaiStoreRef.current) { + jotaiStoreRef.current = createStore(); + } + bootstrapSettingsStore(jotaiStoreRef.current, clientConfig.settingsDefaults); + const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); + + return ( + + + + + + {reactQueryDevtoolsEnabled && ( + + + + )} + + + ); +} + function App() { const screenSize = useScreenSize(); useCompositionEndTracking(); - const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); - const portalContainer = document.getElementById('portalContainer') ?? undefined; return ( @@ -53,18 +84,7 @@ function App() { {(clientConfig) => { setMatrixToBase(clientConfig.matrixToBaseUrl); return ( - - - - - - {reactQueryDevtoolsEnabled && ( - - - - )} - - + ); }} diff --git a/src/app/state/settings.defaults.test.ts b/src/app/state/settings.defaults.test.ts new file mode 100644 index 000000000..d2b51cd56 --- /dev/null +++ b/src/app/state/settings.defaults.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + defaultSettings, + mergePersistedSettings, + sanitizeSettingsDefaults, + resetRuntimeSettingsDefaults, +} from '$state/settings'; + +beforeEach(() => { + localStorage.clear(); + resetRuntimeSettingsDefaults(); +}); + +describe('mergePersistedSettings', () => { + it('layers deployer defaults over code defaults when localStorage is empty', () => { + const merged = mergePersistedSettings(null, { twitterEmoji: false }); + expect(merged.twitterEmoji).toBe(false); + expect(merged.pageZoom).toBe(defaultSettings.pageZoom); + }); + + it('lets localStorage override deployer defaults', () => { + localStorage.setItem('settings', JSON.stringify({ twitterEmoji: true })); + const merged = mergePersistedSettings(localStorage.getItem('settings'), { + twitterEmoji: false, + }); + expect(merged.twitterEmoji).toBe(true); + }); + + it('still applies monochrome migration when layering defaults', () => { + localStorage.setItem('settings', JSON.stringify({ monochromeMode: true })); + const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(merged.saturationLevel).toBe(0); + }); +}); + +describe('sanitizeSettingsDefaults', () => { + it('keeps known keys with valid types', () => { + expect(sanitizeSettingsDefaults({ twitterEmoji: false })).toEqual({ + twitterEmoji: false, + }); + }); + + it('drops unknown keys', () => { + expect(sanitizeSettingsDefaults({ notARealSetting: true, hour24Clock: true })).toEqual({ + hour24Clock: true, + }); + }); + + it('drops invalid types', () => { + expect(sanitizeSettingsDefaults({ twitterEmoji: 'yes' })).toEqual({}); + }); + + it('accepts messageLayout 0–2 only', () => { + expect(sanitizeSettingsDefaults({ messageLayout: 2 })).toEqual({ + messageLayout: 2, + }); + expect(sanitizeSettingsDefaults({ messageLayout: 9 })).toEqual({}); + expect(sanitizeSettingsDefaults({ messageLayout: 1.5 })).toEqual({}); + }); + + it('accepts rightSwipeAction enum strings', () => { + expect(sanitizeSettingsDefaults({ rightSwipeAction: 'members' })).toEqual({ + rightSwipeAction: 'members', + }); + expect(sanitizeSettingsDefaults({ rightSwipeAction: 'nope' })).toEqual({}); + }); +}); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 12b39fbb9..401963f49 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -1,4 +1,5 @@ import { atom, type WritableAtom } from 'jotai'; +import type { Store } from 'jotai/vanilla/store'; import { mobileOrTablet } from '$utils/user-agent'; const STORAGE_KEY = 'settings'; @@ -296,13 +297,18 @@ export const defaultSettings: Settings = { themeRemoteEnabledTweakFullUrls: [], }; -export const getSettings = () => { - const settings = localStorage.getItem(STORAGE_KEY); - if (settings === null) return defaultSettings; +function cloneDefaultSettings(): Settings { + return { + ...defaultSettings, + themeRemoteFavorites: defaultSettings.themeRemoteFavorites.map((x) => ({ + ...x, + })), + themeRemoteTweakFavorites: defaultSettings.themeRemoteTweakFavorites.map((x) => ({ ...x })), + themeRemoteEnabledTweakFullUrls: [...defaultSettings.themeRemoteEnabledTweakFullUrls], + }; +} - // migration for old keys - // monochrome -> saturation - const parsed = JSON.parse(settings); +function migrateParsedLocalStorage(parsed: Record): void { if (parsed.monochromeMode === true && parsed.saturationLevel === undefined) { parsed.saturationLevel = 0; } else if (parsed.monochromeMode === false && parsed.saturationLevel === undefined) { @@ -321,27 +327,215 @@ export const getSettings = () => { parsed.renderUserCards = 'both'; } - const parsedRecord = parsed as Record; if ( - typeof parsedRecord.themeChatAutoPreviewAnyUrl !== 'boolean' && - typeof parsedRecord.themeChatPreviewAnyUrl === 'boolean' + typeof parsed.themeChatAutoPreviewAnyUrl !== 'boolean' && + typeof parsed.themeChatPreviewAnyUrl === 'boolean' ) { - parsedRecord.themeChatAutoPreviewAnyUrl = parsedRecord.themeChatPreviewAnyUrl; + parsed.themeChatAutoPreviewAnyUrl = parsed.themeChatPreviewAnyUrl; } - delete parsedRecord.themeChatPreviewAnyUrl; - delete parsedRecord.themeChatPreviewApprovedCatalogOnly; + delete parsed.themeChatPreviewAnyUrl; + delete parsed.themeChatPreviewApprovedCatalogOnly; +} + +export function mergePersistedSettings( + rawLocalStorage: string | null, + fileDefaults: Partial +): Settings { + const base = { ...defaultSettings, ...fileDefaults }; + if (rawLocalStorage === null) return base; + + const parsed = JSON.parse(rawLocalStorage) as Record; + migrateParsedLocalStorage(parsed); return { - ...defaultSettings, - ...(parsed as Settings), + ...base, + ...(parsed as unknown as Settings), }; -}; +} + +const MESSAGE_SPACING_VALUES = new Set(['0', '100', '200', '300', '400', '500']); +const JUMBO_EMOJI_VALUES = new Set([ + 'none', + 'extraSmall', + 'small', + 'normal', + 'large', + 'extraLarge', +]); + +function sanitizeStringArray(val: unknown): string[] | undefined { + if (!Array.isArray(val)) return undefined; + const out = val.filter((x): x is string => typeof x === 'string'); + return out; +} + +function sanitizeThemeRemoteFavorites(val: unknown): ThemeRemoteFavorite[] | undefined { + if (!Array.isArray(val)) return undefined; + const out: ThemeRemoteFavorite[] = []; + for (const item of val) { + if (!item || typeof item !== 'object') continue; + const o = item as Record; + if ( + typeof o.fullUrl === 'string' && + typeof o.displayName === 'string' && + typeof o.basename === 'string' && + (o.kind === 'light' || o.kind === 'dark') + ) { + out.push({ + fullUrl: o.fullUrl, + displayName: o.displayName, + basename: o.basename, + kind: o.kind, + pinned: typeof o.pinned === 'boolean' ? o.pinned : undefined, + importedLocal: typeof o.importedLocal === 'boolean' ? o.importedLocal : undefined, + }); + } + } + return out; +} + +function sanitizeThemeRemoteTweakFavorites(val: unknown): ThemeRemoteTweakFavorite[] | undefined { + if (!Array.isArray(val)) return undefined; + const out: ThemeRemoteTweakFavorite[] = []; + for (const item of val) { + if (!item || typeof item !== 'object') continue; + const o = item as Record; + if ( + typeof o.fullUrl === 'string' && + typeof o.displayName === 'string' && + typeof o.basename === 'string' + ) { + out.push({ + fullUrl: o.fullUrl, + displayName: o.displayName, + basename: o.basename, + pinned: typeof o.pinned === 'boolean' ? o.pinned : undefined, + importedLocal: typeof o.importedLocal === 'boolean' ? o.importedLocal : undefined, + }); + } + } + return out; +} + +function isSanitizableSettingsKey(k: string): k is keyof Settings { + return ( + k in defaultSettings || k === 'filterPronounsBasedOnLanguage' || k === 'filterPronounsLanguages' + ); +} + +function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { + switch (key) { + case 'filterPronounsBasedOnLanguage': + return typeof val === 'boolean' ? val : undefined; + case 'filterPronounsLanguages': + return sanitizeStringArray(val); + case 'messageLayout': + return typeof val === 'number' && Number.isInteger(val) && val >= 0 && val <= 2 + ? val + : undefined; + case 'messageSpacing': + return typeof val === 'string' && MESSAGE_SPACING_VALUES.has(val as MessageSpacing) + ? val + : undefined; + case 'captionPosition': + return val === CaptionPosition.Above || + val === CaptionPosition.Inline || + val === CaptionPosition.Hidden || + val === CaptionPosition.Below + ? val + : undefined; + case 'rightSwipeAction': + return val === RightSwipeAction.Members || val === RightSwipeAction.Reply ? val : undefined; + case 'renderUserCards': + return val === 'both' || val === 'light' || val === 'dark' || val === 'none' + ? val + : undefined; + case 'jumboEmojiSize': + return typeof val === 'string' && JUMBO_EMOJI_VALUES.has(val as JumboEmojiSize) + ? val + : undefined; + case 'themeRemoteManualKind': + case 'themeRemoteLightKind': + case 'themeRemoteDarkKind': + return val === 'light' || val === 'dark' ? val : undefined; + case 'themeRemoteFavorites': + return sanitizeThemeRemoteFavorites(val); + case 'themeRemoteTweakFavorites': + return sanitizeThemeRemoteTweakFavorites(val); + case 'themeRemoteEnabledTweakFullUrls': + return sanitizeStringArray(val); + default: { + if (!(key in defaultSettings)) return undefined; + const sample = defaultSettings[key]; + if (typeof sample === 'boolean') { + return typeof val === 'boolean' ? val : undefined; + } + if (typeof sample === 'number') { + return typeof val === 'number' && Number.isFinite(val) ? val : undefined; + } + if (typeof sample === 'string') { + return typeof val === 'string' ? val : undefined; + } + if (sample === undefined) { + return typeof val === 'string' ? val : undefined; + } + return undefined; + } + } +} + +export function sanitizeSettingsDefaults(raw: unknown): Partial { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + const src = raw as Record; + const out: Partial = {}; + const warnings: string[] = []; + + for (const k of Object.keys(src)) { + if (!isSanitizableSettingsKey(k)) { + warnings.push(k); + continue; + } + const sanitized = sanitizeSettingsKey(k, src[k]); + if (sanitized !== undefined) { + (out as Record)[k] = sanitized; + } else if (src[k] !== undefined) { + warnings.push(k); + } + } + + if (import.meta.env.DEV && warnings.length > 0) { + console.warn( + '[config.settingsDefaults] ignored unknown or invalid keys:', + [...new Set(warnings)].slice(0, 25).join(', ') + ); + } + + return out; +} + +let runtimeSettingsDefaults: Partial = {}; + +/** @internal Resets deploy-time defaults, only used in tests. */ +export function resetRuntimeSettingsDefaults(): void { + runtimeSettingsDefaults = {}; +} + +export const baseSettings = atom(cloneDefaultSettings()); + +export function bootstrapSettingsStore(store: Store, rawSettingsDefaults: unknown): void { + const sanitized = sanitizeSettingsDefaults(rawSettingsDefaults); + runtimeSettingsDefaults = sanitized; + const merged = mergePersistedSettings(localStorage.getItem(STORAGE_KEY), sanitized); + store.set(baseSettings, merged); +} + +export const getSettings = (): Settings => + mergePersistedSettings(localStorage.getItem(STORAGE_KEY), runtimeSettingsDefaults); export const setSettings = (settings: Settings) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); }; -const baseSettings = atom(getSettings()); export const settingsAtom = atom( (get) => get(baseSettings), (_get, set, update) => { diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index c0d617752..608a94343 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { getSettings } from '$state/settings'; +import { getSettings, resetRuntimeSettingsDefaults } from '$state/settings'; import { NON_SYNCABLE_KEYS, SETTINGS_SYNC_VERSION, @@ -15,6 +15,7 @@ let base: ReturnType; beforeEach(() => { localStorage.clear(); + resetRuntimeSettingsDefaults(); base = getSettings(); }); @@ -141,7 +142,12 @@ describe('deserializeFromSync', () => { developerTools: true, }, }; - const local = { ...base, pageZoom: 100, isPeopleDrawer: true, settingsSyncEnabled: false }; + const local = { + ...base, + pageZoom: 100, + isPeopleDrawer: true, + settingsSyncEnabled: false, + }; const result = deserializeFromSync(remote, local); expect(result).not.toBeNull(); expect(result!.pageZoom).toBe(100); @@ -280,12 +286,21 @@ describe('importSettingsFromJson', () => { }); it('resolves merged settings when a valid JSON file is provided', async () => { - const payload = { v: SETTINGS_SYNC_VERSION, settings: { twitterEmoji: false } }; + const payload = { + v: SETTINGS_SYNC_VERSION, + settings: { twitterEmoji: false }, + }; const fileContent = JSON.stringify(payload); - const file = new File([fileContent], 'settings.json', { type: 'application/json' }); + const file = new File([fileContent], 'settings.json', { + type: 'application/json', + }); // Build a minimal FileList-like object. - const fakeFileList = { 0: file, length: 1, item: () => file } as unknown as FileList; + const fakeFileList = { + 0: file, + length: 1, + item: () => file, + } as unknown as FileList; mockInput.files = fakeFileList; const promise = importSettingsFromJson({ ...base, twitterEmoji: true }); @@ -299,8 +314,14 @@ describe('importSettingsFromJson', () => { }); it('resolves null when the file contains invalid JSON', async () => { - const file = new File(['not json {{'], 'bad.json', { type: 'application/json' }); - const fakeFileList = { 0: file, length: 1, item: () => file } as unknown as FileList; + const file = new File(['not json {{'], 'bad.json', { + type: 'application/json', + }); + const fakeFileList = { + 0: file, + length: 1, + item: () => file, + } as unknown as FileList; mockInput.files = fakeFileList; const promise = importSettingsFromJson(base); @@ -314,7 +335,11 @@ describe('importSettingsFromJson', () => { const file = new File([JSON.stringify(payload)], 'settings.json', { type: 'application/json', }); - const fakeFileList = { 0: file, length: 1, item: () => file } as unknown as FileList; + const fakeFileList = { + 0: file, + length: 1, + item: () => file, + } as unknown as FileList; mockInput.files = fakeFileList; const promise = importSettingsFromJson(base);