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
@@ -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}>