Files
paseo/packages/app/src/hooks/use-settings/index.ts
T
Mohamed BoudraandGitHub 602860625c Give readable content its own text size (#3637)
* feat(app): separate content typography from interface size

Readable prose benefits from its own scale without inflating navigation and controls. Preserve existing users’ rendered size during migration while giving fresh desktop installs a 15px content default.

* test(app): cover independent content typography

The appearance journey now verifies that changing interface size leaves content size intact. Timeline traversal yields when a test deliberately holds the requested page, so the helper no longer waits on its own gate.

* test(desktop): cover independent content typography

The desktop appearance regression follows the renamed Interface control and verifies it does not alter Content size.
2026-08-21 12:35:23 +02:00

258 lines
7.5 KiB
TypeScript

import { useCallback, useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { queryClient as appQueryClient } from "@/data/query-client";
import type { AppLanguage } from "@/i18n/locales";
import {
DEFAULT_DESKTOP_SETTINGS,
loadDesktopSettings,
migrateLegacyDesktopSettings,
useDesktopSettings,
} from "@/desktop/settings/desktop-settings";
import { isElectronRuntime } from "@/desktop/host";
import {
APP_SETTINGS_KEY,
APP_SETTINGS_QUERY_KEY,
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_CONTENT_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_THEME_PREFERENCE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_CONTENT_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_BASE_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_CONTENT_FONT_SIZE,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_BASE_FONT_SIZE,
loadAppSettingsFromStorage as loadAppSettingsFromStoragePure,
loadSettingsFromStorage as loadSettingsFromStoragePure,
normalizeAppSettings,
parseClampedFontSize,
parseTerminalScrollbackLines,
sanitizeFontFamily,
saveAppSettings as saveAppSettingsPure,
type AppSettings,
type DesktopSettingsBridge,
type KeyValueStorage,
type ReleaseChannel,
type SendBehavior,
type ServiceUrlBehavior,
type Settings,
type SidebarWorkspaceTrailing,
type SettingsDeps,
type WorkspaceTitleSource,
} from "./storage";
export {
APP_SETTINGS_KEY,
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_CONTENT_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_THEME_PREFERENCE,
DEFAULT_UI_BASE_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_CONTENT_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_BASE_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_CONTENT_FONT_SIZE,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_BASE_FONT_SIZE,
parseClampedFontSize,
parseTerminalScrollbackLines,
sanitizeFontFamily,
};
export type {
AppSettings,
AppLanguage,
DesktopSettingsBridge,
KeyValueStorage,
ReleaseChannel,
SendBehavior,
ServiceUrlBehavior,
Settings,
SettingsDeps,
SidebarWorkspaceTrailing,
WorkspaceTitleSource,
};
/**
* Split a `Settings` patch into the part the app owns. The two halves persist to different
* places (AsyncStorage vs the Electron settings bridge), and the app's half is exactly the
* key set of `DEFAULT_CLIENT_SETTINGS` — reading the keys off it means a new app setting
* flows through here without anyone remembering to widen a hand-written list.
*/
function pickDefinedAppSettings(updates: Partial<Settings>): Partial<AppSettings> {
const appUpdates: Partial<AppSettings> = {};
for (const key of Object.keys(DEFAULT_CLIENT_SETTINGS) as (keyof AppSettings)[]) {
const value = updates[key];
if (value !== undefined) {
Object.assign(appUpdates, { [key]: value });
}
}
return appUpdates;
}
const productionDeps: SettingsDeps = {
storage: AsyncStorage,
desktop: {
isElectron: isElectronRuntime,
loadDesktopSettings,
migrateLegacyDesktopSettings,
},
};
export interface UseAppSettingsReturn {
settings: AppSettings;
isLoading: boolean;
error: unknown;
updateSettings: (updates: Partial<AppSettings>) => Promise<void>;
resetSettings: () => Promise<void>;
}
export interface UseSettingsReturn {
settings: Settings;
isLoading: boolean;
error: unknown;
updateSettings: (updates: Partial<Settings>) => Promise<void>;
resetSettings: () => Promise<void>;
}
type SettingsSelector<TSelected> = (settings: Settings) => TSelected;
export function useAppSettings(): UseAppSettingsReturn {
const queryClient = useQueryClient();
const { data, isPending, error } = useQuery({
queryKey: APP_SETTINGS_QUERY_KEY,
queryFn: () => loadAppSettingsFromStorage(),
staleTime: Infinity,
gcTime: Infinity,
});
const updateSettings = useCallback(
async (updates: Partial<AppSettings>) => {
try {
await saveAppSettings({ queryClient, updates });
} catch (err) {
console.error("[AppSettings] Failed to save settings:", err);
throw err;
}
},
[queryClient],
);
const resetSettings = useCallback(async () => {
try {
const next = { ...DEFAULT_CLIENT_SETTINGS };
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
} catch (err) {
console.error("[AppSettings] Failed to reset settings:", err);
throw err;
}
}, [queryClient]);
const settings = useMemo(() => normalizeAppSettings(data), [data]);
return {
settings,
isLoading: isPending,
error: error ?? null,
updateSettings,
resetSettings,
};
}
export function useSettings(): UseSettingsReturn;
export function useSettings<TSelected>(selector: SettingsSelector<TSelected>): TSelected;
export function useSettings<TSelected>(
selector?: SettingsSelector<TSelected>,
): UseSettingsReturn | TSelected {
const appSettings = useAppSettings();
const desktopSettings = useDesktopSettings();
const updateSettings = useCallback(
async (updates: Partial<Settings>) => {
const appUpdates = pickDefinedAppSettings(updates);
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
}
if (isElectronRuntime()) {
const desktopUpdates: Parameters<typeof desktopSettings.updateSettings>[0] = {};
if (updates.manageBuiltInDaemon !== undefined) {
desktopUpdates.daemon = {
manageBuiltInDaemon: updates.manageBuiltInDaemon,
};
}
if (updates.releaseChannel !== undefined) {
desktopUpdates.releaseChannel = updates.releaseChannel;
}
if (Object.keys(desktopUpdates).length > 0) {
promises.push(desktopSettings.updateSettings(desktopUpdates));
}
}
await Promise.all(promises);
},
[appSettings, desktopSettings],
);
const resetSettings = useCallback(async () => {
const resets: Promise<void>[] = [appSettings.resetSettings()];
if (isElectronRuntime()) {
resets.push(desktopSettings.updateSettings(DEFAULT_DESKTOP_SETTINGS));
}
await Promise.all(resets);
}, [appSettings, desktopSettings]);
const settings = {
...DEFAULT_APP_SETTINGS,
...appSettings.settings,
manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon,
releaseChannel: desktopSettings.settings.releaseChannel,
};
if (selector) {
return selector(settings);
}
return {
settings,
isLoading: appSettings.isLoading || desktopSettings.isLoading,
error: appSettings.error ?? desktopSettings.error,
updateSettings,
resetSettings,
};
}
export async function persistAppSettings(updates: Partial<AppSettings>): Promise<void> {
await saveAppSettings({ queryClient: appQueryClient, updates });
}
export async function saveAppSettings(input: {
queryClient: QueryClient;
updates: Partial<AppSettings>;
deps?: SettingsDeps;
}): Promise<void> {
await saveAppSettingsPure({
queryClient: input.queryClient,
updates: input.updates,
deps: input.deps ?? productionDeps,
});
}
export async function loadAppSettingsFromStorage(deps?: SettingsDeps): Promise<AppSettings> {
return loadAppSettingsFromStoragePure(deps ?? productionDeps);
}
export async function loadSettingsFromStorage(deps?: SettingsDeps): Promise<Settings> {
return loadSettingsFromStoragePure(deps ?? productionDeps);
}