// Generic localStorage-backed setting — the shared plumbing a theme // override and a reduced-motion override both need ("read a stored // value, react to it changing, write a new one") rather than each // caller reinventing its own key handling. Not settings-page-specific: // any client-local preference reaches for `useLocalSetting` directly. // Originated in swarm-ui's own `lib/settings-storage.ts`; moved here // once the per-agent page needed the identical mechanism (mara: "i // think the component should be shared" — see `SettingsMenu.tsx`'s // file comment for the full story). // // `localStorage`'s own `storage` event only fires in *other* tabs, never // the tab that made the write — so two components in the same tab both // watching the same key (e.g. `SettingsMenu` writing, a top-level effect // reading) need their own same-tab signal. The tiny module-level pub/sub // below is that signal; it's deliberately not exported, callers only see // the hook. import { useEffect, useState } from "preact/hooks"; const listeners = new Map void>>(); function subscribe(key: string, onChange: () => void): () => void { let set = listeners.get(key); if (!set) { set = new Set(); listeners.set(key, set); } set.add(onChange); return () => { set!.delete(onChange); if (set!.size === 0) listeners.delete(key); }; } function notify(key: string): void { listeners.get(key)?.forEach((fn) => fn()); } // Reads outside a component (e.g. an early top-level check) can call // this directly; `useLocalSetting` builds on it rather than duplicating // the try/catch. export function readLocalSetting(key: string, fallback: T): T { try { const raw = localStorage.getItem(key); return raw === null ? fallback : (JSON.parse(raw) as T); } catch { // Private-browsing storage bans, a corrupted value, quota errors — // all the same answer: behave as if nothing were stored. return fallback; } } function writeLocalSetting(key: string, value: T): void { try { localStorage.setItem(key, JSON.stringify(value)); } catch { // Best-effort: the setting just won't persist this time, not worth // surfacing an error for a client-local preference. } notify(key); } // `[value, setValue]`, same shape as `useState` — deliberately, so a // caller that later needs to swap a plain `useState` for a persisted // setting (or vice versa) changes one line, not its whole call site. // // `setValue` only writes + notifies; it doesn't also call this // component's own `setValue` state setter directly. The subscription // below already fires for a same-instance write (it's in the same // `listeners` set as every other subscriber), so a self-write reaches // this component's state through the identical "react to a change" // path every other subscriber uses — one path, not two that have to // agree. export function useLocalSetting( key: string, fallback: T, ): [T, (value: T) => void] { const [value, setValue] = useState(() => readLocalSetting(key, fallback)); useEffect( () => subscribe(key, () => setValue(readLocalSetting(key, fallback))), [key], ); return [value, (next: T) => writeLocalSetting(key, next)]; }