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