Files
siftlode/frontend/src/lib/storage.ts
T
peter 9538fb1b7a fix(views): apply the starred default view on first resolve, for feed AND plex (S2)
The feed applied its starred default via a localStorage mirror
(LS.defaultViewFilters) written by SavedViewsProvider and read by
FeedFiltersProvider on load. That raced the login effect: on a genuinely
fresh browser (no LS.filters and no mirror yet) the feed could seed
DEFAULT_FILTERS and never re-apply the default until a reload. Plex had no
default-application at all.

Replace the mirror with a single path: SavedViewsProvider applies the
starred default via cfg.setFilters on the views query's FIRST resolve, but
only into a genuinely fresh module (no stored filters for the account, and
— feed only — no URL filter override). Applying persists it, so it happens
once and the last-view-wins path takes over, matching the documented
storage semantics. Because it runs off cfg.setFilters it delivers the Plex
port for free. Extract pickDefaultToSeed (pure, unit-tested) and cover the
first-resolve/stored/no-default cases for both modules in a provider test.

Removes the now-dead LS.defaultViewFilters key and its mirror read.
2026-08-23 23:05:24 +02:00

196 lines
8.6 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, useState } from "react";
import { activeAccountId } from "./activeAccount";
import { useAccountRehydrate } from "./useAccountRehydrate";
// 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",
hints: "siftlode.hints",
lang: "siftlode.lang",
filters: "siftlode.filters",
page: "siftlode.page",
perfMode: "siftlode.perfMode",
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
// established tab paints your view straight away. A brand-new tab still starts on the default
// until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so
// the per-account key can't be read.
feedView: "siftlode.feedView",
// 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",
// 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",
// The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account.
channelLayout: "siftlode.channelLayout",
// The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab).
channelSort: "siftlode.channelSort",
channelDiscoverySort: "siftlode.channelDiscoverySort",
// 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",
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
settingsTab: "siftlode.settingsTab",
configTab: "siftlode.configTab",
auditTable: "siftlode.auditTable",
statsTab: "siftlode.statsTab",
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",
// 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",
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
} as const;
// --- per-account scoping -----------------------------------------------------------------
// The active-account primitive (activeAccountId / useActiveAccountId / notify) lives in ./activeAccount
// so it can be shared without a cycle; the helpers below key localStorage on it.
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
* playlist, notifications, tab positions, cached prefs…) is NOT shared across the accounts signed
* into one browser — a bare shared key leaks one account's state into another (and, with per-tab
* accounts, two tabs would fight over it). Defaults to the current tab's active account; returns
* null when no account is known yet, so callers fall back to defaults / skip persistence instead
* of touching another account's data. */
export function accountKey(
base: string,
id: number | null | undefined = activeAccountId(),
): string | null {
return id != null ? `${base}.${id}` : null;
}
/** Per-account variants of the read/write helpers: key by the current account, no-op / return the
* fallback when the account isn't known (avoids reading or writing a shared/other-account key). */
export function readAccount<T>(base: string, fallback: T): T {
const k = accountKey(base);
return k ? readJSON(k, fallback) : fallback;
}
export function readAccountMerged<T extends object>(base: string, defaults: T): T {
const k = accountKey(base);
return k ? readMerged(k, defaults) : { ...defaults };
}
export function writeAccount(base: string, value: unknown): void {
const k = accountKey(base);
if (k) writeJSON(k, value);
}
export function getAccountRaw(base: string): string | null {
const k = accountKey(base);
return k ? localStorage.getItem(k) : null;
}
export function setAccountRaw(base: string, value: string): void {
const k = accountKey(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 localStorage, scoped to the current account (see
* accountKey), so one account's persisted tab/view position doesn't carry over to another in the
* same browser. 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. Consumers
* usually mount only once signed in (account known), but re-read on the account-pin transition too so
* one that mounts before the pin (a fresh tab's first paint) still restores. */
export function useAccountPersistedState(
base: string,
fallback = "",
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
const markDirty = useAccountRehydrate(() => setValue(getAccountRaw(base) ?? fallback));
// Stable setter (base + markDirty are both 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) => {
markDirty();
setValue(next);
setAccountRaw(base, next);
},
[base, markDirty],
);
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 markDirty = useAccountRehydrate(() => setValue(readAccountMerged(base, defaults)));
const patch = useCallback(
(p: Partial<T>) => {
markDirty();
setValue((prev) => {
const next = { ...prev, ...p };
writeAccount(base, next);
return next;
});
},
[base, markDirty],
);
return [value, patch];
}