A tab names the account it was claimed for on every request, beacon and upload (X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with 401 account_changed and keep the session, so a tab left showing the account signed in before reloads instead of writing to the new one. /auth/login clears a connect marker left by an abandoned connect, and a link completes only on a session that still holds. The prefs merge reads the row under a lock. A tab's sessionStorage records its own owner. A page the account loses is given up, address bar and remembered page included. The panel layouts are preferences; the storage contract also refuses a key handed to the Storage API or to usePref outside its kind. A refused sign-in as someone else and a failed sign-out say so. The chromium-only e2e specs are in the gate register, the member walk waits for its requests, and devices.spec checks the save itself.
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
import { useEffect, useEffectEvent, 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,
|
|
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 { LS, ownKey } 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 = ownKey(lsKey);
|
|
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
|
|
// account's stored-filters key, which is always readable here: BootGate hands the browser's local
|
|
// state to the account (claimLocalData) before this provider mounts.
|
|
const seededRef = useRef<Set<ViewSource>>(new Set());
|
|
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: 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.
|
|
const latestCfg = useEffectEvent(() => cfg);
|
|
useEffect(() => {
|
|
const data = viewsQuery.data;
|
|
if (!data || seededRef.current.has(source)) return;
|
|
seededRef.current.add(source);
|
|
const c = latestCfg();
|
|
const def = pickDefaultToSeed(data, c.canSeedDefault());
|
|
// The name follows by itself: `preferSticky` picks the starred default among same-filter views.
|
|
if (def) c.setFilters(c.asFilters(def.filters));
|
|
}, [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 = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
|
|
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
|
let ok = true;
|
|
try {
|
|
await fn();
|
|
} catch (e) {
|
|
notifyActionError(e, failMsg);
|
|
ok = false;
|
|
}
|
|
refresh();
|
|
return ok;
|
|
}
|
|
|
|
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>;
|
|
}
|