From da143fd70d91bd6b0457810a318438ac0b879cc7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 18:27:04 +0200 Subject: [PATCH 1/4] feat(filters): scope global feed filters in FeedFiltersProvider Lift the feed's filter god-state (per-account persistence, the account-load effect, share-link capture) out of App into a split-context provider mirroring NavigationProvider. Sidebar/Header/NotificationsPanel read it via hooks instead of prop-drilling; keeps its filters prop since ChannelPage renders it with its own channel-scoped state. --- frontend/src/App.tsx | 99 +------------ .../src/components/FeedFiltersProvider.tsx | 137 ++++++++++++++++++ frontend/src/components/Header.tsx | 9 +- .../src/components/NotificationsPanel.tsx | 9 +- frontend/src/components/Sidebar.tsx | 7 +- frontend/src/main.tsx | 5 +- 6 files changed, 160 insertions(+), 106 deletions(-) create mode 100644 frontend/src/components/FeedFiltersProvider.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3767d49d..273a6f06 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,7 +7,6 @@ import { getActiveAccount, setActiveAccount, setUnauthorizedHandler, - type FeedFilters, type PlexFilters, } from "./lib/api"; import { setLanguage, isSupported, type LangCode } from "./i18n"; @@ -18,7 +17,7 @@ import { saveLocalTheme, type ThemePrefs, } from "./lib/theme"; -import { hasFilterParams, paramsToFilters, stripUrlParams } from "./lib/urlState"; +import { hasFilterParams, stripUrlParams } from "./lib/urlState"; import { loadLayout, normalizeLayout, @@ -31,19 +30,16 @@ import { configureNotifications, getNotifSettings, notify, removeByMetaKind, typ import { hintsEnabled, setHintsEnabled } from "./lib/hints"; import { parseFeedView, type FeedView } from "./lib/feedView"; import { - accountKey, getAccountRaw, LS, readAccount, - readJSON, - readMerged, setAccountRaw, useAccountPersistedState, writeAccount, - writeJSON, } from "./lib/storage"; import type { PrefsController } from "./components/SettingsPanel"; import { useNavigation, useNavigationActions } from "./components/NavigationProvider"; +import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider"; import type { ChannelStatusFilter, ChannelsView } from "./components/Channels"; // Persistent shell — always mounted, so eagerly imported. import Welcome from "./components/Welcome"; @@ -86,20 +82,6 @@ const OnboardingWizard = lazy(() => import("./components/OnboardingWizard")); const About = lazy(() => import("./components/About")); const ReleaseNotes = lazy(() => import("./components/ReleaseNotes")); -const DEFAULT_FILTERS: FeedFilters = { - tags: [], - tagMode: "or", - channelIds: [], - q: "", - sort: "newest", - scope: "my", - includeNormal: true, - includeShorts: false, - includeLive: false, - show: "unwatched", -}; - -const FILTERS_KEY = LS.filters; const PERF_KEY = LS.perfMode; // The preferences edited on the Settings page. They apply locally for instant preview but @@ -112,50 +94,16 @@ type EditablePrefs = { notifications: NotifSettings; }; -// Page is navigation state, not a filter, but it's also kept out of the address bar — so -// persist it to localStorage to survive a reload. A share link's ?page= still wins. -// This account's own persisted filters (per-account keys — never another account's). A chosen -// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When -// the account isn't known yet (first load, before the tab is pinned), start from defaults; the -// post-login effect loads the real ones once the id arrives. -function loadAccountFilters(id: number | null): 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. The - // starred default view only SEEDS a fresh account that has never stored filters: it drives the - // very first load, after which that state persists like any other. (Re-apply the default view - // from the sidebar to return to it.) - const fKey = accountKey(LS.filters, id); - let hasStored = false; - try { - hasStored = !!fKey && localStorage.getItem(fKey) != null; - } catch { - /* localStorage may be unavailable */ - } - if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS); - const dvKey = accountKey(LS.defaultViewFilters, id); - const def = dvKey ? readJSON(dvKey, null) : null; - if (def) return { ...DEFAULT_FILTERS, ...def }; - return { ...DEFAULT_FILTERS }; -} - -// URL wins over localStorage so a pasted link reproduces the exact view. -function loadInitialFilters(): FeedFilters { - const params = new URLSearchParams(window.location.search); - if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS); - return loadAccountFilters(getActiveAccount()); -} - export default function App() { const { t, i18n } = useTranslation(); const { page, channelView, ytSearch } = useNavigation(); const { setPage, openChannel, enterYtSearch, exitYtSearch } = useNavigationActions(); const [theme, setThemeState] = useState(() => loadLocalTheme()); - const [filters, setFiltersState] = useState(loadInitialFilters); - // Whether this load arrived via a "Share view" link (captured once, before the URL is stripped) - // — such filters win over the account's stored view and are persisted to the account instead. - const [initialHadUrlFilters] = useState(() => - hasFilterParams(new URLSearchParams(window.location.search)) - ); + // The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load + // effect + share-link capture all moved there). App reads them to hand to and to the + // handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey). + const filters = useFeedFilters(); + const { setFilters } = useFeedFiltersActions(); // The feed's view mode is picked from the feed's own toolbar; like every Settings pref it // persists the moment you choose it. const [view, setViewState] = useState(() => parseFeedView(readAccount(LS.feedView, null))); @@ -257,13 +205,6 @@ export default function App() { setNotesOpen(true); } - function setFilters(next: FeedFilters) { - setFiltersState(next); - // Persist under THIS tab's active account so filters don't bleed between accounts. - const key = accountKey(FILTERS_KEY, getActiveAccount()); - if (key) writeJSON(key, next); - } - function setPanelLayout(panel: PanelId, next: PanelLayout) { setPanelLayouts((prev) => ({ ...prev, [panel]: next })); saveLayoutLocal(panel, next); @@ -423,26 +364,6 @@ export default function App() { if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id); }, [meQuery.data?.id]); - // Load THIS account's own filters when the account first resolves or changes (login / switch / - // add). Filters are per-account localStorage state, so another account's last view (incl. its - // default saved view) must never carry over. A share link's filters (captured at mount) win and - // are persisted to the account instead. Runs on id change only, so a 60s background refetch of - // the same account never clobbers the filters you're editing. - useEffect(() => { - const id = meQuery.data?.id; - if (id == null) return; - if (initialHadUrlFilters) { - const key = accountKey(FILTERS_KEY, id); - if (key) writeJSON(key, filters); - return; - } - let f = loadAccountFilters(id); - // The shared demo account has no subscriptions, so default it to the whole library. - if (meQuery.data?.is_demo) f = { ...f, scope: "all" }; - setFiltersState(f); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [meQuery.data?.id]); - // Once signed in, drop any leftover query string from the pre-login flow (e.g. // ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the // address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params. @@ -661,8 +582,6 @@ export default function App() { content column. Sits beside the nav rail as its own collapsible column. */} {page === "feed" && !channelView && ( setPanelLayout("feed", l)} onFocusChannel={focusChannel} @@ -740,8 +659,6 @@ export default function App() { header={
) : page === "notifications" ? ( - + ) : page === "messages" && !meQuery.data!.is_demo ? (
diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx new file mode 100644 index 00000000..d0622a8e --- /dev/null +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -0,0 +1,137 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api, getActiveAccount, type FeedFilters } from "../lib/api"; +import { hasFilterParams, paramsToFilters } from "../lib/urlState"; +import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage"; + +// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App +// god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters` +// through the tree. Mirrors NavigationProvider (split contexts + one-line hooks); composed in +// main.tsx inside NavigationProvider, around . +// +// TWO contexts on purpose: a STABLE actions context (setFilters never changes reference) and a +// CHANGING state context (the filters object). Context consumers re-render when their value changes, +// so a component that only mutates filters (e.g. NotificationsPanel) subscribes to actions ONLY and +// doesn't re-render on every filter tweak. +// +// SCOPE NOTE: this owns the FEED's filters only. The channel detail page keeps its OWN isolated +// filter state (ChannelPage) — it renders a channel-scoped catalog, not the global feed — and the +// reusable is parameterized by a `filters` prop so it can render either. Because the two live +// in structurally separate cells, opening a channel can never leak into the global feed (bug 2). + +const DEFAULT_FILTERS: FeedFilters = { + tags: [], + tagMode: "or", + channelIds: [], + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", +}; + +const FILTERS_KEY = LS.filters; + +// This account's own persisted filters (per-account keys — never another account's). A chosen +// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When +// the account isn't known yet (first load, before the tab is pinned), start from defaults; the +// post-login effect loads the real ones once the id arrives. +function loadAccountFilters(id: number | null): 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. The + // starred default view only SEEDS a fresh account that has never stored filters: it drives the + // very first load, after which that state persists like any other. (Re-apply the default view + // from the sidebar to return to it.) + const fKey = accountKey(FILTERS_KEY, id); + let hasStored = false; + try { + hasStored = !!fKey && localStorage.getItem(fKey) != null; + } catch { + /* localStorage may be unavailable */ + } + if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS); + const dvKey = accountKey(LS.defaultViewFilters, id); + const def = dvKey ? readJSON(dvKey, null) : null; + if (def) return { ...DEFAULT_FILTERS, ...def }; + return { ...DEFAULT_FILTERS }; +} + +// URL wins over localStorage so a pasted link reproduces the exact view. +function loadInitialFilters(): FeedFilters { + const params = new URLSearchParams(window.location.search); + if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS); + return loadAccountFilters(getActiveAccount()); +} + +interface FeedFiltersActions { + setFilters: (next: FeedFilters) => void; +} + +const StateContext = createContext(DEFAULT_FILTERS); +const ActionsContext = createContext({ setFilters: () => {} }); + +export function useFeedFilters(): FeedFilters { + return useContext(StateContext); +} +export function useFeedFiltersActions(): FeedFiltersActions { + return useContext(ActionsContext); +} + +export function FeedFiltersProvider({ children }: { children: ReactNode }) { + const [filters, setFiltersState] = useState(loadInitialFilters); + // Whether this load arrived via a "Share view" link (captured once, before the URL is stripped) + // — such filters win over the account's stored view and are persisted to the account instead. + const [initialHadUrlFilters] = useState(() => + hasFilterParams(new URLSearchParams(window.location.search)), + ); + + // Passive observer: App owns the fetching `["me"]` query (gated on setup state); enabled:false + // here reads its cache and re-renders when it lands, without triggering a second fetch (which + // could 503 in setup mode). We only need id + is_demo, which don't change on background refetch. + const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + + const setFilters = useCallback((next: FeedFilters) => { + setFiltersState(next); + // Persist under THIS tab's active account so filters don't bleed between accounts. + const key = accountKey(FILTERS_KEY, getActiveAccount()); + if (key) writeJSON(key, next); + }, []); + + // Load THIS account's own filters when the account first resolves or changes (login / switch / + // add). Filters are per-account localStorage state, so another account's last view (incl. its + // default saved view) must never carry over. A share link's filters (captured at mount) win and + // are persisted to the account instead. Runs on id change only, so a 60s background refetch of + // the same account never clobbers the filters you're editing. + useEffect(() => { + const id = meQuery.data?.id; + if (id == null) return; + if (initialHadUrlFilters) { + const key = accountKey(FILTERS_KEY, id); + if (key) writeJSON(key, filters); + return; + } + let f = loadAccountFilters(id); + // The shared demo account has no subscriptions, so default it to the whole library. + if (meQuery.data?.is_demo) f = { ...f, scope: "all" }; + setFiltersState(f); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [meQuery.data?.id]); + + const actions = useMemo(() => ({ setFilters }), [setFilters]); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 313dde75..7eadae02 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,20 +1,19 @@ import { type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Youtube } from "lucide-react"; -import type { FeedFilters, Me } from "../lib/api"; +import type { Me } from "../lib/api"; import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules"; import ModuleName from "./ModuleName"; import SearchBar from "./SearchBar"; import SyncStatus from "./SyncStatus"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; +import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; // The floating top header: a row of glass pills over the content — a ModuleName pill (label + // accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user // sync chip at the right end. export default function Header({ me, - filters, - setFilters, plexQ, setPlexQ, channelsSearch, @@ -25,8 +24,6 @@ export default function Header({ onGoToFullHistory, }: { me: Me; - filters: FeedFilters; - setFilters: (f: FeedFilters) => void; // Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not. plexQ: string; setPlexQ: (q: string) => void; @@ -43,6 +40,8 @@ export default function Header({ const { t } = useTranslation(); const { page } = useNavigation(); const { setPage } = useNavigationActions(); + const filters = useFeedFilters(); + const { setFilters } = useFeedFiltersActions(); // The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so // it stays correct as modules are added/gated). Labels of every active module feed ModuleName's // dynamic width so the pill is sized to the widest reachable title in the current language. diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index aed27a59..e4aaed4c 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -2,7 +2,7 @@ import { useEffect, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react"; -import { api, type AppNotification, type FeedFilters } from "../lib/api"; +import { api, type AppNotification } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { focusAccessRequestsTab } from "../lib/adminUsersTab"; import { @@ -23,6 +23,7 @@ import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; import { LEVEL_STYLE } from "./Toaster"; import { PageToolbar } from "./PageShell"; import { useNavigationActions } from "./NavigationProvider"; +import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; // The single notification center. "System" = durable, server-backed notifications (inbox // phase 1). "Activity" = the client-side transient events (action confirmations, errors, with @@ -31,16 +32,14 @@ import { useNavigationActions } from "./NavigationProvider"; const POLL_MS = 8000; export default function NotificationsPanel({ - filters, - setFilters, onFocusChannel, }: { - filters: FeedFilters; - setFilters: (f: FeedFilters) => void; onFocusChannel: (name: string) => void; }) { const { t } = useTranslation(); const { setPage } = useNavigationActions(); + const filters = useFeedFilters(); + const { setFilters } = useFeedFiltersActions(); const qc = useQueryClient(); const q = useLiveQuery<{ items: AppNotification[]; total: number }>( ["notifications"], diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 20d7d1c3..2da3a3a1 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -13,6 +13,7 @@ import SavedViewsWidget from "./SavedViewsWidget"; import SidePanel from "./SidePanel"; import PanelGroups, { type PanelItem } from "./PanelGroups"; import ChannelPicker from "./ChannelPicker"; +import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const DATE_PRESETS: { days: number; key: string }[] = [ @@ -60,8 +61,6 @@ function TagChip({ } export default function Sidebar({ - filters, - setFilters, layout, setLayout, onFocusChannel, @@ -69,8 +68,6 @@ export default function Sidebar({ collapsed, onToggleCollapse, }: { - filters: FeedFilters; - setFilters: (f: FeedFilters) => void; layout: PanelLayout; setLayout: (l: PanelLayout) => void; onFocusChannel: (name: string) => void; @@ -79,6 +76,8 @@ export default function Sidebar({ onToggleCollapse: () => void; }) { const { t } = useTranslation(); + const filters = useFeedFilters(); + const { setFilters } = useFeedFiltersActions(); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tags = tagsQuery.data ?? []; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1b5e7911..078b824a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import ErrorBoundary from "./components/ErrorBoundary"; import { ConfirmProvider } from "./components/ConfirmProvider"; import { NavigationProvider } from "./components/NavigationProvider"; +import { FeedFiltersProvider } from "./components/FeedFiltersProvider"; import "./i18n"; import "./index.css"; @@ -37,7 +38,9 @@ const root = - + + + From ae7d893a3ed1b16c296c4a184e6b3159592b5458 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 18:27:17 +0200 Subject: [PATCH 2/4] fix(channel): inherit content-type filters instead of forcing Live/Upcoming Opening a channel hardcoded includeLive:true (+ normal on / shorts off), so a channel view silently showed live/upcoming even when the feed had them off. Seed the content triad from the global feed once at mount; the channel toolbar can still toggle them locally. --- frontend/src/components/ChannelPage.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 74c26280..d57b64b3 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersH import Avatar from "./Avatar"; import Feed from "./Feed"; import { PageToolbar, PageToolbarSlot } from "./PageShell"; +import { useFeedFilters } from "./FeedFiltersProvider"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -76,6 +77,11 @@ export default function ChannelPage({ const { closeChannel } = useNavigationActions(); const qc = useQueryClient(); const confirm = useConfirm(); + // Seed the channel view's content-type prefs from the global feed (read once at mount). The + // channel page is its own filter scope, but WHICH content types the user wants to see (normal / + // shorts / live-upcoming) is a genuine preference, not part of "this channel's catalog" — so we + // inherit it instead of forcing Live/Upcoming on. The channel toolbar can still toggle these. + const globalFilters = useFeedFilters(); const [tab, setTab] = useState<"videos" | "about">("videos"); // The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's // fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is @@ -182,9 +188,10 @@ export default function ChannelPage({ sort: "newest", scope: "all", librarySource: "all", - includeNormal: true, - includeShorts: false, - includeLive: true, + // Content-type prefs inherited from the global feed (seeded once) — no longer forced. + includeNormal: globalFilters.includeNormal, + includeShorts: globalFilters.includeShorts, + includeLive: globalFilters.includeLive, show: "all", channelIds: [channelId], })); From 7d7e2c6146223321515073c776e51d08111f66df Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 18:27:17 +0200 Subject: [PATCH 3/4] test(e2e): lock the channel content-filter inherit + no-leak (bug 2) Add feed-content- testids to the toolbar's content pills and two channel specs: the channel view inherits the feed's Live/Upcoming both ways, and a content toggle made in the channel view does not leak back into the feed. --- frontend/e2e/channel.spec.ts | 45 ++++++++++++++++++++++++++++++++ frontend/src/components/Feed.tsx | 1 + 2 files changed, 46 insertions(+) diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index ae8b3c1a..586e6150 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -56,6 +56,51 @@ test.describe("channel view", () => { .toBeGreaterThan(saved - 500); }); + // Bug 2 (Platform S5): opening a channel no longer FORCES Live/Upcoming on — the channel view + // seeds its content-type prefs (normal / shorts / live) from the global feed. Locked both ways so + // a future refactor can't silently re-hardcode it. + test("the channel view inherits the feed's content filters (no forced Live/Upcoming)", async ({ page }) => { + await openApp(page); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + const feedLive = page.getByTestId("feed-content-includeLive"); + await expect(feedLive).toHaveAttribute("aria-pressed", "false"); + + // Inherit OFF: the channel toolbar's Live/Upcoming stays off, not forced on. + await page.getByTestId("video-card-channel").first().click(); + await expect(page.getByTestId("channel-title")).toBeVisible(); + await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "false"); + await page.getByTestId("channel-back").click(); + await expect(page.getByTestId("channel-title")).toHaveCount(0); + + // Inherit ON: flip the feed's Live/Upcoming on, reopen the channel → it inherits the on state. + await feedLive.click(); + await expect(feedLive).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + await page.getByTestId("video-card-channel").first().click(); + await expect(page.getByTestId("channel-title")).toBeVisible(); + await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "true"); + }); + + // Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there + // must NOT leak back into the global feed. (FeedFiltersProvider owns the feed's filters; the + // channel page keeps a separate local cell.) + test("a channel's content toggle does not leak into the feed", async ({ page }) => { + await openApp(page); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "false"); + + // Turn Live/Upcoming ON inside the channel view. + await page.getByTestId("video-card-channel").first().click(); + await expect(page.getByTestId("channel-title")).toBeVisible(); + await page.getByTestId("feed-content-includeLive").click(); + await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "true"); + + // Back on the feed, its Live/Upcoming is still off — the channel's change stayed local. + await page.getByTestId("channel-back").click(); + await expect(page.getByTestId("channel-title")).toHaveCount(0); + await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "false"); + }); + // Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays // put while only the video grid scrolls. test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => { diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index da292476..c0b10d19 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -544,6 +544,7 @@ export default function Feed({ return (