Eleven suppressions across the five providers. The recurring shape here was "an effect that undoes something when you navigate away", and every one of them ran a commit LATE — the new page painted once while the old module's state was still live. Adjusted during render instead (React discards that pass rather than committing it, so there is no in-between frame): - SavedViewsProvider reset the sticky view and closed its modal from an effect on `source`, so the first Plex render carried the feed's sticky id — long enough for a stale view chip. It also followed the active view into stickyId from a second effect; that one converges by construction, since preferSticky returns the sticky match when there is one. - NavigationProvider ended a live YouTube search from an effect on `page`. Keyed on the page CHANGE rather than derived from it, because "ended" has to be permanent — deriving would have quietly brought the search back on return to the feed. - ChannelsProvider's manager focus is now simply DERIVED from the page: it means nothing off the Channels page, and focusChannel sets it and navigates in one handler. Honest deps by freezing what is genuinely mount-time: - FeedFiltersProvider persisted a share link's filters with an empty dep list; listing `filters` honestly would have re-persisted on every edit and spun. The mount value is frozen into its own state, so all three deps are stable and the effect says what it does. - ChannelsProvider's URL-seed layout effect and NavigationProvider's initial history stamp get the same treatment. The three slice setters they call were already stable useCallbacks (useFilterState / useSortState / useAccountPersistedState all return one). - SavedViewsProvider reads `cfg` through a ref: it re-memoizes on every filter edit, and the per-source seed guard already enforces once. That also made the set-state finding go away — with the render value gone, the checker can see the seed for what it is. WizardProvider keeps its one escape hatch, now inline with the invariant: auto-opening is a one-way decision taken from server data as it arrives, and deriving it would reopen a wizard the user had just closed. Suppressions 89 -> 78; exhaustive-deps 27 -> 23.
268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import {
|
|
api,
|
|
coercePlexFilters,
|
|
getActiveAccount,
|
|
plexFiltersSignature,
|
|
type FeedFilters,
|
|
type PlexFilters,
|
|
type SavedView,
|
|
} from "../lib/api";
|
|
import { filtersToParams, hasFilterParams } from "../lib/urlState";
|
|
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { accountKey, LS } from "../lib/storage";
|
|
import { useConfirm } from "./confirmContext";
|
|
import { useNavigation } from "./navigationContext";
|
|
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
|
import { usePlex, usePlexActions } from "./plexContext";
|
|
import {
|
|
asFilters,
|
|
SavedViewsCtx,
|
|
type SavedViewsContext,
|
|
type ViewSource,
|
|
} from "./savedViewsContext";
|
|
|
|
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
|
|
// switches which module it operates on by the active page (both filter providers are global, so it can
|
|
// read either), so the shared UI (popover / modal / chip) works on the feed page against feed views and
|
|
// on the plex page against plex views without a second provider or an App-layout change.
|
|
//
|
|
// The context, its value type and the hook live in savedViewsContext.ts.
|
|
|
|
// The per-source wiring the generic logic runs on: the live filters, how to apply a view's, a stable
|
|
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
|
|
interface ViewConfig {
|
|
source: ViewSource;
|
|
filters: Record<string, unknown>;
|
|
setFilters: (f: Record<string, unknown>) => void;
|
|
keyOf: (f: Record<string, unknown>) => string;
|
|
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
|
|
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
|
|
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
|
|
* stored filters for the active account, and — feed only — no URL filter override. */
|
|
canSeedDefault: () => boolean;
|
|
}
|
|
|
|
const feedKeyOf = (f: Record<string, unknown>) =>
|
|
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
|
const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
|
|
|
// True once this account has its own persisted filters for `lsKey` — the signal that the last
|
|
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
|
// report "stored" so a default never seeds blind over an indeterminate state.
|
|
function storedFiltersPresent(lsKey: string): boolean {
|
|
const key = accountKey(lsKey, getActiveAccount());
|
|
if (!key) return true;
|
|
try {
|
|
return localStorage.getItem(key) != null;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
// Feed also yields to a URL that carries filter params (a shared/bookmarked view).
|
|
function feedCanSeedDefault(): boolean {
|
|
if (hasFilterParams(new URLSearchParams(window.location.search))) return false;
|
|
return !storedFiltersPresent(LS.filters);
|
|
}
|
|
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
|
|
function plexCanSeedDefault(): boolean {
|
|
return !storedFiltersPresent(LS.plexFilters);
|
|
}
|
|
|
|
export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const { page } = useNavigation();
|
|
|
|
// Both filter providers are global — read both, then use the one the active page owns.
|
|
const feedFilters = useFeedFilters();
|
|
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
|
const { filters: plexFilters } = usePlex();
|
|
const { setFilters: setPlexFilters } = usePlexActions();
|
|
|
|
const cfg: ViewConfig = useMemo(() => {
|
|
if (page === "plex") {
|
|
return {
|
|
source: "plex",
|
|
filters: plexFilters as unknown as Record<string, unknown>,
|
|
setFilters: (f) => setPlexFilters(f as unknown as PlexFilters),
|
|
keyOf: plexKeyOf,
|
|
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
|
|
stripForSave: (f) => f, // Plex has no transient field to drop
|
|
canSeedDefault: plexCanSeedDefault,
|
|
};
|
|
}
|
|
return {
|
|
source: "feed",
|
|
filters: feedFilters as unknown as Record<string, unknown>,
|
|
setFilters: (f) => setFeedFilters(f as unknown as FeedFilters),
|
|
keyOf: feedKeyOf,
|
|
asFilters: (raw) =>
|
|
asFilters(raw as unknown as FeedFilters) as unknown as Record<string, unknown>,
|
|
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
|
|
stripForSave: (f) => {
|
|
const { seed: _seed, ...rest } = f;
|
|
return rest;
|
|
},
|
|
canSeedDefault: feedCanSeedDefault,
|
|
};
|
|
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
|
|
|
|
const source = cfg.source;
|
|
// Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too
|
|
// (the public landing), and saved views are per-account, so asking for "nobody's" views only logs
|
|
// a 401 on the landing page. Enabled once a signed-in account is cached.
|
|
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
|
const viewsQuery = useQuery({
|
|
queryKey: qk.savedViews(source),
|
|
queryFn: () => api.savedViews(source),
|
|
enabled: !!me,
|
|
});
|
|
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
|
|
|
|
const [stickyId, setStickyId] = useState<number | null>(null);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
|
|
// Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex,
|
|
// and a feed modal shouldn't linger open on plex. Adjusted DURING RENDER against the previous
|
|
// source rather than from an effect, so the first plex render never commits carrying the feed's
|
|
// sticky id — which is exactly long enough for a stale view chip to paint.
|
|
const [prevSource, setPrevSource] = useState(source);
|
|
if (prevSource !== source) {
|
|
setPrevSource(source);
|
|
setStickyId(null);
|
|
setModalOpen(false);
|
|
}
|
|
|
|
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
|
|
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
|
|
// stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists
|
|
// it, so it happens once and the normal last-view-wins path takes over, exactly as the storage
|
|
// semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's
|
|
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
|
|
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
|
|
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
|
|
// per-account stored-filters key, which is always readable here: BootGate pins the tab's account
|
|
// before this provider mounts.
|
|
const seededRef = useRef<Set<ViewSource>>(new Set());
|
|
// `cfg` re-memoizes on every filter edit, so it is read through a ref: the seed must fire on the
|
|
// views query's first resolve for a source and never again, which `seededRef` enforces. Listing
|
|
// cfg would re-arm nothing (the guard still holds) but would re-run the effect on every keystroke
|
|
// in the filter bar. The ref keeps the dep list honest without that.
|
|
const cfgRef = useRef(cfg);
|
|
cfgRef.current = cfg;
|
|
useEffect(() => {
|
|
const data = viewsQuery.data;
|
|
if (!data || seededRef.current.has(source)) return;
|
|
seededRef.current.add(source);
|
|
const c = cfgRef.current;
|
|
const def = pickDefaultToSeed(data, c.canSeedDefault());
|
|
if (!def) return;
|
|
c.setFilters(c.asFilters(def.filters));
|
|
setStickyId(def.id);
|
|
}, [viewsQuery.data, source]);
|
|
|
|
const currentKey = cfg.keyOf(cfg.filters);
|
|
const activeView = useMemo(
|
|
() =>
|
|
preferSticky(
|
|
views.filter((v) => cfg.keyOf(v.filters) === currentKey),
|
|
stickyId,
|
|
),
|
|
[views, currentKey, stickyId, cfg],
|
|
);
|
|
// The sticky id follows whichever view is active — also adjusted during render. It converges by
|
|
// construction: preferSticky returns the sticky match when there is one, so the very write below
|
|
// makes its own condition false on the next pass.
|
|
if (activeView && activeView.id !== stickyId) setStickyId(activeView.id);
|
|
|
|
const stickyView = useMemo(
|
|
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
|
|
[views, stickyId],
|
|
);
|
|
const displayView = activeView ?? stickyView;
|
|
const isDirty = displayView != null && activeView == null;
|
|
|
|
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews(source) });
|
|
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
|
try {
|
|
await fn();
|
|
return true;
|
|
} catch (e) {
|
|
notifyActionError(e, failMsg);
|
|
return false;
|
|
} finally {
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
const value: SavedViewsContext = {
|
|
source,
|
|
views,
|
|
loading: viewsQuery.isLoading,
|
|
activeViewId: activeView?.id ?? null,
|
|
displayView,
|
|
isDirty,
|
|
modalOpen,
|
|
openModal: () => setModalOpen(true),
|
|
closeModal: () => setModalOpen(false),
|
|
apply: (v) => {
|
|
cfg.setFilters(cfg.asFilters(v.filters));
|
|
setStickyId(v.id);
|
|
},
|
|
clearActive: () => setStickyId(null),
|
|
create: async (name) => {
|
|
const clean = name.trim();
|
|
if (!clean) return false;
|
|
return runViewMutation(
|
|
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
|
|
t("views.actionFailed"),
|
|
);
|
|
},
|
|
rename: async (id, name) => {
|
|
const next = name.trim();
|
|
if (!next) return false;
|
|
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
|
},
|
|
updateFilters: (id, filters) =>
|
|
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
|
|
saveCurrent: async (id) => {
|
|
const filters = cfg.stripForSave(cfg.filters);
|
|
const ok = await runViewMutation(
|
|
() => api.updateView(id, { filters }),
|
|
t("views.actionFailed"),
|
|
);
|
|
return ok ? filters : null;
|
|
},
|
|
toggleDefault: (v) =>
|
|
runViewMutation(
|
|
() => api.updateView(v.id, { is_default: !v.is_default }),
|
|
t("views.actionFailed"),
|
|
).then(() => undefined),
|
|
remove: async (v) => {
|
|
const ok = await confirm({
|
|
message: t("views.deleteConfirm", { name: v.name }),
|
|
confirmLabel: t("views.delete"),
|
|
danger: true,
|
|
});
|
|
if (!ok) return false;
|
|
if (stickyId === v.id) setStickyId(null);
|
|
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
|
},
|
|
reorder: async (nextIds) => {
|
|
qc.setQueryData<SavedView[]>(
|
|
qk.savedViews(source),
|
|
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
|
);
|
|
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
|
},
|
|
};
|
|
|
|
return <SavedViewsCtx.Provider value={value}>{children}</SavedViewsCtx.Provider>;
|
|
}
|