diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c55b675c..13a21521 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,7 +15,8 @@ import { type PanelLayout, } from "./lib/panelLayout"; import { notify, removeByMetaKind } from "./lib/notifications"; -import { LS, readAccount, setAccountRaw, writeAccount } from "./lib/storage"; +import { LS, setAccountRaw } from "./lib/storage"; +import { usePref } from "./lib/usePref"; import { useNavigation, useNavigationActions } from "./components/NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider"; import { useChannelLayout } from "./components/ChannelsProvider"; @@ -90,26 +91,18 @@ export default function App() { } // Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the - // user's preferences so it follows the account across devices; a localStorage cache seeds the - // initial render synchronously so nothing flashes open before the server prefs arrive. + // user's preferences (via usePref) so it follows the account across devices; the localStorage mirror + // seeds the initial render synchronously so nothing flashes open before the server prefs arrive. // Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice. - const [filterCollapsed, setFilterCollapsedState] = useState(() => - Boolean(readAccount(LS.filterCollapsed, true)), - ); + const [filterCollapsed, setFilterCollapsed] = usePref("filterCollapsed", true, { + localKey: LS.filterCollapsed, + parse: Boolean, + }); // The Playlists rail has its own collapse flag (it's a list/navigator, not filters). - const [playlistsCollapsed, setPlaylistsCollapsedState] = useState(() => - Boolean(readAccount(LS.playlistsCollapsed, true)), - ); - function setFilterCollapsed(next: boolean) { - setFilterCollapsedState(next); - writeAccount(LS.filterCollapsed, next); - api.savePrefs({ filterCollapsed: next }).catch(() => {}); - } - function setPlaylistsCollapsed(next: boolean) { - setPlaylistsCollapsedState(next); - writeAccount(LS.playlistsCollapsed, next); - api.savePrefs({ playlistsCollapsed: next }).catch(() => {}); - } + const [playlistsCollapsed, setPlaylistsCollapsed] = usePref("playlistsCollapsed", true, { + localKey: LS.playlistsCollapsed, + parse: Boolean, + }); // Filters live in localStorage, not the address bar. If we arrived via a "Share view" // link, its params have already hydrated the initial state — persist them and strip the // query so the URL stays clean from here on. @@ -213,9 +206,10 @@ export default function App() { stripUrlParams(); }, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps - // On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags, - // language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider - // and the feed view by FeedViewProvider — each via its own passive qk.me() observer. + // On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse + // flags (filterCollapsed / playlistsCollapsed) are adopted by their own usePref hooks, the + // Settings-page prefs (theme/perf/hints/notifications) by PrefsProvider, and the feed view by + // FeedViewProvider — each via its own passive qk.me() observer. useEffect(() => { const prefs = meQuery.data?.preferences; if (!prefs) return; @@ -227,14 +221,6 @@ export default function App() { saveLayoutLocal(p, l); } }); - if (typeof prefs.filterCollapsed === "boolean") { - setFilterCollapsedState(prefs.filterCollapsed); - writeAccount(LS.filterCollapsed, prefs.filterCollapsed); - } - if (typeof prefs.playlistsCollapsed === "boolean") { - setPlaylistsCollapsedState(prefs.playlistsCollapsed); - writeAccount(LS.playlistsCollapsed, prefs.playlistsCollapsed); - } const lang = prefs.language; if (typeof lang === "string" && isSupported(lang)) setLanguage(lang); // (Per-account filters — incl. the demo account's whole-library default — are loaded by the diff --git a/frontend/src/components/FeedViewProvider.tsx b/frontend/src/components/FeedViewProvider.tsx index ba515118..61ce301d 100644 --- a/frontend/src/components/FeedViewProvider.tsx +++ b/frontend/src/components/FeedViewProvider.tsx @@ -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 . // -// 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(() => - 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("view", "cards", { + localKey: LS.feedView, + parse: parseFeedView, + }); const actions = useMemo(() => ({ setView }), [setView]); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 4360b43f..25fe79bc 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -1,9 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; import { useNavigationActions } from "./NavigationProvider"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; +import { usePref } from "../lib/usePref"; import { AlertTriangle, ArrowLeft, @@ -481,36 +482,18 @@ export default function PlayerModal({ p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true); }; - // Auto-advance + loop are persistent per-user settings. Read the current values from the cached - // `me`, mirror them in local state for instant UI, and write both to the server + cache on change - // so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler. - const savedPrefs = useMemo( - () => - (qc.getQueryData<{ preferences?: Record }>(qk.me())?.preferences ?? {}) as { - playerAutoAdvance?: AutoMode; - playerLoop?: LoopMode; - }, - [qc], - ); - const [autoMode, setAutoMode] = useState(savedPrefs.playerAutoAdvance ?? "off"); - const [loopMode, setLoopMode] = useState(savedPrefs.playerLoop ?? "off"); + // Auto-advance + loop are persistent per-user settings (usePref: server + `me` cache, adopted on + // login) so they apply to every player and survive reloads. The player mounts after `me` is cached, + // so it seeds from the cache — no localStorage mirror. A ref feeds the (once-bound) end handler. + const [autoMode, setAutoMode] = usePref("playerAutoAdvance", "off"); + const [loopMode, setLoopMode] = usePref("playerLoop", "off"); const modeRef = useRef({ autoMode, loopMode }); modeRef.current = { autoMode, loopMode }; - const persistPref = (patch: Record) => { - api.savePrefs(patch).catch(() => {}); - qc.setQueryData<{ preferences?: Record } | undefined>(qk.me(), (m) => - m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, - ); - }; const cycleAuto = () => { - const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!; - setAutoMode(next); - persistPref({ playerAutoAdvance: next }); + setAutoMode(AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!); }; const cycleLoop = () => { - const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!; - setLoopMode(next); - persistPref({ playerLoop: next }); + setLoopMode(LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!); }; const replayCurrent = () => { const p = playerRef.current; diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index 0ab08d91..328bf66c 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -1,10 +1,8 @@ import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; -import { qk } from "./queryKeys"; import { useTranslation } from "react-i18next"; -import { useQueryClient } from "@tanstack/react-query"; import { SlidersHorizontal } from "lucide-react"; import { useDismiss } from "./useDismiss"; -import { api } from "./api"; +import { usePref, useSavePref } from "./usePref"; // Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages, // so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two @@ -12,42 +10,21 @@ import { api } from "./api"; // customize menu that toggles them. export function useDetailPrefs() { - const qc = useQueryClient(); - const prefs = (qc.getQueryData<{ preferences?: Record }>(qk.me())?.preferences ?? - {}) as { - plexInfoArtBg?: boolean; - plexInfoCast?: boolean; - plexInfoRelated?: boolean; - }; - const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false); - const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false); - const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false); - const save = (patch: Record) => { - api.savePrefs(patch).catch(() => {}); - qc.setQueryData<{ preferences?: Record } | undefined>(qk.me(), (m) => - m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, - ); - }; + // Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON: + // undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so + // usePref seeds from the cache (no localStorage mirror). + const [artBg, setArtBg] = usePref("plexInfoArtBg", true); + const [showCast, setShowCast] = usePref("plexInfoCast", true); + const [showRelated, setShowRelated] = usePref("plexInfoRelated", true); + const savePref = useSavePref(); return { artBg, showCast, showRelated, - savePref: save, // expose the raw persister for callers with extra per-user prefs (e.g. strip sources) - toggleArtBg: () => { - const next = !artBg; - setArtBg(next); - save({ plexInfoArtBg: next }); - }, - toggleCast: () => { - const next = !showCast; - setShowCast(next); - save({ plexInfoCast: next }); - }, - toggleRelated: () => { - const next = !showRelated; - setShowRelated(next); - save({ plexInfoRelated: next }); - }, + savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources) + toggleArtBg: () => setArtBg(!artBg), + toggleCast: () => setShowCast(!showCast), + toggleRelated: () => setShowRelated(!showRelated), }; } diff --git a/frontend/src/lib/usePref.dom.test.ts b/frontend/src/lib/usePref.dom.test.ts new file mode 100644 index 00000000..e20831da --- /dev/null +++ b/frontend/src/lib/usePref.dom.test.ts @@ -0,0 +1,174 @@ +// @vitest-environment jsdom +// +// usePref: the one per-account + server preference hook. Covers the three seed sources (server cache / +// localStorage mirror / default), the persist path (server PUT + optimistic `me` cache merge + mirror), +// debounced writes, adopt-on-login, parse, and the raw useSavePref persister. The `me` cache is the +// server source of truth here; storage is an in-memory account-keyed map; api.savePrefs is a spy. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type ReactNode } from "react"; + +const { store, savePrefs } = vi.hoisted(() => ({ + store: new Map(), + savePrefs: vi.fn(() => Promise.resolve({})), +})); + +vi.mock("./storage", () => ({ + readAccount: (base: string, fallback: unknown) => { + const raw = store.get(base); + return raw == null ? fallback : JSON.parse(raw); + }, + writeAccount: (base: string, value: unknown) => { + store.set(base, JSON.stringify(value)); + }, +})); +vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } })); + +import { usePref, useSavePref } from "./usePref"; +import { qk } from "./queryKeys"; + +function makeClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} +function seedMe(qc: QueryClient, preferences: Record, id = 1) { + qc.setQueryData(qk.me(), { id, preferences }); +} +function wrapperFor(qc: QueryClient) { + return ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: qc }, children); +} + +describe("usePref", () => { + beforeEach(() => { + store.clear(); + savePrefs.mockClear(); + }); + + it("seeds from the default when neither server nor mirror has the key", () => { + const qc = makeClient(); + const { result } = renderHook(() => usePref("view", "cards"), { + wrapper: wrapperFor(qc), + }); + expect(result.current[0]).toBe("cards"); + }); + + it("seeds from the server (me cache) when present", () => { + const qc = makeClient(); + seedMe(qc, { view: "rows" }); + const { result } = renderHook(() => usePref("view", "cards"), { + wrapper: wrapperFor(qc), + }); + expect(result.current[0]).toBe("rows"); + }); + + it("seeds from the localStorage mirror when there is no server value", () => { + const qc = makeClient(); + store.set("siftlode.feedView", JSON.stringify("tiles")); + const { result } = renderHook( + () => usePref("view", "cards", { localKey: "siftlode.feedView" }), + { wrapper: wrapperFor(qc) }, + ); + expect(result.current[0]).toBe("tiles"); + }); + + it("prefers the server value over the mirror", () => { + const qc = makeClient(); + seedMe(qc, { view: "rows" }); + store.set("siftlode.feedView", JSON.stringify("tiles")); + const { result } = renderHook( + () => usePref("view", "cards", { localKey: "siftlode.feedView" }), + { wrapper: wrapperFor(qc) }, + ); + expect(result.current[0]).toBe("rows"); + }); + + it("applies parse to a raw stored value", () => { + const qc = makeClient(); + seedMe(qc, { view: "GRID" }); + const { result } = renderHook( + () => usePref("view", "cards", { parse: (r) => String(r).toLowerCase() }), + { wrapper: wrapperFor(qc) }, + ); + expect(result.current[0]).toBe("grid"); + }); + + it("on set: updates state, writes the server, merges the me cache, and the mirror", () => { + const qc = makeClient(); + seedMe(qc, {}); + const { result } = renderHook( + () => usePref("view", "cards", { localKey: "siftlode.feedView" }), + { wrapper: wrapperFor(qc) }, + ); + act(() => result.current[1]("rows")); + expect(result.current[0]).toBe("rows"); + expect(savePrefs).toHaveBeenCalledWith({ view: "rows" }); + expect( + (qc.getQueryData(qk.me()) as { preferences: Record }).preferences.view, + ).toBe("rows"); + expect(store.get("siftlode.feedView")).toBe(JSON.stringify("rows")); + }); + + it("does not write a localStorage mirror when no localKey is given", () => { + const qc = makeClient(); + seedMe(qc, {}); + const { result } = renderHook(() => usePref("playerLoop", "off"), { + wrapper: wrapperFor(qc), + }); + act(() => result.current[1]("all")); + expect(store.size).toBe(0); + expect(savePrefs).toHaveBeenCalledWith({ playerLoop: "all" }); + }); + + it("adopts the server value when the account id changes (login / switch)", async () => { + const qc = makeClient(); + seedMe(qc, { view: "cards" }, 1); + const { result } = renderHook(() => usePref("view", "cards"), { + wrapper: wrapperFor(qc), + }); + expect(result.current[0]).toBe("cards"); + // A different account resolves. The passive me observer notifies asynchronously (react-query's + // notifyManager batches), so wait for the re-render — the same path the real App drives on login. + act(() => seedMe(qc, { view: "tiles" }, 2)); + await waitFor(() => expect(result.current[0]).toBe("tiles")); + }); + + describe("debounceMs", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("coalesces rapid sets into one PUT after the delay", () => { + const qc = makeClient(); + seedMe(qc, {}); + const { result } = renderHook(() => usePref("vol", 0, { debounceMs: 300 }), { + wrapper: wrapperFor(qc), + }); + act(() => { + result.current[1](10); + result.current[1](20); + result.current[1](30); + }); + // Local state is instant; the server write is deferred. + expect(result.current[0]).toBe(30); + expect(savePrefs).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(300)); + expect(savePrefs).toHaveBeenCalledTimes(1); + expect(savePrefs).toHaveBeenCalledWith({ vol: 30 }); + }); + }); +}); + +describe("useSavePref", () => { + beforeEach(() => savePrefs.mockClear()); + + it("persists an arbitrary patch and merges it into the me cache", () => { + const qc = makeClient(); + seedMe(qc, { a: 1 }); + const { result } = renderHook(() => useSavePref(), { wrapper: wrapperFor(qc) }); + act(() => result.current({ plexHiddenStripSources: ["x"] })); + expect(savePrefs).toHaveBeenCalledWith({ plexHiddenStripSources: ["x"] }); + const prefs = (qc.getQueryData(qk.me()) as { preferences: Record }) + .preferences; + expect(prefs).toEqual({ a: 1, plexHiddenStripSources: ["x"] }); + }); +}); diff --git a/frontend/src/lib/usePref.ts b/frontend/src/lib/usePref.ts new file mode 100644 index 00000000..6d7a0776 --- /dev/null +++ b/frontend/src/lib/usePref.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "./queryKeys"; +import { api } from "./api"; +import type { Me } from "./api/account"; +import { readAccount, writeAccount } from "./storage"; + +// 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 is adopted +// back on login / account switch. Before this, every such pref hand-rolled the same three pieces at its +// call site: (1) seed local state, (2) on change write the server + a local mirror + the `me` cache, +// (3) a `useEffect([me?.id])` block to adopt the server value when the account resolves. usePref folds +// all three into one hook so a new server-synced pref is a one-liner. +// +// Two mirror strategies, unified here (pick via `localKey`): +// • Providers mounted ABOVE (feed view, panel layouts, rail-collapse flags) render before +// /api/me pins the account, so they need a synchronous localStorage seed to avoid a flash on +// reload — pass `localKey`. +// • Late-mounting consumers (the player, the Plex detail pages) mount after `me` is cached, so they +// seed straight from the react-query cache and need no localStorage mirror — omit `localKey`. +// Either way 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 per-account localStorage under this base key, and seed synchronously from it on + * first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */ + 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 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(() => {}); + qc.setQueryData(qk.me(), (m) => + m ? { ...m, preferences: { ...m.preferences, ...patch } } : m, + ); + }, + [qc], + ); +} + +/** A per-account preference persisted to the server (and optionally mirrored to localStorage), with + * automatic adopt on login / account switch. Returns `[value, setValue]`; the setter is stable. */ +export function usePref( + serverKey: string, + defaultValue: T, + options: UsePrefOptions = {}, +): [T, (next: T) => void] { + const { localKey, debounceMs, parse } = options; + const qc = useQueryClient(); + // 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 }); + + // Latest parse in a ref so the seed/adopt/setter don't depend on a fresh closure each render (which + // would churn the memoized setter and the adopt effect). parse must be idempotent anyway. + const parseRef = useRef(parse); + parseRef.current = parse; + const norm = useCallback((raw: unknown): T => { + const p = parseRef.current; + return p ? p(raw) : (raw as T); + }, []); + + const seed = useCallback((): T => { + const server = readServerPref(qc.getQueryData(qk.me()), serverKey); + if (server !== undefined) return norm(server); + if (localKey) return norm(readAccount(localKey, defaultValue)); + return defaultValue; + // defaultValue / serverKey / localKey are stable for a given call site. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [qc, serverKey, localKey, norm]); + + const [value, setValue] = useState(seed); + + const saveTimer = useRef(undefined); + useEffect(() => () => window.clearTimeout(saveTimer.current), []); + + 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 this hook's own adopt effect) + // see the new value without a refetch. Merges into preferences, mirroring the server PUT. + qc.setQueryData(qk.me(), (m) => + m ? { ...m, preferences: { ...m.preferences, [serverKey]: next } } : m, + ); + const payload = { [serverKey]: next }; + // savePrefs is fire-and-forget: api.req() already surfaces a failure toast, and the local state + + // cache already reflect the change (idempotent PUT retries a transient blip). + if (debounceMs) { + window.clearTimeout(saveTimer.current); + saveTimer.current = window.setTimeout(() => { + api.savePrefs(payload).catch(() => {}); + }, debounceMs); + } else { + api.savePrefs(payload).catch(() => {}); + } + }, + [qc, serverKey, localKey, debounceMs], + ); + + // Adopt the server value when the account becomes known / changes (login, account switch). Guarded on + // the account id so a `me` cache update from our OWN setter (same id) can't re-fire it. Missing key → + // keep the current (seeded / user-set) value rather than clobbering it with the default. + const prevId = useRef(me?.id); + useEffect(() => { + if (me?.id === prevId.current) return; + prevId.current = me?.id; + const server = readServerPref(me, serverKey); + if (server === undefined) return; + const val = norm(server); + setValue(val); + if (localKey) writeAccount(localKey, val); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [me?.id]); + + return [value, set]; +}