diff --git a/CLAUDE.md b/CLAUDE.md index 9a119a62..3ff78371 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//`** (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//`** (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`, diff --git a/frontend/src/features/filters/PersonalViewsModal.dom.test.tsx b/frontend/src/features/filters/PersonalViewsModal.dom.test.tsx index 4589fb23..9447e587 100644 --- a/frontend/src/features/filters/PersonalViewsModal.dom.test.tsx +++ b/frontend/src/features/filters/PersonalViewsModal.dom.test.tsx @@ -63,7 +63,8 @@ const CTX = { toggleDefault: vi.fn(async () => {}), }; -vi.mock("./savedViewsContext", () => ({ +vi.mock("./savedViewsContext", async (importOriginal) => ({ + ...(await importOriginal()), useSavedViews: () => ({ ...CTX, activeViewId: h.activeViewId, diff --git a/frontend/src/features/filters/PersonalViewsModal.tsx b/frontend/src/features/filters/PersonalViewsModal.tsx index d0abed22..5b062547 100644 --- a/frontend/src/features/filters/PersonalViewsModal.tsx +++ b/frontend/src/features/filters/PersonalViewsModal.tsx @@ -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, diff --git a/frontend/src/features/filters/savedViewsContext.ts b/frontend/src/features/filters/savedViewsContext.ts index 49752f94..0d875c2d 100644 --- a/frontend/src/features/filters/savedViewsContext.ts +++ b/frontend/src/features/filters/savedViewsContext.ts @@ -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; } -export const SavedViewsCtx = createContext(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(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 +// (``). +const SavedViewsScopeCtx = createContext(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; } diff --git a/frontend/src/modules/feed/SavedViewsProvider.tsx b/frontend/src/features/filters/useSavedViewsStore.ts similarity index 50% rename from frontend/src/modules/feed/SavedViewsProvider.tsx rename to frontend/src/features/filters/useSavedViewsStore.ts index edb27d4d..cc599934 100644 --- a/frontend/src/modules/feed/SavedViewsProvider.tsx +++ b/frontend/src/features/filters/useSavedViewsStore.ts @@ -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; setFilters: (f: Record) => 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) => - filtersToParams(asFilters(f as unknown as FeedFilters)).toString(); -const plexKeyOf = (f: Record) => 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, - setFilters: (f) => setPlexFilters(f as unknown as PlexFilters), - keyOf: plexKeyOf, - asFilters: (raw) => coercePlexFilters(raw) as unknown as Record, - stripForSave: (f) => f, // Plex has no transient field to drop - canSeedDefault: plexCanSeedDefault, - }; - } - return { - source: "feed", - filters: feedFilters as unknown as Record, - setFilters: (f) => setFeedFilters(f as unknown as FeedFilters), - keyOf: feedKeyOf, - asFilters: (raw) => - asFilters(raw as unknown as FeedFilters) as unknown as Record, - // 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(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>(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 {children}; } diff --git a/frontend/src/features/navigation/ModuleStores.tsx b/frontend/src/features/navigation/ModuleStores.tsx new file mode 100644 index 00000000..7df118cc --- /dev/null +++ b/frontend/src/features/navigation/ModuleStores.tsx @@ -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((inner, Store) => {inner}, children); +} diff --git a/frontend/src/features/navigation/moduleRegistry.contract.test.ts b/frontend/src/features/navigation/moduleRegistry.contract.test.ts index f7c6a4df..9087a952 100644 --- a/frontend/src/features/navigation/moduleRegistry.contract.test.ts +++ b/frontend/src/features/navigation/moduleRegistry.contract.test.ts @@ -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); }); }); diff --git a/frontend/src/features/navigation/moduleRegistry.ts b/frontend/src/features/navigation/moduleRegistry.ts index b94755be..2f557273 100644 --- a/frontend/src/features/navigation/moduleRegistry.ts +++ b/frontend/src/features/navigation/moduleRegistry.ts @@ -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] : [])); diff --git a/frontend/src/features/navigation/moduleStores.contract.test.tsx b/frontend/src/features/navigation/moduleStores.contract.test.tsx new file mode 100644 index 00000000..ff908bbd --- /dev/null +++ b/frontend/src/features/navigation/moduleStores.contract.test.tsx @@ -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()), + 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

all stores in reach

