refactor(frontend): the module stores mount from the registry, and the feed and Plex each own their saved views

A module's definition names its store (feed: filters, view, saved views; Plex: browse state, saved
views; channels, files, playlists their provider), and ModuleStores mounts them at the root in
registry order instead of a hand-nested tree in main.tsx. A contract test holds that every store's
read side is in reach and that no store's static imports leave its own module, so the order cannot
matter.

Saved views were one provider that switched between the feed and Plex by the active page and reset
on each switch. The logic is now one hook (useSavedViewsStore) with an instance per module; the
shared chip, popover and modal read the views their page or rail hands down. A feed view applied
and edited now survives a visit to Plex. Plex's views are still asked for only on its page, and a
module's manage modal still closes when its page is left. The feed's URL-override check applies
only on the feed page, where its filter params live.

The Channels store's tab type moves from the page into the store's context.
This commit is contained in:
2026-10-23 04:59:10 +02:00
parent cde5a98fd2
commit 6ae7825a14
43 changed files with 653 additions and 289 deletions
+7 -5
View File
@@ -3,7 +3,8 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useFeedView, useFeedViewActions } from "./feedViewContext";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
import { useSavedViews } from "../../features/filters/savedViewsContext";
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
import { useFeedSavedViews } from "./feedSavedViews";
import FeedHeader from "./FeedHeader";
import Feed from "./Feed";
@@ -21,9 +22,10 @@ export default function FeedPage() {
// The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts
// on navigation) rather than inside the collapsible filter rail — otherwise opening it from the
// always-visible toolbar view chip while the rail is unpinned would render nothing and latch open.
const { modalOpen } = useSavedViews();
// The feed's saved views reach the shared views UI under this page (the chip, the modal) by the scope.
const savedViews = useFeedSavedViews();
return (
<>
<SavedViewsScope value={savedViews}>
<FeedHeader />
<Feed
filters={filters}
@@ -35,7 +37,7 @@ export default function FeedPage() {
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/>
{modalOpen && <PersonalViewsModal />}
</>
{savedViews.modalOpen && <PersonalViewsModal />}
</SavedViewsScope>
);
}
@@ -1,19 +1,34 @@
// @vitest-environment jsdom
//
// The single default-application path. On the saved-views query's FIRST resolve, SavedViewsProvider
// seeds the starred default view's filters into a genuinely fresh module (no stored filters), for BOTH
// the feed and Plex — but leaves them alone once the account has its own stored filters. This is the
// fix for the old feed-only LS mirror that raced FeedFiltersProvider's login effect and never applied a
// Plex default at all. The sub-providers are mocked so we can assert exactly which setFilters is called;
// api.savedViews is stubbed and driven through a real QueryClient.
// The feed's saved views. The single default-application path: on the views query's FIRST resolve, the
// starred default view's filters seed a genuinely fresh feed (no stored filters, no URL filter override)
// — and are left alone once the account has its own stored filters. This is the fix for the old
// feed-only LS mirror that raced FeedFiltersProvider's login effect. And the store is the feed's own: a
// visit to another page leaves its active view where it was. The feed filters are mocked so we can
// assert exactly which setFilters is called; api.savedViews is stubbed and driven through a real
// QueryClient.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
const BASE = {
tags: [],
channelIds: [],
tagModes: { language: "or", topic: "or", other: "or" },
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const h = vi.hoisted(() => ({
page: "feed" as "feed" | "plex",
page: "feed" as string,
filters: {} as Record<string, unknown>,
setFeedFilters: vi.fn(),
setPlexFilters: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => {
@@ -24,31 +39,17 @@ vi.mock("../../features/navigation/navigationContext", () => ({
useNavigation: () => ({ page: h.page }),
}));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({
tags: [],
channelIds: [],
tagModes: { language: "or", topic: "or", other: "or" },
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
}),
useFeedFilters: () => h.filters,
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
}));
vi.mock("../plex/plexContext", () => ({
usePlex: () => ({ filters: {} }),
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
import { SavedViewsProvider } from "./SavedViewsProvider";
import { useSavedViews } from "../../features/filters/savedViewsContext";
import { FeedSavedViewsProvider } from "./FeedSavedViewsProvider";
import { useFeedSavedViews } from "./feedSavedViews";
import { api, type SavedView } from "../../lib/api";
import { claimLocalData, LS } from "../../lib/storage";
import { qk } from "../../lib/queryKeys";
import type { SavedViewsContext } from "../../features/filters/savedViewsContext";
const ID = 7;
const view = (over: Partial<SavedView>): SavedView => ({
@@ -60,11 +61,17 @@ const view = (over: Partial<SavedView>): SavedView => ({
...over,
});
// The store as the last commit left it, for the cases that drive it directly.
let latest: SavedViewsContext | null = null;
const keep = (store: SavedViewsContext) => {
latest = store;
};
function Probe() {
const { loading, views } = useSavedViews();
const store = useFeedSavedViews();
useEffect(() => keep(store));
return (
<div data-testid="loading" data-count={views.length}>
{String(loading)}
<div data-testid="loading" data-count={store.views.length}>
{String(store.loading)}
</div>
);
}
@@ -76,41 +83,46 @@ function signedInClient() {
return qc;
}
function tree(qc: QueryClient) {
return (
<QueryClientProvider client={qc}>
<FeedSavedViewsProvider>
<Probe />
</FeedSavedViewsProvider>
</QueryClientProvider>
);
}
async function mountResolved(views: SavedView[]) {
// Fresh array + object copies per call, like a real fetch — so a refetch changes viewsQuery.data's
// identity and re-runs the seed effect (which is what makes the per-source guard actually load-bearing
// identity and re-runs the seed effect (which is what makes the one-shot guard actually load-bearing
// in the re-seed test; a shared reference would skip the effect and hide a missing guard).
vi.spyOn(api, "savedViews").mockImplementation(async () => views.map((v) => ({ ...v })));
const qc = signedInClient();
render(
<QueryClientProvider client={qc}>
<SavedViewsProvider>
<Probe />
</SavedViewsProvider>
</QueryClientProvider>,
);
const r = render(tree(qc));
// Wait for the query to resolve so the first-resolve effect has run.
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
return qc;
return { qc, rerender: () => r.rerender(tree(qc)) };
}
beforeEach(() => {
localStorage.clear();
claimLocalData(ID);
// Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior
// case would suppress seeding in the others.
// Reset the URL: the seed check reads window.location.search, and a stale ?sort= from a prior case
// would suppress seeding in the others.
window.history.replaceState({}, "", "/");
h.page = "feed";
h.filters = { ...BASE };
h.setFeedFilters.mockClear();
h.setPlexFilters.mockClear();
latest = null;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("SavedViewsProvider — default-view seeding on first resolve", () => {
it("feed: seeds the starred default into a fresh module (no stored filters)", async () => {
describe("the feed's saved views — default-view seeding on first resolve", () => {
it("seeds the starred default into a fresh feed (no stored filters)", async () => {
await mountResolved([
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
]);
@@ -118,20 +130,20 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
});
it("feed: does NOT seed when the account already has stored filters", async () => {
it("does NOT seed when the account already has stored filters", async () => {
localStorage.setItem(LS.filters, JSON.stringify({ scope: "my" }));
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
expect(h.setFeedFilters).not.toHaveBeenCalled();
});
it("feed: does NOT seed when no view is starred as default", async () => {
it("does NOT seed when no view is starred as default", async () => {
await mountResolved([view({ id: 2, is_default: false, filters: { scope: "all" } })]);
expect(h.setFeedFilters).not.toHaveBeenCalled();
});
it("feed: does NOT seed when the URL carries filter params (a shared/bookmarked view wins)", async () => {
it("does NOT seed when the feed's URL carries filter params (a shared/bookmarked view wins)", async () => {
// A link like ?sort=oldest is an explicit filter the user arrived on; the starred default must not
// stomp it. feedCanSeedDefault() bails on hasFilterParams — plex ignores the URL, so this is feed-only.
// stomp it.
window.history.replaceState({}, "", "/?sort=oldest");
await mountResolved([
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
@@ -139,7 +151,16 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
expect(h.setFeedFilters).not.toHaveBeenCalled();
});
it("does NOT re-seed on a background refetch that returns a changed list (per-source one-shot guard)", async () => {
it("seeds when another page's URL carries those params: they are that page's, not a feed link", async () => {
// Booting onto Plex with its ?sort= used to hold the feed's default back: the feed's views were
// only asked for once you left Plex, by when the URL was the feed's own.
h.page = "plex";
window.history.replaceState({}, "", "/?page=plex&sort=oldest");
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
});
it("does NOT re-seed on a background refetch that returns a changed list (one-shot guard)", async () => {
// The 60s refetch re-resolves the query; when it returns a CHANGED list (e.g. the user just added a
// view elsewhere) react-query hands down a new reference and the seed effect re-runs. seededRef must
// stop it re-applying the default over filters the user has since edited. (An identical refetch is
@@ -151,13 +172,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
return calls === 1 ? [def] : [def, view({ id: 9, name: "added-later" })];
});
const qc = signedInClient();
render(
<QueryClientProvider client={qc}>
<SavedViewsProvider>
<Probe />
</SavedViewsProvider>
</QueryClientProvider>,
);
render(tree(qc));
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
await act(async () => {
@@ -168,34 +183,42 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
await waitFor(() => expect(screen.getByTestId("loading").dataset.count).toBe("2"));
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
});
});
it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => {
describe("the feed's saved views — a visit to another page", () => {
it("keeps the view you applied, edited since, when you come back from Plex", async () => {
const { rerender } = await mountResolved([view({ id: 4, name: "mine", filters: { ...BASE } })]);
act(() => latest!.apply(latest!.views[0]!));
h.filters = { ...BASE, show: "all" }; // an edit since: the view is now shown as changed
rerender();
expect(latest!.displayView?.id).toBe(4);
expect(latest!.isDirty).toBe(true);
h.page = "plex";
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
rerender();
h.page = "feed";
rerender();
expect(latest!.displayView?.id).toBe(4);
expect(latest!.isDirty).toBe(true);
});
it("plex: does NOT seed when plex filters are already stored", async () => {
h.page = "plex";
localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] }));
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
expect(h.setPlexFilters).not.toHaveBeenCalled();
it("closes its manage modal when you leave the feed, so it does not reopen on the way back", async () => {
const { rerender } = await mountResolved([]);
act(() => latest!.openModal());
expect(latest!.modalOpen).toBe(true);
h.page = "home";
rerender();
h.page = "feed";
rerender();
expect(latest!.modalOpen).toBe(false);
});
});
describe("SavedViewsProvider — signed-out tab", () => {
describe("the feed's saved views — signed-out tab", () => {
it("does NOT ask for saved views while no account is signed in (the landing logged a 401)", async () => {
const spy = vi.spyOn(api, "savedViews").mockResolvedValue([]);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), null); // BootGate's "nobody"
render(
<QueryClientProvider client={qc}>
<SavedViewsProvider>
<Probe />
</SavedViewsProvider>
</QueryClientProvider>,
);
render(tree(qc));
// Let any fetch the mount would start get going before asserting it never happened.
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
@@ -0,0 +1,49 @@
import { useMemo, type ReactNode } from "react";
import type { FeedFilters } from "../../lib/api";
import { filtersToParams, hasFilterParams } from "../../lib/urlState";
import { LS } from "../../lib/storage";
import { useNavigation } from "../../features/navigation/navigationContext";
import { asFilters } from "../../features/filters/savedViewsContext";
import {
storedFiltersPresent,
useSavedViewsStore,
type SavedViewsConfig,
} from "../../features/filters/useSavedViewsStore";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { FeedSavedViewsContextProvider } from "./feedSavedViews";
const keyOf = (f: Record<string, unknown>) =>
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
// The feed's saved views, over the feed's live filters (useSavedViewsStore holds the logic).
export function FeedSavedViewsProvider({ children }: { children: ReactNode }) {
const { page } = useNavigation();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
const onScreen = page === "feed";
const cfg: SavedViewsConfig = useMemo(
() => ({
source: "feed",
filters: filters as unknown as Record<string, unknown>,
setFilters: (f) => setFilters(f as unknown as FeedFilters),
keyOf,
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;
},
// The feed also yields to a URL that carries filter params (a shared/bookmarked view) — which
// only the feed's own page can carry: on any other page those params are that page's.
canSeedDefault: () =>
!(onScreen && hasFilterParams(new URLSearchParams(window.location.search))) &&
!storedFiltersPresent(LS.filters),
enabled: true,
onScreen,
}),
[filters, setFilters, onScreen],
);
const value = useSavedViewsStore(cfg);
return <FeedSavedViewsContextProvider value={value}>{children}</FeedSavedViewsContextProvider>;
}
+16
View File
@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
import { FeedFiltersProvider } from "./FeedFiltersProvider";
import { FeedViewProvider } from "./FeedViewProvider";
import { FeedSavedViewsProvider } from "./FeedSavedViewsProvider";
// The feed module's store, mounted at the root (features/navigation/ModuleStores): its filters, its view
// (the card or row layout) and its saved views over those filters.
export function FeedStore({ children }: { children: ReactNode }) {
return (
<FeedFiltersProvider>
<FeedViewProvider>
<FeedSavedViewsProvider>{children}</FeedSavedViewsProvider>
</FeedViewProvider>
</FeedFiltersProvider>
);
}
@@ -1,264 +0,0 @@
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 "../../components/ui/confirmContext";
import { useNavigation } from "../../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { usePlex, usePlexActions } from "../plex";
import {
asFilters,
SavedViewsCtx,
type SavedViewsContext,
type ViewSource,
} from "../../features/filters/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>;
}
+32 -28
View File
@@ -9,7 +9,8 @@ import { useDebounced } from "../../lib/useDebounced";
import { useActiveFilters } from "../../lib/useActiveFilters";
import TagManager from "../../features/channel/TagManager";
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
import { useSavedViews } from "../../features/filters/savedViewsContext";
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
import { useFeedSavedViews } from "./feedSavedViews";
import FilterRail from "../../components/ui/FilterRail";
import { type PanelItem } from "../../components/ui/PanelGroups";
import { type MediaSourceKind } from "../../lib/mediaSource";
@@ -43,7 +44,8 @@ export default function Sidebar() {
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("feed");
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { openModal } = useSavedViews();
// The feed's saved views, handed to the shared views UI in this rail through the scope below.
const savedViews = useFeedSavedViews();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
@@ -223,31 +225,33 @@ export default function Sidebar() {
}));
return (
<FilterRail
kind={source}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
// The feed widgets are simple label + control rows, not collapsible islands.
collapsible={false}
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
// full manager modal. Feed rail only.
iconExpanded={
source === "feed" ? (
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
) : undefined
}
onTitleClick={source === "feed" ? openModal : undefined}
titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined}
>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
</FilterRail>
<SavedViewsScope value={savedViews}>
<FilterRail
kind={source}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
// The feed widgets are simple label + control rows, not collapsible islands.
collapsible={false}
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
// full manager modal. Feed rail only.
iconExpanded={
source === "feed" ? (
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
) : undefined
}
onTitleClick={source === "feed" ? savedViews.openModal : undefined}
titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined}
>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
</FilterRail>
</SavedViewsScope>
);
}
+1 -1
View File
@@ -29,7 +29,7 @@ export function loadAccountFilters(): FeedFilters {
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
// FeedSavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
const fKey = ownKey(FILTERS_KEY);
@@ -0,0 +1,7 @@
import { createSavedViewsStore } from "../../features/filters/savedViewsContext";
// The feed's saved views: filled by FeedSavedViewsProvider, read by the feed's page and rail (which hand
// them to the shared views UI) and by the palette.
const store = createSavedViewsStore("Feed");
export const FeedSavedViewsContextProvider = store.Provider;
export const useFeedSavedViews = store.useViews;
+1 -3
View File
@@ -1,7 +1,5 @@
// The feed's public face: its stores (filters, view, saved views), mounted at the root and read app-wide.
export { FeedFiltersProvider } from "./FeedFiltersProvider";
export { FeedViewProvider } from "./FeedViewProvider";
export { SavedViewsProvider } from "./SavedViewsProvider";
export { useFeedSavedViews } from "./feedSavedViews";
export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
export { useFeedView, useFeedViewActions } from "./feedViewContext";
export { ChannelPage, feedModule } from "./module";
+2
View File
@@ -1,6 +1,7 @@
import { YouTubeIcon } from "../../lib/brandIcons";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { FeedStore } from "./FeedStore";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
const Rail = lazyWithPreload(() => import("./Sidebar"));
@@ -13,6 +14,7 @@ export const feedModule: ModuleDef = {
icon: YouTubeIcon,
Rail,
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
Store: FeedStore,
};
// The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the