Files
siftlode/frontend/src/lib/storage.ts
T
peter d4f24de4ba feat(nav): tabs, list views and name searches are named in the address
Settings, Configuration and Users name their tab (stab/cfgtab/utab); a link's tab wins and a tab pre-selected
while its page is on screen now switches it. A playlist's sort, filters and grouping (plsort/plf/plgroup), the
Files sort (fsort, always written) and the name searches of Channels, Files, Playlists and Plex (cq/fq/plq/pq+pqf)
reopen from the address; the searches are written once the typing pauses.
2026-10-26 01:13:39 +01:00

411 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { key: `siftlode.${string}`; kind: StorageKind }>;
/** 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<string, StorageKind> = 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<string> = 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<string> = 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<T>(base: string, fallback: T): T {
const k = ownKey(base);
return k ? readJSON(k, fallback) : fallback;
}
export function readAccountMerged<T extends object>(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<T extends object>(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<T>(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<string>(() => 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<TabSelect>(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<string>(() => {
// 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<TabSelect>;
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<T extends object>(
base: string,
defaults: T,
): [T, (patch: Partial<T>) => void] {
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
const patch = useCallback(
(p: Partial<T>) => {
setValue((prev) => {
const next = { ...prev, ...p };
writeAccount(base, next);
return next;
});
},
[base],
);
return [value, patch];
}