; +} + +// 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 { + const seen = new Set(); + 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( + + + + + , + ); + 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([]); + }); +}); diff --git a/frontend/src/lib/moduleDef.ts b/frontend/src/lib/moduleDef.ts index d8644d79..207924d7 100644 --- a/frontend/src/lib/moduleDef.ts +++ b/frontend/src/lib/moduleDef.ts @@ -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 }>; }; diff --git a/frontend/src/lib/savedViews.ts b/frontend/src/lib/savedViews.ts index eaafc7c7..6619a678 100644 --- a/frontend/src/lib/savedViews.ts +++ b/frontend/src/lib/savedViews.ts @@ -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( * 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( views: T[], diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 63b659ea..8a439655 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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,31 +73,17 @@ const root = seeds per-account state in its initializer (see BootGate). */} - - - - - {/* SavedViewsProvider sits below BOTH filter providers so it can switch its - source (feed ⟷ plex) by the active page and read the right live filters. */} - - - - - - {/* The same quiet screen as the gate's, so a chunk that is still - loading when the gate opens continues it instead of blanking. */} - }> - - - - - - - - - - - + + + + {/* The same quiet screen as the gate's, so a chunk that is still + loading when the gate opens continues it instead of blanking. */} + }> + + + + + diff --git a/frontend/src/modules/channels/Channels.tsx b/frontend/src/modules/channels/Channels.tsx index 86f94707..bf8fa9d6 100644 --- a/frontend/src/modules/channels/Channels.tsx +++ b/frontend/src/modules/channels/Channels.tsx @@ -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"]; diff --git a/frontend/src/modules/channels/ChannelsProvider.tsx b/frontend/src/modules/channels/ChannelsProvider.tsx index e9a78d0c..0bfc05d1 100644 --- a/frontend/src/modules/channels/ChannelsProvider.tsx +++ b/frontend/src/modules/channels/ChannelsProvider.tsx @@ -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 diff --git a/frontend/src/modules/channels/channelsContext.ts b/frontend/src/modules/channels/channelsContext.ts index b154da0f..47828f36 100644 --- a/frontend/src/modules/channels/channelsContext.ts +++ b/frontend/src/modules/channels/channelsContext.ts @@ -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 diff --git a/frontend/src/modules/channels/index.ts b/frontend/src/modules/channels/index.ts index 2dd9e0be..6fe2f689 100644 --- a/frontend/src/modules/channels/index.ts +++ b/frontend/src/modules/channels/index.ts @@ -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"; diff --git a/frontend/src/modules/channels/module.ts b/frontend/src/modules/channels/module.ts index 2e079a6e..8a2f4edd 100644 --- a/frontend/src/modules/channels/module.ts +++ b/frontend/src/modules/channels/module.ts @@ -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, }; diff --git a/frontend/src/modules/feed/FeedPage.tsx b/frontend/src/modules/feed/FeedPage.tsx index 8ff03af0..fb1340f8 100644 --- a/frontend/src/modules/feed/FeedPage.tsx +++ b/frontend/src/modules/feed/FeedPage.tsx @@ -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 ( - <> + - {modalOpen && } - + {savedViews.modalOpen && } + ); } diff --git a/frontend/src/modules/feed/SavedViewsProvider.dom.test.tsx b/frontend/src/modules/feed/FeedSavedViewsProvider.dom.test.tsx similarity index 57% rename from frontend/src/modules/feed/SavedViewsProvider.dom.test.tsx rename to frontend/src/modules/feed/FeedSavedViewsProvider.dom.test.tsx index 8be872b9..24b7f4eb 100644 --- a/frontend/src/modules/feed/SavedViewsProvider.dom.test.tsx +++ b/frontend/src/modules/feed/FeedSavedViewsProvider.dom.test.tsx @@ -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, 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 => ({ @@ -60,11 +61,17 @@ const view = (over: Partial): 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 ( -
- {String(loading)} +
+ {String(store.loading)}
); } @@ -76,41 +83,46 @@ function signedInClient() { return qc; } +function tree(qc: QueryClient) { + return ( + + + + + + ); +} + 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( - - - - - , - ); + 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( - - - - - , - ); + 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( - - - - - , - ); + 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)); diff --git a/frontend/src/modules/feed/FeedSavedViewsProvider.tsx b/frontend/src/modules/feed/FeedSavedViewsProvider.tsx new file mode 100644 index 00000000..e3588810 --- /dev/null +++ b/frontend/src/modules/feed/FeedSavedViewsProvider.tsx @@ -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) => + 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, + setFilters: (f) => setFilters(f as unknown as FeedFilters), + keyOf, + asFilters: (raw) => + asFilters(raw as unknown as FeedFilters) as unknown as Record, + // 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 {children}; +} diff --git a/frontend/src/modules/feed/FeedStore.tsx b/frontend/src/modules/feed/FeedStore.tsx new file mode 100644 index 00000000..419c4525 --- /dev/null +++ b/frontend/src/modules/feed/FeedStore.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/frontend/src/modules/feed/Sidebar.tsx b/frontend/src/modules/feed/Sidebar.tsx index d9a68176..529725c8 100644 --- a/frontend/src/modules/feed/Sidebar.tsx +++ b/frontend/src/modules/feed/Sidebar.tsx @@ -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 ( - } - 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" ? ( - } /> - ) : undefined - } - onTitleClick={source === "feed" ? openModal : undefined} - titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined} - > - {tagManagerOpen && ( - setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> - )} - + + } + 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" ? ( + } /> + ) : undefined + } + onTitleClick={source === "feed" ? savedViews.openModal : undefined} + titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined} + > + {tagManagerOpen && ( + setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> + )} + + ); } diff --git a/frontend/src/modules/feed/feedFiltersInit.ts b/frontend/src/modules/feed/feedFiltersInit.ts index dd7f11a0..c203c476 100644 --- a/frontend/src/modules/feed/feedFiltersInit.ts +++ b/frontend/src/modules/feed/feedFiltersInit.ts @@ -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); diff --git a/frontend/src/modules/feed/feedSavedViews.ts b/frontend/src/modules/feed/feedSavedViews.ts new file mode 100644 index 00000000..dea02a35 --- /dev/null +++ b/frontend/src/modules/feed/feedSavedViews.ts @@ -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; diff --git a/frontend/src/modules/feed/index.ts b/frontend/src/modules/feed/index.ts index e3ab4cc0..f0432d85 100644 --- a/frontend/src/modules/feed/index.ts +++ b/frontend/src/modules/feed/index.ts @@ -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"; diff --git a/frontend/src/modules/feed/module.ts b/frontend/src/modules/feed/module.ts index e2e83171..51f7a162 100644 --- a/frontend/src/modules/feed/module.ts +++ b/frontend/src/modules/feed/module.ts @@ -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 diff --git a/frontend/src/modules/files/index.ts b/frontend/src/modules/files/index.ts index 0a5b5c4f..d1def32d 100644 --- a/frontend/src/modules/files/index.ts +++ b/frontend/src/modules/files/index.ts @@ -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"; diff --git a/frontend/src/modules/files/module.ts b/frontend/src/modules/files/module.ts index 24e48dd0..4c3d798a 100644 --- a/frontend/src/modules/files/module.ts +++ b/frontend/src/modules/files/module.ts @@ -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, }; diff --git a/frontend/src/modules/playlists/index.ts b/frontend/src/modules/playlists/index.ts index 0f54f916..2a988f81 100644 --- a/frontend/src/modules/playlists/index.ts +++ b/frontend/src/modules/playlists/index.ts @@ -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"; diff --git a/frontend/src/modules/playlists/module.ts b/frontend/src/modules/playlists/module.ts index f8536088..4fba72dd 100644 --- a/frontend/src/modules/playlists/module.ts +++ b/frontend/src/modules/playlists/module.ts @@ -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, }; diff --git a/frontend/src/modules/plex/PlexBrowse.playShow.dom.test.tsx b/frontend/src/modules/plex/PlexBrowse.playShow.dom.test.tsx index 740b2d15..18af5433 100644 --- a/frontend/src/modules/plex/PlexBrowse.playShow.dom.test.tsx +++ b/frontend/src/modules/plex/PlexBrowse.playShow.dom.test.tsx @@ -24,6 +24,12 @@ vi.mock("./plexViews", async (importOriginal) => ({ PlexInfoView: ({ id }: { id: string }) =>
info page {id}
, })); +// The page hands Plex's saved views to its chip row; none are saved here. +vi.mock("./plexSavedViews", async (importOriginal) => ({ + ...(await importOriginal()), + usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }), +})); + import PlexBrowse from "./PlexBrowse"; import { api, type PlexCard } from "../../lib/api"; diff --git a/frontend/src/modules/plex/PlexBrowse.tsx b/frontend/src/modules/plex/PlexBrowse.tsx index 6f06a865..28dabe9e 100644 --- a/frontend/src/modules/plex/PlexBrowse.tsx +++ b/frontend/src/modules/plex/PlexBrowse.tsx @@ -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({ 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 ( - <> + - + ); } diff --git a/frontend/src/modules/plex/PlexProvider.dom.test.tsx b/frontend/src/modules/plex/PlexProvider.dom.test.tsx index 5d4ec661..702bf7e8 100644 --- a/frontend/src/modules/plex/PlexProvider.dom.test.tsx +++ b/frontend/src/modules/plex/PlexProvider.dom.test.tsx @@ -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"; diff --git a/frontend/src/modules/plex/PlexProvider.tsx b/frontend/src/modules/plex/PlexProvider.tsx index b07fc0cc..6afe3f76 100644 --- a/frontend/src/modules/plex/PlexProvider.tsx +++ b/frontend/src/modules/plex/PlexProvider.tsx @@ -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; diff --git a/frontend/src/modules/plex/PlexSavedViewsProvider.dom.test.tsx b/frontend/src/modules/plex/PlexSavedViewsProvider.dom.test.tsx new file mode 100644 index 00000000..8050e494 --- /dev/null +++ b/frontend/src/modules/plex/PlexSavedViewsProvider.dom.test.tsx @@ -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(); + 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 => ({ + id: 1, + name: "v", + filters: {}, + position: 0, + is_default: false, + ...over, +}); + +function Probe() { + const { loading } = usePlexSavedViews(); + return
{String(loading)}
; +} + +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 = () => ( + + + + + + ); + 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")); + }); +}); diff --git a/frontend/src/modules/plex/PlexSavedViewsProvider.tsx b/frontend/src/modules/plex/PlexSavedViewsProvider.tsx new file mode 100644 index 00000000..83acd1f6 --- /dev/null +++ b/frontend/src/modules/plex/PlexSavedViewsProvider.tsx @@ -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) => plexFiltersSignature(coercePlexFilters(f)); +const asFilters = (raw: Record) => + coercePlexFilters(raw) as unknown as Record; +// Plex has no transient field to drop before saving. +const stripForSave = (f: Record) => 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, + setFilters: (f) => setFilters(f as unknown as PlexFilters), + keyOf, + asFilters, + stripForSave, + canSeedDefault, + enabled: onScreen, + onScreen, + }), + [filters, setFilters, onScreen], + ); + const value = useSavedViewsStore(cfg); + return {children}; +} diff --git a/frontend/src/modules/plex/PlexSidebar.tsx b/frontend/src/modules/plex/PlexSidebar.tsx index dbcb9cf7..4271e1ca 100644 --- a/frontend/src/modules/plex/PlexSidebar.tsx +++ b/frontend/src/modules/plex/PlexSidebar.tsx @@ -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 ( - <> + } />} onTitleClick={openModal} titleActionLabel={t("views.manageTitle")} /> {/* The manager modal lives here (rail is always mounted on plex); Modal portals to . */} {modalOpen && } - + ); } diff --git a/frontend/src/modules/plex/PlexStore.tsx b/frontend/src/modules/plex/PlexStore.tsx new file mode 100644 index 00000000..c757e37c --- /dev/null +++ b/frontend/src/modules/plex/PlexStore.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/src/modules/plex/index.ts b/frontend/src/modules/plex/index.ts index 104b4407..dbda14d6 100644 --- a/frontend/src/modules/plex/index.ts +++ b/frontend/src/modules/plex/index.ts @@ -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"; diff --git a/frontend/src/modules/plex/module.ts b/frontend/src/modules/plex/module.ts index 8f350205..4d2f9b70 100644 --- a/frontend/src/modules/plex/module.ts +++ b/frontend/src/modules/plex/module.ts @@ -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, }; diff --git a/frontend/src/modules/plex/plexSavedViews.ts b/frontend/src/modules/plex/plexSavedViews.ts new file mode 100644 index 00000000..9aca5f5e --- /dev/null +++ b/frontend/src/modules/plex/plexSavedViews.ts @@ -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; diff --git a/frontend/src/shell/AppCommandPalette.dom.test.tsx b/frontend/src/shell/AppCommandPalette.dom.test.tsx index e9dc09f5..62dd4b01 100644 --- a/frontend/src/shell/AppCommandPalette.dom.test.tsx +++ b/frontend/src/shell/AppCommandPalette.dom.test.tsx @@ -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>()), + useFeedSavedViews: () => ({ views: views.feed, apply: vi.fn() }), +})); +vi.mock("../modules/plex/plexSavedViews", async (importOriginal) => ({ + ...(await importOriginal>()), + 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 }, diff --git a/frontend/src/shell/AppCommandPalette.tsx b/frontend/src/shell/AppCommandPalette.tsx index 837887c6..2e57e0c3 100644 --- a/frontend/src/shell/AppCommandPalette.tsx +++ b/frontend/src/shell/AppCommandPalette.tsx @@ -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;