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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,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}>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user