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/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/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], })); 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 (