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