feat(prefs): usePref — one per-account+server preference hook (E4 S1)

Fold the hand-rolled server-pref plumbing (savePrefs + local mirror + me-cache
merge + adopt-on-login) into a single usePref<T>(serverKey, default, opts) hook,
plus useSavePref for dynamic multi-key patches.

Migrated (6 savePrefs call sites + 2 duplicated adopt effects -> the hook):
- FeedViewProvider: view
- App: filterCollapsed, playlistsCollapsed (+ their adopt block removed)
- PlayerModal: playerAutoAdvance, playerLoop
- plexDetailUi: plexInfoArtBg/Cast/Related (savePref via useSavePref)

Left as-is by design: panel layouts (a dynamically-indexed Record, not one key),
language (i18n store, no React state), PrefsProvider (debounced multi-field
auto-save + baseline echo-suppression - a different model).

Behaviour-equivalent (net -84 LOC at the call sites). usePref.dom.test covers
the three seed sources, persist path, debounce, adopt, parse, and useSavePref.
Frontend gate green: typecheck, knip, eslint (0 err), prettier, 238 vitest.
This commit is contained in:
2026-08-19 03:07:25 +02:00
parent c82cf25bcf
commit 48aa1a25f5
6 changed files with 354 additions and 132 deletions
+11 -41
View File
@@ -1,26 +1,15 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { parseFeedView, type FeedView } from "../lib/feedView";
import { LS, readAccount, writeAccount } from "../lib/storage";
import { LS } from "../lib/storage";
import { usePref } from "../lib/usePref";
// Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around <App/>.
//
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick (savePrefs)
// and is adopted from server prefs on login — so unlike the ephemeral list filters, this provider
// carries the same server-sync the App god component used to. A localStorage mirror seeds the initial
// render synchronously; the passive qk.me() observer adopts the server value once the account resolves.
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and is
// adopted from server prefs on login. usePref carries that server-sync (plus the localStorage mirror
// that seeds the first render synchronously) so this provider is just the two contexts over it.
interface FeedViewActions {
setView: (v: FeedView) => void;
@@ -37,30 +26,11 @@ export function useFeedViewActions(): FeedViewActions {
}
export function FeedViewProvider({ children }: { children: ReactNode }) {
const [view, setViewState] = useState<FeedView>(() =>
parseFeedView(readAccount(LS.feedView, null)),
);
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// A user pick persists both to the localStorage mirror (instant reload) and to the server.
const setView = useCallback((next: FeedView) => {
setViewState(next);
writeAccount(LS.feedView, next);
api.savePrefs({ view: next }).catch(() => {});
}, []);
// Adopt the server-stored view on login / account switch. Writes the mirror but does NOT savePrefs
// (that would echo the just-adopted value straight back). parseFeedView also maps the pre-0.45
// "grid" / "list" values forward.
useEffect(() => {
const prefs = me?.preferences;
if (!prefs) return;
const adopted = parseFeedView(prefs.view);
setViewState(adopted);
writeAccount(LS.feedView, adopted);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me?.id]);
// parseFeedView also maps the pre-0.45 "grid"/"list" values forward (idempotent for current values).
const [view, setView] = usePref<FeedView>("view", "cards", {
localKey: LS.feedView,
parse: parseFeedView,
});
const actions = useMemo<FeedViewActions>(() => ({ setView }), [setView]);