/** * @file userPrefs.ts * @description An account's UI preferences, kept per ACCOUNT on the server * (`/api/me/prefs`, docs/plans/onboarding) while every component goes on * reading and writing localStorage as it always did. On sign-in `hydrate()` * copies the account's stored values into localStorage (the account wins), * sends up the ones this browser has and the account does not yet, and makes * the theme, language and pet re-read them. From then on a write to one of * `PREF_KEYS` through `localStorage.setItem`/`removeItem` is sent to the * server, batched. `useOnboarding()` tells the setup wizard whether this * account has been through it. Never throws: a server that cannot answer * leaves the browser's own values in charge. The same row also holds * `agenthub.chatUi`, which `lib/chatUi.ts` reads and writes on the server * directly (no localStorage copy). * @author Nguyễn Ngọc Trí Vĩ */ import { useSyncExternalStore } from "react"; import { api } from "./api"; import i18n from "../i18n"; import { applyStoredTheme } from "../hooks/useTheme"; import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize"; /** The localStorage keys an account's preferences are made of (server/routes/me.js). */ export const PREF_KEYS = [ "theme", "agenthub.theme.dark", "agenthub.theme.light", "i18nextLng", "agenthub.terminal.font", "agenthub.terminal.fontSize", "agenthub.ui.fontSize", "agenthub.pet", "agenthub.pet.size", "agenthub.pet.names", "agent-dashboard-sagi-enabled", "agent-dashboard-sagi-muted", "agent-monitor-notifications", "agenthub.defaultTool", "agenthub.permissionMode", "agenthub.codex.sandbox", "agenthub.codex.approval", "agenthub.codex.bypass", "agenthub.tour", "agenthub.workspace.allTabs", "agenthub.markdown.view", ] as const; const SYNCED = new Set(PREF_KEYS); const PUSH_DELAY_MS = 400; // `forced`: Settings asked for the setup again, whatever the account has. type State = { loaded: boolean; onboardedAt: string | null; forced: boolean }; let state: State = { loaded: false, onboardedAt: null, forced: false }; const listeners = new Set<() => void>(); const setState = (next: Partial) => { state = { ...state, ...next }; listeners.forEach((l) => l()); }; let pending: Record = {}; let timer: ReturnType | undefined; let syncing = false; function queue(key: string, value: string | null) { if (!syncing) return; pending[key] = value; clearTimeout(timer); timer = setTimeout(flush, PUSH_DELAY_MS); } /** Send what is queued now (also on page hide). */ export function flush(): Promise { clearTimeout(timer); const batch = pending; pending = {}; if (!Object.keys(batch).length) return Promise.resolve(); return api.me .savePrefs(batch) .then(() => undefined) .catch(() => { /* offline or signed out: the browser keeps its own copy */ }); } let wrapped = false; function wrapStorage() { if (wrapped || typeof Storage === "undefined") return; wrapped = true; const proto = Storage.prototype; const setItem = proto.setItem; const removeItem = proto.removeItem; proto.setItem = function (key: string, value: string) { setItem.call(this, key, value); if (this === window.localStorage && SYNCED.has(key)) queue(key, String(value)); }; proto.removeItem = function (key: string) { removeItem.call(this, key); if (this === window.localStorage && SYNCED.has(key)) queue(key, null); }; window.addEventListener("pagehide", () => void flush()); } function readLocal(key: string): string | null { try { return window.localStorage.getItem(key); } catch { return null; } } /** Make what reads these keys read them again. */ function reapply(prefs: Record) { // Painted now, not only on the next useTheme render: the login page wore the OS's. applyStoredTheme(); window.dispatchEvent(new Event("agenthub-theme")); window.dispatchEvent(new Event("sagi:prefs")); applyUiFontSize(); window.dispatchEvent(new Event(FONT_SIZE_EVENT)); const lng = prefs.i18nextLng; if (lng && lng !== i18n.language) void i18n.changeLanguage(lng); } /** * Load the signed-in account's preferences into this browser and start * sending changes. Call once a login (or local mode) is known. */ export async function hydrate(): Promise { syncing = false; pending = {}; let answer; try { answer = await api.me.prefs(); } catch { // No answer (an older server, a test with no API): no wizard, no sync — // the browser's own values stay in charge. return; } const up: Record = {}; for (const key of PREF_KEYS) { const theirs = answer.prefs[key]; if (typeof theirs === "string") { try { window.localStorage.setItem(key, theirs); } catch { /* storage blocked */ } } else { const mine = readLocal(key); if (mine !== null) up[key] = mine; } } wrapStorage(); syncing = true; reapply(answer.prefs); if (Object.keys(up).length) { pending = { ...up }; void flush(); } setState({ loaded: true, onboardedAt: answer.onboardedAt }); } /** The first-run setup is done or skipped, for this account. */ export async function markOnboarded(): Promise { await flush(); const at = new Date().toISOString(); setState({ onboardedAt: at, forced: false }); try { const r = await api.me.onboarded(); setState({ onboardedAt: r.onboardedAt || at }); } catch { /* shown again next time — better than never saving the choices */ } } /** Show the setup again (Settings → System). */ export function resetOnboarding(): void { setState({ onboardedAt: null, forced: true }); } const subscribe = (l: () => void) => { listeners.add(l); return () => listeners.delete(l); }; /** `{loaded, onboardedAt, forced}` for the account signed in now. */ export function useOnboarding(): State { return useSyncExternalStore(subscribe, () => state); } /** Tests only. */ export function __resetUserPrefs(): void { state = { loaded: false, onboardedAt: null, forced: false }; pending = {}; syncing = false; clearTimeout(timer); }