fix(channel): the channel page's address names the channel and its own view, not the page under it

Opening a channel kept every key of the page under it (`?q=music&sort=oldest&chan=…`, a sort the
channel did not show), and the channel's own tab, sort and content types lived in its state alone, so a
reload or a bookmark lost them. Now the channel's address is `channelUrl`: the shell's keys and `chan`,
shared by `openChannel` and the channel link's href. The page writes its view (`chtab`, `chsort`,
`chtypes`, lib/channelPageUrlState) and reopens it; these are shell keys, cleared by a page switch and
by closing it in place. While a channel stands over a page, no module store writes its keys (`urlPage`);
Back finds them on the page's own entry. A link written by an older build opens without the page's keys.
This commit is contained in:
2026-10-26 00:19:02 +01:00
parent 9bd7304bb9
commit 91d36b3357
17 changed files with 565 additions and 46 deletions
@@ -27,6 +27,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import { urlPage } from "./urlPage";
import { useBackToClose } from "../../lib/history";
import { PageHeading } from "../../components/ui/Headings";
@@ -225,6 +226,58 @@ describe("NavigationProvider · a link carrying another page's keys", () => {
expect(window.location.search).toBe("?page=stats&chan=UC1");
expect(result.current.channelView?.id).toBe("UC1");
});
it("opens a channel link with only the channel page's keys, even over the page they belong to", () => {
window.history.replaceState({}, "", "/?q=music&sort=oldest&chan=UC1&chtab=about&chtypes=ns");
render();
expect(window.location.search).toBe("?chan=UC1&chtab=about&chtypes=ns");
});
});
// The channel page has an address of its own (the owner, 2026-10-05): it used to take the feed's
// filters along (`?q=music&sort=oldest&chan=…`), naming a sort the channel did not show.
describe("NavigationProvider · the channel page's address", () => {
it("names only the channel; the page under it keeps its own address for Back", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&sort=oldest");
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
expect(window.location.search).toBe("?chan=UC1");
act(() => window.history.back());
await vi.waitFor(() => expect(result.current.channelView).toBeNull());
expect(window.location.search).toBe("?q=music&sort=oldest");
});
it("says no page owns the address while the channel stands over it", () => {
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
const { result } = render();
expect(result.current.page).toBe("plex");
expect(urlPage(result.current)).toBe("plex");
act(() => result.current.openChannel("UC1", "One"));
expect(urlPage(result.current)).toBeNull();
});
it("another channel opened from it starts fresh", () => {
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
window.history.replaceState(window.history.state, "", "/?chan=UC1&chtab=about&chsort=oldest");
act(() => result.current.openChannel("UC2", "Two"));
expect(window.location.search).toBe("?chan=UC2");
});
it("a page switch takes the channel page's keys with it", () => {
window.history.replaceState({}, "", "/?chan=UC1&chtab=about&chsort=oldest&chtypes=n");
const { result } = render();
act(() => result.current.setPage("stats"));
expect(window.location.search).toBe("?page=stats");
});
it("closing it in place takes its keys with it", () => {
window.history.replaceState({}, "", "/?chan=UC1&chtab=about&chtypes=n");
const { result } = render();
stubNavigation([false]);
act(() => result.current.closeChannel());
expect(window.location.search).toBe("");
});
});
describe("NavigationProvider · Back", () => {
@@ -2,7 +2,13 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
import { useLatest } from "../../lib/useLatest";
import { useTranslation } from "react-i18next";
import { useMe } from "../../lib/useMe";
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState";
import {
channelUrl,
resolvePage,
retiredParams,
urlWithParams,
type Page,
} from "../../lib/urlState";
import { useDocumentObjectTitle } from "../../lib/documentTitle";
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
import {
@@ -13,6 +19,7 @@ import {
rememberNavPosition,
} from "../../lib/history";
import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry";
import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState";
import {
channelViewFrom,
loadInitialPage,
@@ -41,11 +48,20 @@ import {
function pageRootParams(page: Page): Record<string, string | null> {
return {
page: page === "feed" ? null : page,
chan: null,
...Object.fromEntries(moduleUrlKeys().map((k) => [k, null])),
...closedChannelParams(),
...nulls(moduleUrlKeys()),
};
}
const nulls = (keys: readonly string[]) => Object.fromEntries(keys.map((k) => [k, null]));
/** No channel page: neither `chan` nor its view's keys, which the page itself writes (ChannelPage) —
* a closed channel leaves none behind. (An opened one gets its address from `channelUrl`.) */
const closedChannelParams = (): Record<string, string | null> => ({
chan: null,
...nulls(CHANNEL_PAGE_KEYS),
});
export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation();
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
@@ -128,7 +144,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// inside one.
const openChannel = useCallback((id: string, name?: string) => {
closeOverlaysThenNavigate(() => {
const url = urlWithParams({ chan: id });
// The channel's address names the channel alone: the page under it keeps its own keys on its
// own entry, where Back finds them, and writes none while the channel stands over it (urlPage).
const url = channelUrl(id);
if (window.history.state?._chan) {
const st = window.history.state;
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
@@ -148,7 +166,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// entry's channel marks and the ?chan param.
setChannelView(null);
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
window.history.replaceState(st, "", urlWithParams({ chan: null }));
window.history.replaceState(st, "", urlWithParams(closedChannelParams()));
}
}, []);
@@ -234,9 +252,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const linked = new URLSearchParams(window.location.search).get("page");
const remembered = getAccountRaw(PAGE_KEY);
// A link can carry another page's keys (hand-edited, or written by an older build): the page
// opens without them, so its address names only what it shows.
// opens without them, so its address names only what it shows — and a channel page open over it
// shows none of the page's own (an older build's channel links carried the feed's filters).
const shown = channelViewFrom(st, window.location.search);
const url = urlWithParams({
...Object.fromEntries(foreignUrlKeys(mountPage).map((k) => [k, null])),
...nulls(shown ? moduleUrlKeys() : foreignUrlKeys(mountPage)),
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
page: mountPage === "feed" ? null : mountPage,
});
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
//
// The channel page opens over any page, and its address names the channel alone (the owner, 2026-10-05:
// it used to carry the feed's filters, naming a sort the channel did not show). The page under it is
// still mounted — its store lives at the root — and a change in it while the channel stands over it
// (a background refetch, the header search) must not write its keys onto the channel's address. Closing
// the channel brings them back. The real provider and the real stores; only the network is absent.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
import { NavigationProvider } from "./NavigationProvider";
import { ModuleStores } from "./ModuleStores";
import { useNavigationActions, type NavigationActions } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "../../modules/feed";
import { usePlexActions } from "../../modules/plex";
import { useChannelsActions } from "../../modules/channels";
import { useFilesActions } from "../../modules/files";
import { usePlaylistsActions } from "../../modules/playlists";
import { claimLocalData } from "../../lib/storage";
type Probe = {
nav: NavigationActions;
change: Record<string, () => void>;
};
const probe: { current: Probe | null } = { current: null };
function Probe() {
const nav = useNavigationActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
const plex = usePlexActions();
const channels = useChannelsActions();
const files = useFilesActions();
const playlists = usePlaylistsActions();
// Handed out after each render (an effect, not the render itself, may write outside it).
useEffect(() => {
probe.current = {
nav,
change: {
feed: () => setFilters({ ...filters, sort: "oldest" }),
plex: () => plex.setScope("movie"),
channels: () => channels.setView("hidden"),
files: () => files.setView({ kind: "folder", id: 3 }),
playlists: () => playlists.setSelectedId(5),
},
};
});
return null;
}
// What each page's change above writes, once the page is what the address shows.
const WRITTEN: Record<string, [string, string]> = {
feed: ["sort", "oldest"],
plex: ["pscope", "movie"],
channels: ["ctab", "hidden"],
files: ["folder", "3"],
playlists: ["pl", "5"],
};
beforeEach(() => {
localStorage.clear();
claimLocalData(3);
});
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
const search = () => new URLSearchParams(window.location.search);
describe.each(Object.keys(WRITTEN))("a channel page over %s", (page) => {
it("keeps the page's keys off its address, and they come back when it closes", async () => {
// The page named even for the feed: a bare address opens on Home (the account's default).
window.history.replaceState({ sfPage: page }, "", `/?page=${page}`);
render(
<QueryClientProvider client={new QueryClient()}>
<NavigationProvider>
<ModuleStores>
<Probe />
</ModuleStores>
</NavigationProvider>
</QueryClientProvider>,
);
act(() => probe.current!.nav.openChannel("UC1", "One"));
act(() => probe.current!.change[page]!());
const [key, value] = WRITTEN[page]!;
expect(search().get(key)).toBeNull();
expect(search().get("chan")).toBe("UC1");
act(() => probe.current!.nav.closeChannel());
await waitFor(() => expect(search().get("chan")).toBeNull());
await waitFor(() => expect(search().get(key)).toBe(value));
});
});
@@ -0,0 +1,9 @@
import type { Page } from "../../lib/urlState";
import type { NavigationState } from "./navigationContext";
/** The page whose own keys the address bar carries: none while the channel page stands over it, so
* every module store writes its keys (`useUrlMirror`) only while its page is what the screen shows.
* A plain function beside the context, not a hook in it: the stores' tests mock that module. */
export function urlPage(nav: Pick<NavigationState, "page" | "channelView">): Page | null {
return nav.channelView ? null : nav.page;
}
@@ -48,8 +48,9 @@ describe("ChannelNameLink", () => {
it("is an anchor whose href opens this channel on the current page", () => {
const { link } = renderLink();
expect(link.tagName).toBe("A");
// The same URL openChannel pushes: the current page's query plus ?chan=<id>.
expect(link.getAttribute("href")).toBe(`/?page=feed&show=unwatched&chan=${CH}`);
// The same URL openChannel pushes: the current page plus ?chan=<id> — not the page's own keys,
// which named a view the channel does not show (`show=unwatched` here).
expect(link.getAttribute("href")).toBe(`/?page=feed&chan=${CH}`);
});
it("takes the in-app path on a plain left click, and suppresses the browser navigation", () => {
@@ -73,11 +74,16 @@ describe("ChannelNameLink", () => {
expect(onOpenInApp).not.toHaveBeenCalled();
});
it("does not lose the rest of the address bar", () => {
window.history.replaceState({}, "", "/?page=channels&q=abc");
// The address names only what the screen shows: the channel over the page, not the page's filters
// (a link's new tab once opened the channel under the feed's search and sort).
it("keeps the page and the shell's keys, and leaves the page's and another channel's behind", () => {
window.history.replaceState(
{},
"",
"/?page=channels&q=abc&ctab=hidden&chtab=about&m=signup#x=1",
);
const { link } = renderLink();
expect(link.getAttribute("href")).toContain("page=channels");
expect(link.getAttribute("href")).toContain("q=abc");
expect(link.getAttribute("href")).toBe(`/?page=channels&chan=${CH}&m=signup#x=1`);
});
it("gives a channel with no title yet an accessible name", () => {
@@ -1,15 +1,15 @@
import { forwardRef, type AnchorHTMLAttributes } from "react";
import { useTranslation } from "react-i18next";
import { isPlainLeftClick } from "../../lib/nav";
import { urlWithParams } from "../../lib/urlState";
import { channelUrl } from "../../lib/urlState";
// A channel name that is a REAL link. The player showed it as a `<button>`, so the browser's own
// gestures did nothing on it: middle-click and Ctrl/Cmd-click opened no tab, on a thing that looks
// exactly like a link (the YouTube icon right next to it IS an anchor and does open one). The video
// card's channel name had the same `<button>`, so it renders through here too.
//
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
// The href is the same URL `openChannel` pushes (`channelUrl`: the channel over the current page,
// without the page's own keys) — so a new tab lands where the in-app navigation would have. A plain left click is still handled in-app, because
// the SPA path does more than navigate (the player has to close first and reclaim its own
// Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
@@ -33,7 +33,7 @@ const ChannelNameLink = forwardRef<HTMLAnchorElement, Props>(function ChannelNam
<a
{...rest}
ref={ref}
href={urlWithParams({ chan: channelId })}
href={channelUrl(channelId)}
title={tooltip}
onClick={(e) => {
if (stopPropagation) e.stopPropagation();
@@ -0,0 +1,69 @@
// The channel page's address: what a view writes, and what an address opens.
import { describe, it, expect } from "vitest";
import {
channelPageParamUpdates as encode,
channelPageViewFrom as decode,
type ChannelPageView,
} from "./channelPageUrlState";
const FRESH: ChannelPageView = {
tab: "videos",
sort: "newest",
types: { includeNormal: true, includeShorts: false, includeLive: false },
};
const params = (u: Record<string, string | null>) =>
new URLSearchParams(Object.entries(u).filter((e): e is [string, string] => e[1] != null));
describe("channel page address", () => {
it("names a fresh page by its content types alone", () => {
expect(encode(FRESH)).toEqual({ chtab: null, chsort: null, chtypes: "n" });
});
it("names the tab, the sort and every content type it shows", () => {
expect(
encode({
tab: "about",
sort: "oldest",
types: { includeNormal: false, includeShorts: true, includeLive: true },
}),
).toEqual({ chtab: "about", chsort: "oldest", chtypes: "sl" });
});
it("names an empty content choice, which no letters would", () => {
const none = { includeNormal: false, includeShorts: false, includeLive: false };
expect(encode({ ...FRESH, types: none }).chtypes).toBe("none");
expect(decode(params({ chtypes: "none" }), FRESH).types).toEqual(none);
});
it("clears every key once the page is closed", () => {
expect(encode(null)).toEqual({ chtab: null, chsort: null, chtypes: null });
});
it.each<ChannelPageView>([
FRESH,
{
tab: "about",
sort: "views",
types: { includeNormal: true, includeShorts: true, includeLive: false },
},
{
tab: "videos",
sort: "oldest",
types: { includeNormal: false, includeShorts: false, includeLive: true },
},
])("reopens the view it names: %o", (view) => {
expect(decode(params(encode(view)), FRESH)).toEqual(view);
});
it("opens a bare channel link as a fresh page, with the feed's content types", () => {
const feedTypes = { includeNormal: true, includeShorts: true, includeLive: false };
expect(decode(new URLSearchParams("chan=UC1"), { ...FRESH, types: feedTypes })).toEqual({
...FRESH,
types: feedTypes,
});
});
it("ignores a tab it does not know", () => {
expect(decode(params({ chtab: "bogus" }), FRESH).tab).toBe("videos");
});
});
+57
View File
@@ -0,0 +1,57 @@
// The channel page's address-bar keys. The page opens over any page (`?chan=`, the shell's), so its own
// keys are the shell's too: no module owns them, a page switch clears them with `chan`, and the page
// under it writes none of ITS keys while the channel stands over it — the address names only what the
// screen shows. The watch state is not here: it is an account preference that follows every channel
// (`channelShow`).
export const CHANNEL_PAGE_KEYS = ["chtab", "chsort", "chtypes"] as const;
export type ChannelTab = "videos" | "about";
type ChannelContentTypes = {
includeNormal: boolean;
includeShorts: boolean;
includeLive: boolean;
};
export type ChannelPageView = { tab: ChannelTab; sort: string; types: ChannelContentTypes };
const DEFAULT_SORT = "newest";
// The content types as letters: n(ormal) s(horts) l(ive); "none" for an empty choice.
const TYPE_LETTERS: [keyof ChannelContentTypes, string][] = [
["includeNormal", "n"],
["includeShorts", "s"],
["includeLive", "l"],
];
/** The address-bar delta for the channel page's view; `null` clears every key (the page closed). The
* content types are always written: a page opened fresh takes them from the feed, so a link without
* them would reopen with whatever the feed of the browser opening it holds. */
export function channelPageParamUpdates(v: ChannelPageView | null): Record<string, string | null> {
if (v == null) return { chtab: null, chsort: null, chtypes: null };
const letters = TYPE_LETTERS.filter(([k]) => v.types[k])
.map(([, l]) => l)
.join("");
return {
chtab: v.tab === "about" ? "about" : null,
chsort: v.sort && v.sort !== DEFAULT_SORT ? v.sort : null,
chtypes: letters || "none",
};
}
/** The view a channel page's address names; what it leaves out comes from `fresh` (the defaults of a
* page opened without a link: the videos tab, newest first, the feed's content types). */
export function channelPageViewFrom(
params: URLSearchParams,
fresh: ChannelPageView,
): ChannelPageView {
const raw = params.get("chtypes");
const types =
raw == null
? fresh.types
: (Object.fromEntries(
TYPE_LETTERS.map(([k, l]) => [k, raw !== "none" && raw.includes(l)]),
) as ChannelContentTypes);
return {
tab: params.get("chtab") === "about" ? "about" : fresh.tab,
sort: params.get("chsort") || fresh.sort,
types,
};
}
+19 -2
View File
@@ -5,6 +5,7 @@
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
import { TAG_CATEGORIES, type FeedFilters } from "./api";
import { CHANNEL_PAGE_KEYS } from "./channelPageUrlState";
export const FEED_KEYS = [
"q",
@@ -31,12 +32,13 @@ export const FEED_KEYS = [
] as const;
const KEYS = FEED_KEYS;
// The keys the shell itself reads, whatever page is on screen: the page, the channel page (it opens
// over any page), the welcome card's mode and the one-time entry links. No module may declare one of
// The keys the shell itself reads, whatever page is on screen: the page, the channel page and its view
// (it opens over any page, lib/channelPageUrlState), the welcome card's mode and the one-time entry links. No module may declare one of
// these (moduleUrl.contract.test), and a page switch never clears them.
export const SHELL_KEYS = [
"page",
"chan",
...CHANNEL_PAGE_KEYS,
"m",
"link",
"oauth",
@@ -228,6 +230,21 @@ export function urlWithParams(updates: Record<string, string | null>): string {
return hrefHere(params);
}
/** The channel page's address, opened over the page on screen: the shell's own keys (the page, an
* entry link not yet read) and the channel — none of the page's keys, and none of another channel's
* view (the channel page writes its own, lib/channelPageUrlState). What `openChannel` pushes, and what a
* channel link points at, so a new tab opens the same view. */
export function channelUrl(id: string): string {
const here = new URLSearchParams(window.location.search);
const viewKeys: readonly string[] = CHANNEL_PAGE_KEYS;
const params = new URLSearchParams();
for (const k of SHELL_KEYS) {
const v = k === "chan" ? id : here.get(k);
if (v != null && !viewKeys.includes(k)) params.set(k, v);
}
return hrefHere(params);
}
/** This page's address with its query replaced by `query` and its fragment by `hash` — what every
* address-bar writer hands to `history`. Built on the URL itself, never by gluing `pathname` in front:
* a path opened as `//evil.com/x` would read as a link to ANOTHER host, `history.replaceState` refuses
+18 -1
View File
@@ -18,6 +18,7 @@ import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState";
import { FILES_KEYS } from "./lib/filesUrlState";
import { PLEX_SUB_URL } from "./modules/plex/plexSubUrl";
import { MODE_URL } from "./shell/welcomeMode";
import { CHANNEL_PAGE_KEYS, channelPageParamUpdates } from "./lib/channelPageUrlState";
const SRC = __dirname;
@@ -99,6 +100,22 @@ describe("module URL keys", () => {
expect(shell).toContain(MODE_URL.key);
});
// The channel page opens over any page, so no page's switch or stamp may clear its view's keys as
// its own: they are the shell's, and exactly what the page's encoder writes.
it("leave the channel page's keys to the shell", () => {
const shell: readonly string[] = SHELL_KEYS;
expect(CHANNEL_PAGE_KEYS.filter((k) => !shell.includes(k))).toEqual([]);
const written = channelPageParamUpdates({
tab: "about",
sort: "oldest",
types: { includeNormal: true, includeShorts: true, includeLive: true },
});
expect(Object.keys(written).sort()).toEqual([...CHANNEL_PAGE_KEYS].sort());
expect(Object.keys(channelPageParamUpdates(null)).sort()).toEqual(
[...CHANNEL_PAGE_KEYS].sort(),
);
});
it.each([...REGISTERED_PAGES])("are exactly what %s's encoders write", (page) => {
const declared = [...moduleDef(page).urlKeys].sort();
const writes = WRITES[page];
@@ -120,6 +137,6 @@ describe("module URL keys", () => {
it("the literal scan sees the call sites it is meant to (a regex that matched nothing would pass)", () => {
const seen = new Set(queryKeyLiterals().map(({ key }) => key));
for (const key of ["chan", "pl", "page", "rjob", "link"]) expect(seen).toContain(key);
for (const key of ["pl", "page", "rjob", "link"]) expect(seen).toContain(key);
});
});
@@ -24,6 +24,7 @@ import {
} from "../../lib/channelsUrlState";
import type { FilterMap } from "../../lib/tableColumns";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import {
ChannelLayoutContext,
ChannelsActionsContext,
@@ -96,7 +97,8 @@ function readChannelsUrlSeed(): ChannelsUrlState | null {
}
export function ChannelsProvider({ children }: { children: ReactNode }) {
const { page } = useNavigation();
const nav = useNavigation();
const { page } = nav;
const { setPage } = useNavigationActions();
const [search, setSearchState] = useState("");
@@ -246,10 +248,11 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
);
// Mirror the content state (tab + sort + column filters) into the address bar under the namespaced
// keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared
// keys so a manager view is shareable. Gated on urlPage===channels: off the manager (or under a channel
// page) the keys are cleared
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
// deliberately transient rail-search, like the feed's and plex's.
useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null));
useUrlMirror(channelsParamUpdates(urlPage(nav) === "channels" ? { view, sort, filters } : null));
return (
<ChannelsActionsContext.Provider value={actions}>
+36 -6
View File
@@ -34,6 +34,12 @@ import { useFeedView, useFeedViewActions } from "./feedViewContext";
import { useShowChannelInFeed } from "./useFeedPageHooks";
import { SHOW_IDS } from "./feedFiltersInit";
import { usePref } from "../../lib/usePref";
import { useUrlMirror } from "../../lib/useUrlMirror";
import {
channelPageParamUpdates,
channelPageViewFrom,
type ChannelTab,
} from "../../lib/channelPageUrlState";
import { LS } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext";
import { notify } from "../../lib/notifications";
@@ -88,7 +94,21 @@ export default function ChannelPage({
// to: the feed defaults to scope "my", so filtering it to a channel you don't follow would just
// land you on an empty page.
const onShowInFeed = useShowChannelInFeed(channelId);
const [tab, setTab] = useState<"videos" | "about">("videos");
// The view this page opens in: what its address names (a reload, a bookmark), else a fresh page —
// the videos, newest first, in the feed's content types (see `ownFilters` below). Read once, at
// mount: the page is keyed by its channel, and from here on it writes its address itself.
const [opened] = useState(() =>
channelPageViewFrom(new URLSearchParams(window.location.search), {
tab: "videos",
sort: "newest",
types: {
includeNormal: globalFilters.includeNormal,
includeShorts: globalFilters.includeShorts,
includeLive: globalFilters.includeLive,
},
}),
);
const [tab, setTab] = useState<ChannelTab>(opened.tab);
const panel = useTabPanel();
// 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
@@ -266,17 +286,27 @@ export default function ChannelPage({
tags: [],
tagModes: DEFAULT_TAG_MODES,
q: "",
sort: "newest",
sort: opened.sort,
scope: "all",
librarySource: "all",
// Content-type prefs inherited from the global feed (seeded once) — no longer forced.
includeNormal: globalFilters.includeNormal,
includeShorts: globalFilters.includeShorts,
includeLive: globalFilters.includeLive,
...opened.types,
show: "unwatched",
channelIds: [channelId],
}));
const filters: FeedFilters = { ...ownFilters, show };
// The address is this view: a reload or a bookmark reopens the tab, the sort and the content types.
useUrlMirror(
channelPageParamUpdates({
tab,
sort: ownFilters.sort,
types: {
includeNormal: ownFilters.includeNormal,
includeShorts: ownFilters.includeShorts,
includeLive: ownFilters.includeLive,
},
}),
);
const setFilters = (next: FeedFilters) => {
if (next.show !== show) setShow(next.show);
setOwnFilters(next);
@@ -436,7 +466,7 @@ export default function ChannelPage({
idBase={panel.idBase}
className="flex gap-1"
active={tab}
onChange={(id) => setTab(id as "videos" | "about")}
onChange={(id) => setTab(id as ChannelTab)}
tabs={[
{ id: "videos", label: t("channel.tabVideos") },
{ id: "about", label: t("channel.tabAbout") },
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
//
// The channel page's address is its view (the owner, 2026-10-06: a bookmark reopens exactly that view):
// its tab, its sort and the content types it shows are in the address, and a link reopens them. They
// used to live in the page's own state alone, so a reload landed on the videos tab, newest first.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, act, screen, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { FeedFilters } from "../../lib/api";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("../../features/navigation/navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
// The feed's content types, which a page opened without a link takes.
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: true, includeLive: false }),
useFeedFiltersActions: () => ({ setFilters: () => {} }),
}));
const feed = vi.hoisted(() => ({
last: null as null | { filters: FeedFilters; setFilters: (f: FeedFilters) => void },
}));
vi.mock("./Feed", () => ({
default: (p: { filters: FeedFilters; setFilters: (f: FeedFilters) => void }) => {
feed.last = p;
return null;
},
}));
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
import ChannelPage from "./ChannelPage";
import { api, type ChannelDetail, type Me } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
feed.last = null;
window.history.replaceState(null, "", "/");
});
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
function mount(search: string) {
window.history.replaceState({ _chan: CH }, "", `/${search}`);
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "StarTalk",
subscribed: true,
stored_videos: 10,
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, preferences: {} } as unknown as Me);
render(
<QueryClientProvider client={qc}>
<ChannelPage
channelId={CH}
initialName="StarTalk"
me={{ id: 1, can_write: true, can_read: true } as unknown as Me}
/>
</QueryClientProvider>,
);
}
const param = (k: string) => new URLSearchParams(window.location.search).get(k);
const selectedTab = () =>
screen.getAllByRole("tab").find((t) => t.getAttribute("aria-selected") === "true")?.textContent;
describe("ChannelPage — its address", () => {
it("a page opened without a link shows the videos, newest first, in the feed's content types", () => {
mount(`?chan=${CH}`);
expect(selectedTab()).toBe("channel.tabVideos");
expect(feed.last?.filters.sort).toBe("newest");
expect(feed.last?.filters.includeShorts).toBe(true);
// …and says so, so a bookmark of it reopens those types wherever it is opened.
expect(param("chtypes")).toBe("ns");
expect(param("chtab")).toBeNull();
expect(param("chsort")).toBeNull();
});
it("writes the tab, the sort and the content types the viewer picks", () => {
mount(`?chan=${CH}`);
act(() =>
feed.last!.setFilters({
...feed.last!.filters,
sort: "oldest",
includeShorts: false,
includeLive: true,
}),
);
expect(param("chsort")).toBe("oldest");
expect(param("chtypes")).toBe("nl");
fireEvent.click(screen.getByRole("tab", { name: "channel.tabAbout" }));
expect(param("chtab")).toBe("about");
expect(param("chan")).toBe(CH);
});
it("reopens the view its address names", () => {
mount(`?chan=${CH}&chtab=about&chsort=oldest&chtypes=l`);
expect(selectedTab()).toBe("channel.tabAbout");
fireEvent.click(screen.getByRole("tab", { name: "channel.tabVideos" }));
expect(feed.last?.filters.sort).toBe("oldest");
expect(feed.last?.filters.includeNormal).toBe(false);
expect(feed.last?.filters.includeShorts).toBe(false);
expect(feed.last?.filters.includeLive).toBe(true);
});
});
@@ -5,6 +5,7 @@ import { useUrlMirror } from "../../lib/useUrlMirror";
import { ownKey, writeJSON } from "../../lib/storage";
import { logFilterChange } from "../../lib/filterLog";
import { useNavigation } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import {
FeedFiltersActionsContext,
FeedFiltersStateContext,
@@ -50,13 +51,13 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
if (key) writeJSON(key, next);
}, []);
// Mirror the feed filters into the address bar so every feed view is bookmarkable. Gated on
// page===feed: on other pages the filter keys are cleared from the URL (localStorage still holds
// them; they re-mirror on return to the feed). The channel detail page keeps its own isolated
// filters, so those never reach here. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
// Mirror the feed filters into the address bar so every feed view is bookmarkable. Gated on the
// feed being what the address shows (urlPage): on other pages, and under a channel page, the filter
// keys are cleared from the URL (localStorage still holds them; they re-mirror on return to the
// feed). The channel page keeps its own isolated filters, in keys of its own. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
// other providers own.
const { page } = useNavigation();
useUrlMirror(filterParamUpdates(page === "feed" ? filters : null));
const nav = useNavigation();
useUrlMirror(filterParamUpdates(urlPage(nav) === "feed" ? filters : null));
// A share link's filters (captured at mount) win over the account's stored view and are persisted
// to it once, so the view the link opened also survives a later plain reload.
+6 -3
View File
@@ -3,6 +3,7 @@ import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { FILES_FOLDER_KEY, FILES_ITEM_KEY } from "../../lib/filesUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import {
ALL_FILES,
FilesActionsContext,
@@ -57,7 +58,8 @@ export function FilesProvider({ children }: { children: ReactNode }) {
setAccountRaw(LS.filesView, viewParam(next));
}, []);
const { page } = useNavigation();
const nav = useNavigation();
const { page } = nav;
const { setPage } = useNavigationActions();
const openFile = useCallback(
(id: number, folderId: number | null) => {
@@ -87,9 +89,10 @@ export function FilesProvider({ children }: { children: ReactNode }) {
if (page !== "files" && arrivedRaw) setArrivedRaw(null);
const arrived = page === "files" ? arrivedRaw : null;
const shown = urlPage(nav) === "files";
useUrlMirror({
[FILES_FOLDER_KEY]: page === "files" ? viewParam(view) : null,
[FILES_ITEM_KEY]: focus ? String(focus.id) : null,
[FILES_FOLDER_KEY]: shown ? viewParam(view) : null,
[FILES_ITEM_KEY]: shown && focus ? String(focus.id) : null,
});
const state = useMemo<FilesState>(
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { LS, setAccountRaw } from "../../lib/storage";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { useNavigation } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import { readSelectedId } from "./playlistsInit";
import {
PlaylistsActionsContext,
@@ -33,11 +34,13 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
if (id != null) setAccountRaw(LS.playlist, String(id));
}, []);
// Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere
// (the localStorage copy still restores it on return). useUrlMirror keeps ?page and the other nav
// Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere,
// and under a channel page (urlPage; the localStorage copy still restores it on return). useUrlMirror keeps ?page and the other nav
// params intact.
const { page } = useNavigation();
useUrlMirror({ pl: page === "playlists" && selectedId != null ? String(selectedId) : null });
const nav = useNavigation();
useUrlMirror({
pl: urlPage(nav) === "playlists" && selectedId != null ? String(selectedId) : null,
});
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
const actions = useMemo<PlaylistsActions>(
+5 -4
View File
@@ -13,6 +13,7 @@ import { getAccountRaw, LS, setAccountRaw } from "../../lib/storage";
import { logFilterChange } from "../../lib/filterLog";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { useNavigation } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import {
PlexActionsContext,
PlexStateContext,
@@ -195,11 +196,11 @@ export function PlexProvider({ children }: { children: ReactNode }) {
);
// Mirror the content state (scope + sort + watch-state + filters) into the address bar under the
// namespaced keys so a Plex-browse view is shareable. Gated on page===plex: off the Plex page the keys
// are cleared (localStorage still holds the state; it re-mirrors on return). `q`/`field` stay OUT — the
// namespaced keys so a Plex-browse view is shareable. Gated on urlPage===plex: off the Plex page (or
// under a channel page) the keys are cleared (localStorage still holds the state; it re-mirrors on return). `q`/`field` stay OUT — the
// search term is deliberately transient-per-view and not part of the shared four-key contract.
const { page } = useNavigation();
useUrlMirror(plexParamUpdates(page === "plex" ? { scope, sort, show, filters } : null));
const nav = useNavigation();
useUrlMirror(plexParamUpdates(urlPage(nav) === "plex" ? { scope, sort, show, filters } : null));
return (
<PlexActionsContext.Provider value={actions}>