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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user