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 { /** 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( me: Me | null | undefined, serverKey: string, defaultValue: T, { localKey, parse }: UsePrefOptions, ): { 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( me: Me | null | undefined, serverKey: string, defaultValue: T, options: UsePrefOptions = {}, ): 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): 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) => void { const qc = useQueryClient(); return useCallback( (patch: Record) => { 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( serverKey: string, defaultValue: T, options: UsePrefOptions = {}, ): [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(qk.me()), serverKey, defaultValue, options), ); const [value, setValue] = useState(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(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]; }