import { useCallback, useEffect, useState } from "react"; // Central registry of every key the app keeps in the browser (localStorage, and a tab's // sessionStorage). One place to see them all, rename safely, and avoid collisions — instead of // `"siftlode.x"` string literals scattered across files. All keys are namespaced `siftlode.*`, and each // declares its kind: what it is decides where it belongs. storageKinds.contract.test.ts holds what a // reading of the source can: no key is spelled out (or handed to the Storage API as a literal) // anywhere else, a `pref` is read and written only through usePref or the one store listed as // keeping it, and usePref is handed a `pref` and nothing else. That a kind fits what the code does // with its key is the reviewer's to check. // pref a deliberate choice about the account (theme, a list's layout): it lives on the server and // follows the account to every device — usePref, whose local copy only carries a value // this browser held before the server did. // device right for this screen, speaker or GPU and wrong on another (a panel width, the volume, // performance mode): this browser only. // view where the reader is (page, filters, a selected list, a sub-tab): this browser only. // cache a copy of server data for a fast first paint, rebuilt freely. // public the public /watch page's, which has no account: kept whoever signs in. export type StorageKind = "pref" | "device" | "view" | "cache" | "public"; const KEYS = { theme: { key: "siftlode.theme", kind: "pref" }, // A side panel's group cards: their order, collapsed and hidden. Synced by lib/panelLayout.ts. sidebarLayout: { key: "siftlode.sidebarLayout", kind: "pref" }, plexLayout: { key: "siftlode.plexLayout", kind: "pref" }, playlistsLayout: { key: "siftlode.playlistsLayout", kind: "pref" }, playlistsCollapsed: { key: "siftlode.playlistsCollapsed", kind: "pref" }, filesLayout: { key: "siftlode.filesLayout", kind: "pref" }, filesCollapsed: { key: "siftlode.filesCollapsed", kind: "pref" }, // The Files view last open (every file, shared with me, or a folder id), per-account. filesView: { key: "siftlode.filesView", kind: "view" }, // The Files table's rows-per-page and page (DataTable's persist blob), per-account. filesTable: { key: "siftlode.filesTable", kind: "device" }, // List or grid per Files view (a `{ viewParam: layout }` map), the sort both paints share, and the // grid's cards per page — per-account mirrors of the server prefs (the last is local only). filesLayouts: { key: "siftlode.filesLayouts", kind: "pref" }, filesSort: { key: "siftlode.filesSort", kind: "pref" }, filesCardSize: { key: "siftlode.filesCardSize", kind: "device" }, // The download tray open or folded to its pill — a per-account mirror of the server pref. downloadTray: { key: "siftlode.downloadTray", kind: "pref" }, // The queue the reader closed the tray on (job ids, a failure marked) — it stays shut until that // changes. Per-account mirror of the server pref. downloadTrayClosed: { key: "siftlode.downloadTrayClosed", kind: "pref" }, // The download format used last — the download dialog opens on it. Per-account mirror of the // server pref. downloadProfile: { key: "siftlode.downloadProfile", kind: "pref" }, hints: { key: "siftlode.hints", kind: "pref" }, lang: { key: "siftlode.lang", kind: "pref" }, filters: { key: "siftlode.filters", kind: "view" }, page: { key: "siftlode.page", kind: "view" }, // Start every session on the Home surface rather than on the module you happened to leave // open. Per-account, "0" = opt out and go back to remembering the last module. Read by // `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the account // is claimed (claimLocalData) — so the landing is decided right before the first paint, never // corrected after. startOnHome: { key: "siftlode.startOnHome", kind: "pref" }, perfMode: { key: "siftlode.perfMode", kind: "device" }, // The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds // from only when the server has never stored the key. feedView: { key: "siftlode.feedView", kind: "pref" }, // The subscribed table's column filters (a FilterMap), lifted out of the table so the top status // chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes // the old single-string `channelFilter` sync chip. channelsFilterMap: { key: "siftlode.channelsFilterMap", kind: "view" }, // DEPRECATED — the retired single-value sync chip; read once to migrate a pinned value into // `channelsFilterMap`, then inert. Do not write. legacyChannelFilter: { key: "siftlode.channelFilter", kind: "view" }, channelsView: { key: "siftlode.channelsView", kind: "view" }, // The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account. channelLayout: { key: "siftlode.channelLayout", kind: "pref" }, // The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab). channelSort: { key: "siftlode.channelSort", kind: "view" }, channelShow: { key: "siftlode.channelShow", kind: "pref" }, channelDiscoverySort: { key: "siftlode.channelDiscoverySort", kind: "pref" }, // Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob). channelCardSize: { key: "siftlode.channelCardSize", kind: "device" }, channelDiscoveryCardSize: { key: "siftlode.channelDiscoveryCardSize", kind: "device" }, channelsTable: { key: "siftlode.channelsTable", kind: "device" }, // Channel id → name as last seen (useChannelNames): names a channel filter's chips after a reload, // before the subscription list has landed. A cache, rewritten from the list on every load. channelNames: { key: "siftlode.channelNames", kind: "cache" }, channelDiscoveryTable: { key: "siftlode.channelDiscoveryTable", kind: "device" }, // A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab // below. settingsTab: { key: "siftlode.settingsTab", kind: "view" }, configTab: { key: "siftlode.configTab", kind: "view" }, adminUsersTab: { key: "siftlode.adminUsersTab", kind: "view" }, filterCollapsed: { key: "siftlode.filterCollapsed", kind: "pref" }, playlist: { key: "siftlode.playlist", kind: "view" }, plSort: { key: "siftlode.plSort", kind: "pref" }, plexLibrary: { key: "siftlode.plexLibrary", kind: "view" }, plexShow: { key: "siftlode.plexShow", kind: "view" }, plexSort: { key: "siftlode.plexSort", kind: "view" }, plexFilters: { key: "siftlode.plexFilters", kind: "view" }, plexQ: { key: "siftlode.plexQ", kind: "view" }, plexField: { key: "siftlode.plexField", kind: "view" }, plexPlaylistLayout: { key: "siftlode.plexPlaylistLayout", kind: "pref" }, plexPlayerPrefs: { key: "siftlode.plexPlayerPrefs", kind: "device" }, // The YouTube modal player's volume (`{ volume: 0–100 }`; 0 IS muted — there is no separate // flag). A YT.Player instance is created per video and starts at full volume, so without this // every next video is loud again (same idea as plexPlayerPrefs). ytPlayerPrefs: { key: "siftlode.ytPlayerPrefs", kind: "device" }, notifHistory: { key: "siftlode.notifications", kind: "cache" }, notifSettings: { key: "siftlode.notifSettings", kind: "pref" }, onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" }, seenVersion: { key: "siftlode.seenVersion", kind: "pref" }, // The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account: // that page runs outside the app, with no account at all. watchRepeat: { key: "siftlode.watchRepeat", kind: "public" }, // Where this browser stopped in each shared /watch link (`lib/resume.ts`: token -> {at, t}). Same // reason as above: the viewer has no account to keep it in. watchResume: { key: "siftlode.watchResume", kind: "public" }, // A tab's own state, kept in sessionStorage outside the hooks above: the onboarding flow in // progress across Google's consent redirect, the debug recorder's steps, the log's tab id, and // when the tab last reloaded onto a newer build (`lib/staleBuild.ts`). onboardingActive: { key: "siftlode.onboarding.active", kind: "view" }, debugSteps: { key: "siftlode.debugSteps", kind: "view" }, logTab: { key: "siftlode.logTab", kind: "device" }, staleBuildReload: { key: "siftlode.staleBuildReload", kind: "device" }, } as const satisfies Record; /** Every key the app stores, by name. */ export const LS = Object.fromEntries( Object.entries(KEYS).map(([name, { key }]) => [name, key]), ) as { readonly [N in keyof typeof KEYS]: (typeof KEYS)[N]["key"]; }; /** Each stored key's kind (see StorageKind), for the rules that hold per kind. */ export const KIND_OF: ReadonlyMap = new Map( Object.values(KEYS).map(({ key, kind }) => [key, kind]), ); // --- the signed-in account's local state -------------------------------------------------- // A browser session holds ONE account, so its local state needs no account id in the key: the browser // records whose state it holds instead (OWNER_KEY), and an account that is not the recorded one starts // from a clean slate. The account is known only once BootGate has it from the server, and `claimLocalData` // is how BootGate hands it over; until then the helpers below read nothing and write nothing, so the // signed-out landing, or a boot still asking, cannot touch the state of whoever signs in next. const OWNER_KEY = "siftlode.owner"; // Kept whoever signs in: they belong to the browser, not to an account (the public /watch page has none). const BROWSER_KEYS: ReadonlySet = new Set([ OWNER_KEY, ...[...KIND_OF].flatMap(([key, kind]) => (kind === "public" ? [key] : [])), ]); // The multi-account build pinned each tab to an account (sessionStorage) and suffixed every key with // its id (`siftlode.filters.5`). A claim moves the account's own copies onto the plain keys and drops // the other accounts' copies; only keys the registry knows are touched. const LEGACY_PIN_KEYS = ["siftlode.activeAccount", "siftlode.nextAccount"]; const REGISTERED: ReadonlySet = new Set(KIND_OF.keys()); const ACCOUNT_COPY = /^(.+)\.(\d+)$/; let claimed: string | null = null; const claimListeners = new Set<() => void>(); /** Re-read module-level state read at import (the notification and hints stores), which runs before * BootGate knows the account. */ export function onLocalDataClaimed(reread: () => void): void { claimListeners.add(reread); } function each(store: Storage, visit: (key: string) => void): void { for (const key of Object.keys(store)) visit(key); } function settleStore(store: Storage, id: string, wipe: boolean): void { each(store, (key) => { if (!key.startsWith("siftlode.")) return; if (wipe && !BROWSER_KEYS.has(key)) { store.removeItem(key); return; } const [, base, owner] = ACCOUNT_COPY.exec(key) ?? []; if (!base || !REGISTERED.has(base)) return; const value = store.getItem(key); if (owner === id && value != null) store.setItem(base, value); store.removeItem(key); }); } /** Hand the browser's local state to the signed-in account `id` (BootGate, before the app mounts). * Another account's state is wiped first; the old per-account copies are moved onto the plain keys. */ export function claimLocalData(id: number): void { const me = String(id); try { // Each store records whose it is: a tab's sessionStorage is its own, so a tab reloading for the // account another tab already claimed the browser for still holds the previous account's. A // tab that records no owner (one a build before this opened) goes by the browser's. const owner = localStorage.getItem(OWNER_KEY); const tabOwner = sessionStorage.getItem(OWNER_KEY) ?? owner; settleStore(localStorage, me, owner != null && owner !== me); settleStore(sessionStorage, me, tabOwner != null && tabOwner !== me); for (const key of LEGACY_PIN_KEYS) sessionStorage.removeItem(key); localStorage.setItem(OWNER_KEY, me); sessionStorage.setItem(OWNER_KEY, me); } catch { /* storage unavailable: nothing to settle */ } claimed = me; claimListeners.forEach((reread) => reread()); } /** The account this tab was built for (claimed at boot), as its id; null before the claim. Every * request says so (`identityHeaders`): the server refuses one from a tab whose browser has since * signed in as someone else, and the tab reloads as whoever is signed in now. */ export function claimedAccount(): string | null { return claimed; } /** `base` while this tab's account owns the browser's local state, else null: before the claim, and * after another tab has claimed it for a different account (a sign-in there replaces this one). */ export function ownKey(base: string): string | null { if (claimed == null) return null; try { return localStorage.getItem(OWNER_KEY) === claimed ? base : null; } catch { return null; } } /** The account's read/write helpers: no-op / the fallback while the state is not this tab's (see ownKey). */ export function readAccount(base: string, fallback: T): T { const k = ownKey(base); return k ? readJSON(k, fallback) : fallback; } export function readAccountMerged(base: string, defaults: T): T { const k = ownKey(base); return k ? readMerged(k, defaults) : { ...defaults }; } export function writeAccount(base: string, value: unknown): void { const k = ownKey(base); if (k) writeJSON(k, value); } export function getAccountRaw(base: string): string | null { const k = ownKey(base); try { return k ? localStorage.getItem(k) : null; } catch { return null; } } export function setAccountRaw(base: string, value: string): void { const k = ownKey(base); if (k) { try { localStorage.setItem(k, value); } catch { /* ignore */ } } } // --- typed read/write helpers (do the JSON + try/catch once) ---------------------------- /** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated), * falling back to `defaults` on missing or corrupt data. Module-private: its only caller is * `readAccountMerged` above (exporting it makes knip flag it as an unused export). */ function readMerged(key: string, defaults: T): T { try { return { ...defaults, ...JSON.parse(localStorage.getItem(key) || "{}") }; } catch { return { ...defaults }; } } /** Read any JSON value (arrays/scalars), falling back on missing or corrupt data. */ export function readJSON(key: string, fallback: T): T { try { const raw = localStorage.getItem(key); return raw == null ? fallback : (JSON.parse(raw) as T); } catch { return fallback; } } /** Persist a value as JSON, swallowing quota/serialization errors. */ export function writeJSON(key: string, value: unknown): void { try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* ignore quota / serialization errors */ } } // --- reactive persisted string state ---------------------------------------------------- /** A `useState` whose string value is mirrored to the account's localStorage (see ownKey). Survives reload (F5); validation is deferred to the caller (clamp at render) so it * can be used before an async-loaded option list is known, keeping hook order stable. Read once, at * mount: BootGate mounts every consumer after the account is claimed. */ export function useAccountPersistedState( base: string, fallback = "", ): [string, (value: string) => void] { const [value, setValue] = useState(() => getAccountRaw(base) ?? fallback); // Stable setter (base is stable) so a provider can build a stable action on top of it — e.g. // ChannelsProvider's setView, which must not change identity on every tab switch. const set = useCallback( (next: string) => { setValue(next); setAccountRaw(base, next); }, [base], ); return [value, set]; } // --- a page's sub-tab, per browser tab --------------------------------------------------------- // Which sub-tab a page was left on (Settings, Configuration, Users) is kept for THIS browser tab only — // sessionStorage, the account's (see ownKey): it survives a reload and a trip to another page, and a new // tab opens the page on its first sub-tab (the owner's call: a position remembered forever in // localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home). The // address names the tab too (the page mirrors it): a link or a bookmark that carries one wins. /** The sub-tab stored for this browser tab, or null. */ function getTabRaw(base: string): string | null { const k = ownKey(base); try { return k ? sessionStorage.getItem(k) : null; } catch { return null; } } function writeTabRaw(base: string, value: string): void { const k = ownKey(base); if (!k) return; try { sessionStorage.setItem(k, value); } catch { /* ignore */ } } // A pre-selection made while the page is already on screen (a notice's link opened over Settings) has // no mount to be read at, and the navigation to the same page is a no-op: the page's hook hears it. const TAB_SELECT_EVENT = "siftlode:tab-select"; type TabSelect = { base: string; value: string }; /** Pre-select a page's sub-tab before navigating there (a "Review" link, the Google-link return). */ export function setTabRaw(base: string, value: string): void { writeTabRaw(base, value); window.dispatchEvent(new CustomEvent(TAB_SELECT_EVENT, { detail: { base, value } })); } /** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). `urlKey`: the * address-bar key the page mirrors its tab to — a tab it names is the one the page opens on. */ export function useTabPersistedState( base: string, fallback = "", urlKey?: string, ): [string, (value: string) => void] { const [value, setValue] = useState(() => { // The position used to live in localStorage; a value left there would never be read again. const old = ownKey(base); if (old) { try { localStorage.removeItem(old); } catch { /* ignore */ } } const linked = urlKey ? new URLSearchParams(window.location.search).get(urlKey) : null; if (linked) { // Kept for this browser tab too, so a later trip back to the page (its address cleared by the // page switch) still opens it here. writeTabRaw(base, linked); return linked; } return getTabRaw(base) ?? fallback; }); useEffect(() => { function onSelect(e: Event) { const { detail } = e as CustomEvent; if (detail.base === base) setValue(detail.value); } window.addEventListener(TAB_SELECT_EVENT, onSelect); return () => window.removeEventListener(TAB_SELECT_EVENT, onSelect); }, [base]); const set = useCallback( (next: string) => { setValue(next); writeTabRaw(base, next); }, [base], ); return [value, set]; } /** A per-account persisted JSON OBJECT: `patch(partial)` merges + saves, unknown/missing keys fall * back to `defaults` (via readAccountMerged) so adding a field later is safe. Used for bundles of * related per-account settings (e.g. the Plex player prefs: volume, offsets, seek steps, sub style). */ export function useAccountPersistedObject( base: string, defaults: T, ): [T, (patch: Partial) => void] { const [value, setValue] = useState(() => readAccountMerged(base, defaults)); const patch = useCallback( (p: Partial) => { setValue((prev) => { const next = { ...prev, ...p }; writeAccount(base, next); return next; }); }, [base], ); return [value, patch]; }