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
+3 -2
View File
@@ -76,8 +76,9 @@ cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
and its `module.ts` definition — label, icon, gate, the lazy page and rail — collected in nav order by
`features/navigation/moduleRegistry`; a new page is its folder, its id in `lib/urlState` and one line there) →
and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by
`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in
`lib/urlState` and one line there) →
**`features/<name>/`** (what several pages share: video cards, filters, downloads, the player, navigation) → the
**platform layer `components/ui/`** (domain-free primitives: the popover engine and its menus, `Modal`/`Overlay`,
`Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`,
@@ -63,7 +63,8 @@ const CTX = {
toggleDefault: vi.fn(async () => {}),
};
vi.mock("./savedViewsContext", () => ({
vi.mock("./savedViewsContext", async (importOriginal) => ({
...(await importOriginal<typeof import("./savedViewsContext")>()),
useSavedViews: () => ({
...CTX,
activeViewId: h.activeViewId,
@@ -43,7 +43,7 @@ import { Input } from "../../components/ui/Input";
// the right column shows the selected view's name (renamable), its captured filters as REMOVABLE
// chips (pruning writes back to the stored view, undoable), its default flag, its place in the order,
// apply, save-current, and delete. Opened from the filter panel's title or the saved-views popover;
// all mutations flow through SavedViewsProvider.
// all mutations flow through the module's saved-views store (useSavedViewsStore).
function ViewRow({
view,
selected,
@@ -1,8 +1,11 @@
import { createContext, useContext } from "react";
import { coerceTagModes, coerceUntagged, type FeedFilters, type SavedView } from "../../lib/api";
// The saved-views context + hook (and the feed blob coercion its consumers share), split out of
// SavedViewsProvider so that file exports only components (Fast Refresh).
// The saved-views contexts + hooks (and the feed blob coercion their consumers share). Each module that
// has saved views (the feed, Plex) owns a store of them (useSavedViewsStore) under its own context, made
// by `createSavedViewsStore`. The shared UI here (popover, chip, modal) reads `useSavedViews()`: the
// views of the module whose page or rail it sits in, which that page or rail hands down with
// `SavedViewsScope`.
// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it
// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration).
@@ -41,10 +44,25 @@ export interface SavedViewsContext {
reorder: (nextIds: number[]) => Promise<void>;
}
export const SavedViewsCtx = createContext<SavedViewsContext | null>(null);
// One module's store: the context its provider fills and the hook its own code (and the shell) reads.
export function createSavedViewsStore(owner: string) {
const Ctx = createContext<SavedViewsContext | null>(null);
Ctx.displayName = `${owner}SavedViews`;
function useModuleSavedViews(): SavedViewsContext {
const ctx = useContext(Ctx);
if (!ctx) throw new Error(`${owner}'s saved views are read outside its store`);
return ctx;
}
return { Provider: Ctx.Provider, useViews: useModuleSavedViews };
}
// The scope the shared UI reads: the views of the module it is rendered for
// (`<SavedViewsScope value={useFeedSavedViews()}>`).
const SavedViewsScopeCtx = createContext<SavedViewsContext | null>(null);
export const SavedViewsScope = SavedViewsScopeCtx.Provider;
export function useSavedViews(): SavedViewsContext {
const ctx = useContext(SavedViewsCtx);
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
const ctx = useContext(SavedViewsScopeCtx);
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsScope");
return ctx;
}
@@ -1,40 +1,21 @@
import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, useEffectEvent, useMemo, useRef, useState } 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 { api, type SavedView } from "../../lib/api";
import { pickDefaultToSeed, preferSticky } from "../../lib/savedViews";
import { notifyActionError } from "../../lib/actionErrors";
import { LS, ownKey } from "../../lib/storage";
import { 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";
import type { SavedViewsContext, 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.
// Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its
// own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the
// feed's active view where it was.
// The per-source wiring the generic logic runs on: the live filters, how to apply a view's, a stable
// The per-module 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 {
export interface SavedViewsConfig {
source: ViewSource;
filters: Record<string, unknown>;
setFilters: (f: Record<string, unknown>) => void;
@@ -44,16 +25,17 @@ interface ViewConfig {
/** 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;
/** Whether to ask for the views now (a signed-in account is required on top of this). */
enabled: boolean;
/** Whether the module's page is the one on screen. Its manage modal belongs to that visit: leaving
* the page closes it, so it does not reopen by itself on the way back. */
onScreen: 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 {
export function storedFiltersPresent(lsKey: string): boolean {
const key = ownKey(lsKey);
if (!key) return true;
try {
@@ -62,55 +44,11 @@ function storedFiltersPresent(lsKey: string): boolean {
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 }) {
export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
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
@@ -120,49 +58,39 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
const viewsQuery = useQuery({
queryKey: qk.savedViews(source),
queryFn: () => api.savedViews(source),
enabled: !!me,
enabled: !!me && cfg.enabled,
});
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
const [stickyId, setStickyId] = useState<number | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// Adjusted during render, not from an effect, so the frame after leaving never commits it open.
if (modalOpen && !cfg.onScreen) setModalOpen(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.
// The ONE default-application path for both modules. On the views query's FIRST resolve, 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. One-shot, so a 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 store mounts.
const seededRef = useRef(false);
// `cfg` changes on every filter edit, so it is read through an effect event: the seed must fire on
// the views query's first resolve 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.
const latestCfg = useEffectEvent(() => cfg);
useEffect(() => {
const data = viewsQuery.data;
if (!data || seededRef.current.has(source)) return;
seededRef.current.add(source);
if (!data || seededRef.current) return;
seededRef.current = true;
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]);
}, [viewsQuery.data]);
const currentKey = cfg.keyOf(cfg.filters);
const activeView = useMemo(
@@ -198,7 +126,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
return ok;
}
const value: SavedViewsContext = {
return {
source,
views,
loading: viewsQuery.isLoading,
@@ -259,6 +187,4 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
},
};
return <SavedViewsCtx.Provider value={value}>{children}</SavedViewsCtx.Provider>;
}
@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import { MODULE_STORES } from "./moduleRegistry";
// Every module's store, mounted once at the root for the whole session, composed from the registry
// instead of nested by hand: a new module's store is its definition's `Store` and nothing here. They sit
// at the root and not inside their pages because they are read from outside them (the palette, the
// notifications, the download tray steering Files) and hold in-memory state — search terms, the Plex
// jump target, the Files focus — that has to outlive a visit to another page.
export function ModuleStores({ children }: { children: ReactNode }) {
return MODULE_STORES.reduceRight<ReactNode>((inner, Store) => <Store>{inner}</Store>, children);
}
@@ -29,8 +29,10 @@ describe("the module registry", () => {
it.each([...PAGES])("loads %s's page (and rail) lazily", (page) => {
// A static import of the page in its definition would put it on the boot path for every
// account; the chunk guard fails the build on that too, this says which module did it.
// account; the chunk guard fails the build on that too, this says which module did it. The one
// static import a definition may make of its own folder is its store (a `…Provider` / `…Store`
// file): that is root-mounted, on the boot path by design.
const source = readFileSync(resolve(SRC, `modules/${page}/module.ts`), "utf8");
expect(source).not.toMatch(/^import [^;]* from "\.\//m);
expect(source).not.toMatch(/^import [^;]* from "\.\/(?!\w+(?:Provider|Store)")/m);
});
});
@@ -97,3 +97,6 @@ export function preloadPages(pages: Page[]): void {
def.Rail?.preload().catch(() => {});
}
}
// The modules' stores (lib/moduleDef `Store`), in registry order: ModuleStores mounts them at the root.
export const MODULE_STORES = ALL.flatMap((m) => (m.Store ? [m.Store] : []));
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
//
// The modules' stores are mounted at the root by ModuleStores, composed from the registry in its order
// (lib/moduleDef `Store`). Two things make that safe, and this pins both: every store a reader needs is
// in the composition (a missing one throws in its first reader — the palette, the notifications, a
// rail), and no store reads another module's store, so the order they happen to nest in can never
// leave one outside a provider it depends on.
import { readFileSync, existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ModuleStores } from "./ModuleStores";
import { REGISTERED_PAGES, moduleDef } from "./moduleRegistry";
import { useFeedFilters, useFeedSavedViews, useFeedView } from "../../modules/feed";
import { usePlex, usePlexSavedViews } from "../../modules/plex";
import { useChannelsActions } from "../../modules/channels";
import { useFilesActions } from "../../modules/files";
import { usePlaylistsActions } from "../../modules/playlists";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
// Each store's read side, as the app's readers reach it (the module faces).
function Readers() {
useFeedFilters();
useFeedView();
useFeedSavedViews();
usePlex();
usePlexSavedViews();
useChannelsActions();
useFilesActions();
usePlaylistsActions();
return <p>all stores in reach</p>;
}
// The static (value) imports of a source file, resolved to files under src/. Type-only imports and
// lazy `import()` are not edges a store's render depends on.
function staticImports(file: string): string[] {
const text = readFileSync(file, "utf8");
const out: string[] = [];
for (const m of text.matchAll(/^import (?!type )[^;]*? from "(\.[^"]+)";/gms)) {
const base = resolve(dirname(file), m[1]!);
const path = [base + ".ts", base + ".tsx", base + "/index.ts", base + "/index.tsx", base].find(
(p) => /\.tsx?$/.test(p) && existsSync(p),
);
if (path) out.push(path);
}
return out;
}
// Every file a module's definition statically pulls in — its store and what the store imports.
function closure(start: string): Set<string> {
const seen = new Set<string>();
const todo = [start];
while (todo.length) {
const f = todo.pop()!;
if (seen.has(f)) continue;
seen.add(f);
todo.push(...staticImports(f));
}
return seen;
}
describe("the module stores", () => {
it("are all in reach under ModuleStores", () => {
const qc = new QueryClient();
const { getByText } = render(
<QueryClientProvider client={qc}>
<ModuleStores>
<Readers />
</ModuleStores>
</QueryClientProvider>,
);
expect(getByText("all stores in reach")).toBeTruthy();
});
const withStore = REGISTERED_PAGES.filter((p) => moduleDef(p).Store);
it.each(withStore)("%s's store reads no other module", (page) => {
const own = resolve(SRC, "modules", page).split("\\").join("/") + "/";
const others = [...closure(resolve(SRC, `modules/${page}/module.ts`))]
.map((f) => f.split("\\").join("/"))
.filter((f) => f.includes("/src/modules/") && !f.startsWith(own));
expect(others).toEqual([]);
});
});
+7
View File
@@ -1,3 +1,4 @@
import type { ComponentType, ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import type { Me } from "./api";
import type { Page } from "./urlState";
@@ -37,4 +38,10 @@ export type ModuleDef = {
// so the floating chrome lines up with the centred content instead of spanning the whole column.
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
maxWidth?: string;
// The module's store, if it has one: the state its page, its rail and its readers elsewhere (the
// palette, the notifications, the header search) share. Mounted at the root for the whole session
// (features/navigation/ModuleStores), so what it holds in memory survives a visit to another page.
// Static, unlike the page: it is on the boot path by design. It reads no other module's store, so
// the order the stores nest in does not matter (moduleStores.contract.test).
Store?: ComponentType<{ children: ReactNode }>;
};
+3 -3
View File
@@ -1,5 +1,5 @@
// Pure selection helpers for saved ("personal") views, split out of SavedViewsProvider so the
// tie-break logic is unit-testable without mounting the provider (its React Query / context deps).
// Pure selection helpers for saved ("personal") views, split out of useSavedViewsStore so the
// tie-break logic is unit-testable without mounting a store (its React Query / context deps).
/** Among the views whose filters match the live filters, pick the one to treat as active: prefer the
* explicitly-applied ("sticky") view when several capture identical filters, so apply(B) isn't
@@ -23,7 +23,7 @@ export function preferSticky<T extends { id: number; is_default?: boolean }>(
* account and — feed only — no URL filter override); otherwise the last-session/URL/manual filters
* win, exactly as they do after any edit. First `is_default` wins if somehow several are flagged, so
* the choice is deterministic. This is the single default-application path for BOTH the feed and Plex
* (SavedViewsProvider drives it on the views query's first resolve) — it replaced the feed-only
* (useSavedViewsStore drives it on the views query's first resolve) — it replaced the feed-only
* localStorage mirror that raced the login effect and left Plex with no default at all. */
export function pickDefaultToSeed<T extends { is_default: boolean }>(
views: T[],
+3 -21
View File
@@ -7,11 +7,7 @@ import ErrorBoundary from "./shell/ErrorBoundary";
import { ConfirmProvider } from "./components/ui/ConfirmProvider";
import BootGate, { BootScreen } from "./shell/BootGate";
import { NavigationProvider } from "./features/navigation/NavigationProvider";
import { FeedFiltersProvider, FeedViewProvider, SavedViewsProvider } from "./modules/feed";
import { PlaylistsProvider } from "./modules/playlists";
import { FilesProvider } from "./modules/files";
import { PlexProvider } from "./modules/plex";
import { ChannelsProvider } from "./modules/channels";
import { ModuleStores } from "./features/navigation/ModuleStores";
import { PrefsProvider } from "./features/prefs/PrefsProvider";
import { WizardProvider } from "./features/wizard/WizardProvider";
import "./i18n";
@@ -77,15 +73,7 @@ const root =
seeds per-account state in its initializer (see BootGate). */}
<BootGate>
<NavigationProvider>
<FeedFiltersProvider>
<PlaylistsProvider>
<FilesProvider>
<PlexProvider>
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
source (feed ⟷ plex) by the active page and read the right live filters. */}
<SavedViewsProvider>
<ChannelsProvider>
<FeedViewProvider>
<ModuleStores>
<PrefsProvider>
<WizardProvider>
{/* The same quiet screen as the gate's, so a chunk that is still
@@ -95,13 +83,7 @@ const root =
</Suspense>
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</SavedViewsProvider>
</PlexProvider>
</FilesProvider>
</PlaylistsProvider>
</FeedFiltersProvider>
</ModuleStores>
</NavigationProvider>
</BootGate>
</ConfirmProvider>
+6 -3
View File
@@ -58,7 +58,12 @@ import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
import { optionsFromColumns } from "../../lib/sortOptions";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "../../features/channel/TagManager";
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
import {
useChannelLayout,
useChannels,
useChannelsActions,
type ChannelsView,
} from "./channelsContext";
import { useNavigationActions } from "../../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "../feed";
import { useWizard } from "../../features/wizard/wizardContext";
@@ -66,8 +71,6 @@ import { useMe } from "../../lib/useMe";
import { useTags } from "../../lib/useTags";
import ChannelsHeader from "./ChannelsHeader";
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
// The tag filter has its own chip row above the list, so the hidden-filter cue does not name it again.
const SURFACED_FILTERS = ["tags"];
@@ -24,13 +24,13 @@ import {
} from "../../lib/channelsUrlState";
import type { FilterMap } from "../../lib/tableColumns";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import type { ChannelsView } from "./Channels";
import {
ChannelLayoutContext,
ChannelsActionsContext,
ChannelsStateContext,
type ChannelsActions,
type ChannelsState,
type ChannelsView,
} from "./channelsContext";
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
@@ -2,11 +2,13 @@ import { createContext, useContext } from "react";
import type { ChannelLayout } from "../../lib/channelLayout";
import type { SortState } from "../../lib/columnSort";
import type { FilterMap } from "../../lib/tableColumns";
import type { ChannelsView } from "./Channels";
// The Channels module's contexts + hooks, split out of ChannelsProvider so that file exports only
// components (Fast Refresh). The provider owns the state; this is the read side every consumer imports.
// The manager's tabs.
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
export type ChannelsState = {
search: string;
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
-1
View File
@@ -1,6 +1,5 @@
// The channel manager's public face: its store, mounted at the root and read app-wide, and the sync
// chip, which the feed's header shows too.
export { ChannelsProvider } from "./ChannelsProvider";
export { useChannelsActions } from "./channelsContext";
export { default as SyncStatus } from "./SyncStatus";
export { channelsModule } from "./module";
+2
View File
@@ -1,6 +1,7 @@
import { Tv } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { ChannelsProvider } from "./ChannelsProvider";
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
export const channelsModule: ModuleDef = {
@@ -11,4 +12,5 @@ export const channelsModule: ModuleDef = {
icon: Tv,
Page: lazyWithPreload(() => import("./Channels")),
maxWidth: "96rem",
Store: ChannelsProvider,
};
+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,30 +1,18 @@
// @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 h = vi.hoisted(() => ({
page: "feed" as "feed" | "plex",
setFeedFilters: vi.fn(),
setPlexFilters: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../../features/navigation/navigationContext", () => ({
useNavigation: () => ({ page: h.page }),
}));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({
const BASE = {
tags: [],
channelIds: [],
tagModes: { language: "or", topic: "or", other: "or" },
@@ -35,20 +23,33 @@ vi.mock("./feedFiltersContext", () => ({
includeShorts: false,
includeLive: false,
show: "unwatched",
}),
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
};
const h = vi.hoisted(() => ({
page: "feed" as string,
filters: {} as Record<string, unknown>,
setFeedFilters: vi.fn(),
}));
vi.mock("../plex/plexContext", () => ({
usePlex: () => ({ filters: {} }),
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../../features/navigation/navigationContext", () => ({
useNavigation: () => ({ page: h.page }),
}));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => h.filters,
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
}));
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>
);
}
+7 -3
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,6 +225,7 @@ export default function Sidebar() {
}));
return (
<SavedViewsScope value={savedViews}>
<FilterRail
kind={source}
title={t("sidebar.filters")}
@@ -242,12 +245,13 @@ export default function Sidebar() {
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
) : undefined
}
onTitleClick={source === "feed" ? openModal : 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
-1
View File
@@ -1,5 +1,4 @@
// The Files page's public face: its store, mounted at the root, and how a folder is named and pictured.
export { FilesProvider } from "./FilesProvider";
export { useFilesActions } from "./filesContext";
export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders";
export { filesModule } from "./module";
+2
View File
@@ -1,6 +1,7 @@
import { FolderOpen } from "lucide-react";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { FilesProvider } from "./FilesProvider";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
const Rail = lazyWithPreload(() => import("./FilesRail"));
@@ -14,4 +15,5 @@ export const filesModule: ModuleDef = {
Rail,
Page: lazyWithPreloadAlongside(() => import("./Files"), Rail),
maxWidth: "80rem",
Store: FilesProvider,
};
-1
View File
@@ -1,4 +1,3 @@
// The playlists page's public face: its store, mounted at the root and read app-wide.
export { PlaylistsProvider } from "./PlaylistsProvider";
export { usePlaylistsActions } from "./playlistsContext";
export { playlistsModule } from "./module";
+2
View File
@@ -1,6 +1,7 @@
import { ListVideo } from "lucide-react";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { PlaylistsProvider } from "./PlaylistsProvider";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
const Rail = lazyWithPreload(() => import("./PlaylistsRail"));
@@ -14,4 +15,5 @@ export const playlistsModule: ModuleDef = {
Rail,
Page: lazyWithPreloadAlongside(() => import("./Playlists"), Rail),
maxWidth: "64rem",
Store: PlaylistsProvider,
};
@@ -24,6 +24,12 @@ vi.mock("./plexViews", async (importOriginal) => ({
PlexInfoView: ({ id }: { id: string }) => <div>info page {id}</div>,
}));
// The page hands Plex's saved views to its chip row; none are saved here.
vi.mock("./plexSavedViews", async (importOriginal) => ({
...(await importOriginal<typeof import("./plexSavedViews")>()),
usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }),
}));
import PlexBrowse from "./PlexBrowse";
import { api, type PlexCard } from "../../lib/api";
+6 -2
View File
@@ -37,6 +37,8 @@ import { menuGroups } from "../../components/ui/menuGroups";
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
import ActiveFilterChips from "../../features/filters/ActiveFilterChips";
import SelectedViewChip from "../../features/filters/SelectedViewChip";
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
import { usePlexSavedViews } from "./plexSavedViews";
import ItemCount from "../../components/ui/ItemCount";
import { FilterFoldButton } from "../../components/ui/FilterFold";
import { useFilterFold } from "../../lib/useFilterFold";
@@ -101,11 +103,13 @@ export default function PlexBrowse() {
// history, and entering the plex page mounts this anew, so each entry reseeds it (a page switch
// clears ?v and drops _sub: every plex entry starts at the Discover root).
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
// Plex's saved views reach the shared views UI under this page (the chip) by the scope.
const savedViews = usePlexSavedViews();
return (
<>
<SavedViewsScope value={savedViews}>
<PlexHeader view={{ kind: sub.view.kind, open: sub.open }} />
<PlexBrowseView sub={sub} />
</>
</SavedViewsScope>
);
}
@@ -2,7 +2,7 @@
//
// A shared Plex link is persisted to the account, but only the dimensions it CARRIES. The bug it
// replaced: a link with only a scope also wrote the (empty) default filters, and a stored Plex filters
// value at all is what tells SavedViewsProvider the account has picked filters — so the account's
// value at all is what tells PlexSavedViewsProvider the account has picked filters — so the account's
// default saved view was never applied again.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { render, cleanup } from "@testing-library/react";
+1 -1
View File
@@ -142,7 +142,7 @@ export function PlexProvider({ children }: { children: ReactNode }) {
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
// later plain reload. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. Only the dimensions
// the link CARRIED are written: an omitted one already reads from storage, and writing its default
// would not be a no-op — a stored Plex filters value at all is what tells SavedViewsProvider the
// would not be a no-op — a stored Plex filters value at all is what tells PlexSavedViewsProvider the
// account has picked filters, so the account's default saved view would never be applied again.
useEffect(() => {
if (!urlSeed) return;
@@ -0,0 +1,101 @@
// @vitest-environment jsdom
//
// Plex's saved views: the same default-view seed as the feed's (parity — Plex once never applied its
// default at all), asked for only while the Plex page is on screen. Plex's 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";
const h = vi.hoisted(() => ({ page: "plex" as string, setPlexFilters: vi.fn() }));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../../features/navigation/navigationContext", () => ({
useNavigation: () => ({ page: h.page }),
}));
vi.mock("./plexContext", () => ({
usePlex: () => ({ filters: {} }),
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
import { PlexSavedViewsProvider } from "./PlexSavedViewsProvider";
import { usePlexSavedViews } from "./plexSavedViews";
import { api, type SavedView } from "../../lib/api";
import { claimLocalData, LS } from "../../lib/storage";
import { qk } from "../../lib/queryKeys";
const ID = 7;
const view = (over: Partial<SavedView>): SavedView => ({
id: 1,
name: "v",
filters: {},
position: 0,
is_default: false,
...over,
});
function Probe() {
const { loading } = usePlexSavedViews();
return <div data-testid="loading">{String(loading)}</div>;
}
function mount(views: SavedView[]) {
const spy = vi
.spyOn(api, "savedViews")
.mockImplementation(async () => views.map((v) => ({ ...v })));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: ID, preferences: {} });
const tree = () => (
<QueryClientProvider client={qc}>
<PlexSavedViewsProvider>
<Probe />
</PlexSavedViewsProvider>
</QueryClientProvider>
);
const r = render(tree());
return { spy, rerender: () => r.rerender(tree()) };
}
beforeEach(() => {
localStorage.clear();
claimLocalData(ID);
h.page = "plex";
h.setPlexFilters.mockClear();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("Plex's saved views", () => {
it("seeds the starred default into a fresh Plex (parity with the feed)", async () => {
mount([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
});
it("does NOT seed when Plex filters are already stored", async () => {
localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] }));
mount([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
expect(h.setPlexFilters).not.toHaveBeenCalled();
});
it("asks for nothing until the Plex page is on screen", async () => {
h.page = "feed";
const { spy, rerender } = mount([]);
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
expect(spy).not.toHaveBeenCalled();
h.page = "plex";
rerender();
await waitFor(() => expect(spy).toHaveBeenCalledWith("plex"));
});
});
@@ -0,0 +1,45 @@
import { useMemo, type ReactNode } from "react";
import { coercePlexFilters, plexFiltersSignature, type PlexFilters } from "../../lib/api";
import { LS } from "../../lib/storage";
import { useNavigation } from "../../features/navigation/navigationContext";
import {
storedFiltersPresent,
useSavedViewsStore,
type SavedViewsConfig,
} from "../../features/filters/useSavedViewsStore";
import { usePlex, usePlexActions } from "./plexContext";
import { PlexSavedViewsContextProvider } from "./plexSavedViews";
const keyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
const asFilters = (raw: Record<string, unknown>) =>
coercePlexFilters(raw) as unknown as Record<string, unknown>;
// Plex has no transient field to drop before saving.
const stripForSave = (f: Record<string, unknown>) => f;
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
const canSeedDefault = () => !storedFiltersPresent(LS.plexFilters);
// Plex's saved views, over Plex's live filters (useSavedViewsStore holds the logic). Asked for only while
// the Plex page is on screen, so an account that never opens Plex never fetches them, and the starred
// default seeds on the first visit.
export function PlexSavedViewsProvider({ children }: { children: ReactNode }) {
const { page } = useNavigation();
const { filters } = usePlex();
const { setFilters } = usePlexActions();
const onScreen = page === "plex";
const cfg: SavedViewsConfig = useMemo(
() => ({
source: "plex",
filters: filters as unknown as Record<string, unknown>,
setFilters: (f) => setFilters(f as unknown as PlexFilters),
keyOf,
asFilters,
stripForSave,
canSeedDefault,
enabled: onScreen,
onScreen,
}),
[filters, setFilters, onScreen],
);
const value = useSavedViewsStore(cfg);
return <PlexSavedViewsContextProvider value={value}>{children}</PlexSavedViewsContextProvider>;
}
+8 -5
View File
@@ -17,7 +17,8 @@ import FilterChipGroup from "../../components/ui/FilterChipGroup";
import Combobox, { type ComboboxOption } from "../../components/ui/Combobox";
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
import { useSavedViews } from "../../features/filters/savedViewsContext";
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
import { usePlexSavedViews } from "./plexSavedViews";
import { usePlex, usePlexActions } from "./plexContext";
import { DismissibleChip } from "../../components/ui/Chip";
import { Input } from "../../components/ui/Input";
@@ -47,7 +48,9 @@ export default function PlexSidebar() {
const { setFilters } = usePlexActions();
// Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the
// title opens the full manager modal (same shared components as the feed).
const { openModal, modalOpen } = useSavedViews();
// They reach the shared views UI in this rail through the scope below.
const savedViews = usePlexSavedViews();
const { openModal, modalOpen } = savedViews;
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
const source: MediaSourceKind = "plex";
@@ -293,7 +296,7 @@ export default function PlexSidebar() {
];
return (
<>
<SavedViewsScope value={savedViews}>
<FilterRail
kind={source}
title={t("sidebar.filters")}
@@ -308,14 +311,14 @@ export default function PlexSidebar() {
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
collapsible={false}
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal.
// Same shared components as the feed rail (SavedViewsProvider switches source).
// The same shared components as the feed rail, over Plex's own views.
iconExpanded={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
onTitleClick={openModal}
titleActionLabel={t("views.manageTitle")}
/>
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
{modalOpen && <PersonalViewsModal />}
</>
</SavedViewsScope>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
import { PlexProvider } from "./PlexProvider";
import { PlexSavedViewsProvider } from "./PlexSavedViewsProvider";
// The Plex module's store, mounted at the root (features/navigation/ModuleStores): its browse state, and
// its saved views over that state's filters.
export function PlexStore({ children }: { children: ReactNode }) {
return (
<PlexProvider>
<PlexSavedViewsProvider>{children}</PlexSavedViewsProvider>
</PlexProvider>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
// The Plex module's public face: its store, mounted at the root and read app-wide, and how its filters read as chips.
export { PlexProvider } from "./PlexProvider";
export { usePlexSavedViews } from "./plexSavedViews";
export { usePlex, usePlexActions } from "./plexContext";
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
export { plexModule } from "./module";
+2
View File
@@ -1,6 +1,7 @@
import { Clapperboard } from "lucide-react";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { PlexStore } from "./PlexStore";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
const Rail = lazyWithPreload(() => import("./PlexSidebar"));
@@ -15,4 +16,5 @@ export const plexModule: ModuleDef = {
Rail,
Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail),
maxWidth: "100rem",
Store: PlexStore,
};
@@ -0,0 +1,7 @@
import { createSavedViewsStore } from "../../features/filters/savedViewsContext";
// Plex's saved views: filled by PlexSavedViewsProvider, read by the Plex page and rail (which hand them
// to the shared views UI) and by the palette.
const store = createSavedViewsStore("Plex");
export const PlexSavedViewsContextProvider = store.Provider;
export const usePlexSavedViews = store.useViews;
@@ -43,12 +43,20 @@ vi.mock("../lib/keymap", () => ({
toggleKeyboardHelp: vi.fn(),
}));
// Each module's saved views, and the page the palette opens over.
const views = {
feed: [] as { id: number; name: string }[],
plex: [] as { id: number; name: string }[],
};
const nav = { page: "feed" };
const calls: string[] = [];
const setPage = vi.fn((page: string, then?: () => void) => {
calls.push(`setPage:${page}`);
then?.();
});
vi.mock("../features/navigation/navigationContext", () => ({
useNavigation: () => ({ page: nav.page }),
useNavigationActions: () => ({ setPage, openChannel: vi.fn() }),
}));
vi.mock("../modules/playlists/playlistsContext", () => ({
@@ -59,8 +67,13 @@ vi.mock("../modules/playlists/playlistsContext", () => ({
vi.mock("../features/prefs/prefsContext", () => ({
usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }),
}));
vi.mock("../features/filters/savedViewsContext", () => ({
useSavedViews: () => ({ views: [], apply: vi.fn() }),
vi.mock("../modules/feed/feedSavedViews", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useFeedSavedViews: () => ({ views: views.feed, apply: vi.fn() }),
}));
vi.mock("../modules/plex/plexSavedViews", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
usePlexSavedViews: () => ({ views: views.plex, apply: vi.fn() }),
}));
const requestSurprise = vi.fn();
vi.mock("../lib/homeIntent", () => ({ requestSurprise: () => requestSurprise() }));
@@ -96,6 +109,9 @@ beforeEach(() => {
requestSurprise.mockClear();
channels.mockReset().mockResolvedValue([]);
playlists.mockReset().mockResolvedValue([]);
views.feed = [];
views.plex = [];
nav.page = "feed";
});
afterEach(cleanup);
@@ -114,6 +130,18 @@ describe("AppCommandPalette", () => {
expect(ids("modules")).toContain("page:config");
});
it("offers Plex's saved views over the Plex page, and the feed's over any other", () => {
views.feed = [{ id: 1, name: "feed view" }];
views.plex = [{ id: 2, name: "plex view" }];
nav.page = "plex";
mount();
expect(ids("views")).toEqual(["view:2"]);
cleanup();
nav.page = "home";
mount();
expect(ids("views")).toEqual(["view:1"]);
});
it("leaves out the channels the user has hidden", async () => {
channels.mockResolvedValue([
{ id: "UC1", title: "Shown", handle: "@shown", hidden: false },
+9 -5
View File
@@ -23,10 +23,11 @@ import { requestSurprise } from "../lib/homeIntent";
import { requestLinkBox } from "../lib/downloadIntake";
import { openRecorderStart } from "../lib/debugRecorder";
import { useDebugRecording } from "../features/diagnostics/useDebugRecording";
import { useNavigationActions } from "../features/navigation/navigationContext";
import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext";
import { useFeedSavedViews } from "../modules/feed";
import { usePlaylistsActions } from "../modules/playlists";
import { usePlexSavedViews } from "../modules/plex";
import { usePrefs } from "../features/prefs/prefsContext";
import { useSavedViews } from "../features/filters/savedViewsContext";
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
@@ -37,8 +38,8 @@ import { useSavedViews } from "../features/filters/savedViewsContext";
// the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it
// would have saved.
//
// Saved views come from whichever module owns the current SavedViewsProvider source (feed on the
// feed, Plex on Plex), i.e. exactly the views that are applicable where you stand.
// Saved views are Plex's on the Plex page and the feed's everywhere else, i.e. the views that are
// applicable where you stand.
export default function AppCommandPalette() {
const { t } = useTranslation();
const open = usePaletteOpen();
@@ -46,7 +47,10 @@ export default function AppCommandPalette() {
const { setPage, openChannel } = useNavigationActions();
const { setSelectedId } = usePlaylistsActions();
const { theme, setTheme } = usePrefs();
const savedViews = useSavedViews();
const { page } = useNavigation();
const feedViews = useFeedSavedViews();
const plexViews = usePlexSavedViews();
const savedViews = page === "plex" ? plexViews : feedViews;
const { session: recordingSession, stop: stopRecording, markStep } = useDebugRecording();
const stopMutate = stopRecording.mutate;