refactor(frontend): every stored key declares its kind, and preferences follow the account

storage.ts's registry gives each key a kind (pref, device, view, cache,
public); a key without one does not compile. A contract test refuses a key
spelled out elsewhere, an account preference read or written straight from
storage instead of through usePref, and an e2e seed of a key nobody reads.
The keys the next account's wipe keeps are the public ones.

The seven preferences kept only in the browser move onto usePref: start on
Home, the onboarding dismissal, the release banner's last version, the
channel manager's layout, the Plex playlist layout, the playlist rail's sort
and channel discovery's sort. usePref reads an older bare-string copy, and a
value only this browser held is saved to the server once, so the account's
other devices get it.

Performance mode is the device's: it is neither read from the account nor
saved to it.
This commit is contained in:
2026-10-20 23:04:48 +02:00
parent d6dada7fe5
commit f21cdfa156
29 changed files with 749 additions and 220 deletions
+94 -62
View File
@@ -1,100 +1,129 @@
import { useCallback, useState } from "react";
// Central registry of every localStorage key the app uses. One place to see them all, rename
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
// files. Parametric (per-user) keys are functions. All keys are namespaced `siftlode.*`.
export const LS = {
theme: "siftlode.theme",
sidebarLayout: "siftlode.sidebarLayout",
plexLayout: "siftlode.plexLayout",
playlistsLayout: "siftlode.playlistsLayout",
playlistsCollapsed: "siftlode.playlistsCollapsed",
filesLayout: "siftlode.filesLayout",
filesCollapsed: "siftlode.filesCollapsed",
// 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, and a contract test holds the rule per kind
// (storageKinds.contract.test.ts).
// 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" },
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "device" },
plexLayout: { key: "siftlode.plexLayout", kind: "device" },
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "device" },
playlistsCollapsed: { key: "siftlode.playlistsCollapsed", kind: "pref" },
filesLayout: { key: "siftlode.filesLayout", kind: "device" },
filesCollapsed: { key: "siftlode.filesCollapsed", kind: "pref" },
// The Files view last open (every file, shared with me, or a folder id), per-account.
filesView: "siftlode.filesView",
filesView: { key: "siftlode.filesView", kind: "view" },
// The Files table's rows-per-page and page (DataTable's persist blob), per-account.
filesTable: "siftlode.filesTable",
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: "siftlode.filesLayouts",
filesSort: "siftlode.filesSort",
filesCardSize: "siftlode.filesCardSize",
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: "siftlode.downloadTray",
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: "siftlode.downloadTrayClosed",
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: "siftlode.downloadProfile",
hints: "siftlode.hints",
lang: "siftlode.lang",
filters: "siftlode.filters",
page: "siftlode.page",
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: "siftlode.startOnHome",
perfMode: "siftlode.perfMode",
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: "siftlode.feedView",
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: "siftlode.channelsFilterMap",
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: "siftlode.channelFilter",
channelsView: "siftlode.channelsView",
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: "siftlode.channelLayout",
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: "siftlode.channelSort",
channelShow: "siftlode.channelShow",
channelDiscoverySort: "siftlode.channelDiscoverySort",
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: "siftlode.channelCardSize",
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
channelsTable: "siftlode.channelsTable",
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: "siftlode.channelNames",
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
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: "siftlode.settingsTab",
configTab: "siftlode.configTab",
adminUsersTab: "siftlode.adminUsersTab",
filterCollapsed: "siftlode.filterCollapsed",
playlist: "siftlode.playlist",
plSort: "siftlode.plSort",
plexLibrary: "siftlode.plexLibrary",
plexShow: "siftlode.plexShow",
plexSort: "siftlode.plexSort",
plexFilters: "siftlode.plexFilters",
plexQ: "siftlode.plexQ",
plexField: "siftlode.plexField",
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
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: "siftlode.ytPlayerPrefs",
notifHistory: "siftlode.notifications",
notifSettings: "siftlode.notifSettings",
onboardingDismissed: "siftlode.onboarding.dismissed",
seenVersion: "siftlode.seenVersion",
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: "siftlode.watchRepeat",
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: "siftlode.watchResume",
} as const;
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.
onboardingActive: { key: "siftlode.onboarding.active", kind: "view" },
debugSteps: { key: "siftlode.debugSteps", kind: "view" },
logTab: { key: "siftlode.logTab", 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
@@ -105,12 +134,15 @@ export const LS = {
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([LS.watchRepeat, LS.watchResume, OWNER_KEY]);
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(Object.values(LS));
const REGISTERED: ReadonlySet<string> = new Set(KIND_OF.keys());
const ACCOUNT_COPY = /^(.+)\.(\d+)$/;
let claimed: string | null = null;