172 lines
7.8 KiB
TypeScript
172 lines
7.8 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 { msg } from "./notifications";
|
|
|
|
// 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, msg("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];
|
|
}
|