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.
172 lines
7.7 KiB
TypeScript
172 lines
7.7 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
|
import { qk } from "./queryKeys";
|
|
import { api } from "./api";
|
|
import type { Me } from "./api/account";
|
|
import { getAccountRaw, writeAccount } from "./storage";
|
|
import { notifyActionError } from "./actionErrors";
|
|
import { updateMeCache } from "./useMe";
|
|
import i18n from "../i18n";
|
|
|
|
// The local state + cache already show the new value, so a refused save is otherwise invisible until
|
|
// the next load quietly puts the old one back. `req` reports none of 403/404/429 and only the
|
|
// connection notice for a lost connection — say it.
|
|
const reportPrefSaveFailure = (err: unknown) =>
|
|
notifyActionError(err, i18n.t("errors.writeFailed.prefs"));
|
|
|
|
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
|
|
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) and follows the
|
|
// account across devices. usePref folds what every such pref used to hand-roll — seed local state, and
|
|
// on change write the server + an optional local mirror + the `me` cache — into one hook, so a new
|
|
// server-synced pref is a one-liner.
|
|
//
|
|
// It seeds straight from the `me` cache, which is always populated when it mounts: BootGate mounts the
|
|
// app tree only once /api/me has answered, and a tab whose account changes reloads, so there is nothing
|
|
// to adopt after mount. The source of truth is the server; the `me` cache is kept in sync optimistically so other
|
|
// consumers reading the same key (e.g. a second player instance) see the change at once.
|
|
|
|
export interface UsePrefOptions<T> {
|
|
/** Also mirror to the account's localStorage under this base key; that copy seeds the value (and
|
|
* the server, once) when the server payload lacks the key. */
|
|
localKey?: string;
|
|
/** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The
|
|
* local state + cache still update instantly; only the network write is deferred. */
|
|
debounceMs?: number;
|
|
/** Normalize a raw stored value (from the server payload or the localStorage mirror) into T — e.g.
|
|
* mapping legacy values forward, or clamping. Defaults to identity. Must be idempotent. */
|
|
parse?: (raw: unknown) => T;
|
|
}
|
|
|
|
function readServerPref(me: Me | null | undefined, key: string): unknown {
|
|
return me?.preferences?.[key];
|
|
}
|
|
|
|
// The local copy as stored: JSON, or a bare string written before this hook held the key (a layout
|
|
// name, "1"), which is kept as that string for `parse` to map. Undefined when there is none.
|
|
function readLocalCopy(localKey: string): unknown {
|
|
const raw = getAccountRaw(localKey);
|
|
if (raw == null) return undefined;
|
|
try {
|
|
return JSON.parse(raw) as unknown;
|
|
} catch {
|
|
return raw;
|
|
}
|
|
}
|
|
|
|
/** Where a preference's value comes from: the server's, then the browser's own copy (a value it held
|
|
* before the server did, which `usePref` then saves to the server once), then the default. */
|
|
function resolvePref<T>(
|
|
me: Me | null | undefined,
|
|
serverKey: string,
|
|
defaultValue: T,
|
|
{ localKey, parse }: UsePrefOptions<T>,
|
|
): { value: T; fromLocal: boolean } {
|
|
const norm = (raw: unknown): T => (parse ? parse(raw) : (raw as T));
|
|
const server = readServerPref(me, serverKey);
|
|
if (server !== undefined) return { value: norm(server), fromLocal: false };
|
|
const local = localKey ? readLocalCopy(localKey) : undefined;
|
|
if (local !== undefined) return { value: norm(local), fromLocal: true };
|
|
return { value: defaultValue, fromLocal: false };
|
|
}
|
|
|
|
/** A preference's value outside a component (a landing page picked before the first render, a nudge
|
|
* decided from the account): the same order `usePref` seeds from. */
|
|
export function prefValue<T>(
|
|
me: Me | null | undefined,
|
|
serverKey: string,
|
|
defaultValue: T,
|
|
options: UsePrefOptions<T> = {},
|
|
): T {
|
|
return resolvePref(me, serverKey, defaultValue, options).value;
|
|
}
|
|
|
|
// Optimistically merge a preferences patch into the `me` cache so readers see it without a refetch —
|
|
// the shape mirrors the server's PUT /api/me/preferences merge. Shared by useSavePref and usePref.set
|
|
// (their sole difference is WHEN the server write fires — immediately vs debounced), so the cache-merge
|
|
// shape lives in ONE place.
|
|
function mergePrefsCache(qc: QueryClient, patch: Record<string, unknown>): void {
|
|
updateMeCache(qc, (m) => ({ ...m, preferences: { ...m.preferences, ...patch } }));
|
|
}
|
|
|
|
/** The raw preference persister: save an arbitrary partial preferences patch to the server and merge
|
|
* it into the `me` cache optimistically (so readers see it without a refetch). For dynamic or
|
|
* multi-key prefs that don't map to a single stateful usePref call — e.g. a variable set of toggles
|
|
* (PlexInfo's per-collection strip-source hiding). Prefer usePref for a fixed, stateful key. */
|
|
export function useSavePref(): (patch: Record<string, unknown>) => void {
|
|
const qc = useQueryClient();
|
|
return useCallback(
|
|
(patch: Record<string, unknown>) => {
|
|
api.savePrefs(patch).catch(reportPrefSaveFailure);
|
|
mergePrefsCache(qc, patch);
|
|
},
|
|
[qc],
|
|
);
|
|
}
|
|
|
|
/** A per-account preference persisted to the server (and optionally mirrored to localStorage).
|
|
* Returns `[value, setValue]`; the setter is stable. */
|
|
export function usePref<T>(
|
|
serverKey: string,
|
|
defaultValue: T,
|
|
options: UsePrefOptions<T> = {},
|
|
): [T, (next: T) => void] {
|
|
const { localKey, debounceMs } = options;
|
|
const qc = useQueryClient();
|
|
|
|
// Read once, at mount (`parse` is read here only: the setter stores what it is given). Server
|
|
// preference wins, then this browser's copy, then the default.
|
|
const [seed] = useState(() =>
|
|
resolvePref(qc.getQueryData<Me | null>(qk.me()), serverKey, defaultValue, options),
|
|
);
|
|
const [value, setValue] = useState<T>(seed.value);
|
|
// A value only this browser held (the server has never stored the key) is saved to the server once,
|
|
// so the account's other devices get it too; from then on the server's wins everywhere.
|
|
useEffect(() => {
|
|
if (!seed.fromLocal) return;
|
|
const payload = { [serverKey]: seed.value };
|
|
mergePrefsCache(qc, payload);
|
|
api.savePrefs(payload).catch(reportPrefSaveFailure);
|
|
}, [seed, serverKey, qc]);
|
|
|
|
const saveTimer = useRef<number | undefined>(undefined);
|
|
// The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change
|
|
// isn't lost. Dropping it would leave the server stale while local/cache/localStorage hold the new
|
|
// value, and the next load — which seeds from the server first — would bring the old value back.
|
|
const pendingSave = useRef<(() => void) | null>(null);
|
|
useEffect(
|
|
() => () => {
|
|
window.clearTimeout(saveTimer.current);
|
|
pendingSave.current?.();
|
|
pendingSave.current = null;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const set = useCallback(
|
|
(next: T) => {
|
|
setValue(next);
|
|
if (localKey) writeAccount(localKey, next);
|
|
// Keep the `me` cache in step so other readers of this key (and a later mount of this hook,
|
|
// which seeds from the cache) see the new value without a refetch. Merges into preferences, mirroring the server PUT.
|
|
const payload = { [serverKey]: next };
|
|
mergePrefsCache(qc, payload);
|
|
// savePrefs is fire-and-forget (idempotent PUT, so `req` retries a transient blip once); a
|
|
// failure that survives that is reported, not swallowed.
|
|
const flush = () => api.savePrefs(payload).catch(reportPrefSaveFailure);
|
|
if (debounceMs) {
|
|
window.clearTimeout(saveTimer.current);
|
|
pendingSave.current = flush;
|
|
saveTimer.current = window.setTimeout(() => {
|
|
pendingSave.current = null;
|
|
void flush();
|
|
}, debounceMs);
|
|
} else {
|
|
void flush();
|
|
}
|
|
},
|
|
[qc, serverKey, localKey, debounceMs],
|
|
);
|
|
|
|
return [value, set];
|
|
}
|