fix(nav): a linked player, file and search survive what the app does by itself (review round 2)
- Playlists: a link over a playlist the account does not hold plays alone; the rail's own fallback no longer counts as a pick that gives the player up. - The linked opener opens or clears nothing once the address names another player, and stops looking (and fetching its row) once it settled. - Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a notice of its own (EN/HU). - History: an in-place view rewrite (search refine, count, channel to channel, sub-view) drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a search keeps the search; the Feed press on a search opens the plain feed. - Files/Playlists: an emptied search is written at once. - Client log: plf/cf (column text filters) are typed keys; the shell has none of its own. - Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark net reads page tags. - Feed defaults in one constant; one live-search queue helper; Scheduler writes through the Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
This commit is contained in:
@@ -20,7 +20,7 @@ import { setupApi } from "./api/setup";
|
||||
export type * from "./api/setup";
|
||||
import { feedApi } from "./api/feed";
|
||||
export type * from "./api/feed";
|
||||
export { TAKEOUT_JOB } from "./api/feed";
|
||||
export { FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, TAKEOUT_JOB } from "./api/feed";
|
||||
import { discoverApi } from "./api/discover";
|
||||
export type * from "./api/discover";
|
||||
export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover";
|
||||
|
||||
@@ -81,6 +81,11 @@ export interface FeedResponse {
|
||||
// or optional (dead surface). Re-add if a consumer ever needs it.
|
||||
}
|
||||
|
||||
/** The feed's view and order with nothing chosen: what a bookmark of the plain feed names, and what
|
||||
* the active-filter chips read "nothing applied" against. */
|
||||
export const FEED_DEFAULT_SHOW = "unwatched";
|
||||
export const FEED_DEFAULT_SORT = "newest";
|
||||
|
||||
export interface FeedFilters {
|
||||
tags: number[];
|
||||
/** Per-category tag match (language / topic / other each independently any/all). */
|
||||
|
||||
@@ -3,8 +3,9 @@ import { type FilterMap } from "./tableColumns";
|
||||
// A table's column-filter map as one address-bar value (the channel manager's `cf`, a playlist's `plf`).
|
||||
// The map mixes scalar column filters (select, text, the sync chip) with array ones (multi), so it's
|
||||
// carried as a compact JSON blob rather than a nested query string — JSON round-trips both shapes
|
||||
// exactly (and future multi columns for free), and the values are opaque enum/id tokens where a
|
||||
// readable querystring buys little. Empty entries are dropped so a cleared funnel emits nothing.
|
||||
// exactly (and future multi columns for free) where a readable querystring buys little. A text
|
||||
// column's entry is what the user typed, so a map with one is a typed key (ModuleDef.typedUrlKeys).
|
||||
// Empty entries are dropped so a cleared funnel emits nothing.
|
||||
export function encodeFilterMap(map: FilterMap): string | null {
|
||||
const clean: FilterMap = {};
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload under two overlays: the player reopens on the upper one's entry, the lower one's is dead
|
||||
// under it. A browser Back closes the player — and steps over the dead entry too, onto the page, as
|
||||
// the player's own close does. Its own file: the entry is read as the module loads, so it is set up
|
||||
// before the import.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
const landing = () =>
|
||||
new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||
|
||||
describe("a browser Back over an overlay reopened on the entry a reload under two started from", () => {
|
||||
it("lands on the page under both", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
|
||||
window.history.pushState({ sfPage: "feed", _ov: 1, _ovd: 1 }, "", "/?page=feed&watch=v1");
|
||||
window.history.pushState({ sfPage: "feed", _ov: 2, _ovd: 2 }, "", "/?page=feed&watch=v1");
|
||||
const { useBackToClose } = await import("./history");
|
||||
|
||||
let closed = false;
|
||||
renderHook(() => useBackToClose(() => (closed = true), { watch: "v1" }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const first = landing();
|
||||
window.history.back();
|
||||
await first;
|
||||
await landing(); // the step over the dead entry
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(closed).toBe(true);
|
||||
expect(window.history.state?._ov).toBeUndefined();
|
||||
expect(window.location.search).toBe("?page=feed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload under two overlays, in a tab whose entries below the dead one are not this document's
|
||||
// (Chrome dropped them, see backStaysInDocument): closing the reopened player cannot step over both,
|
||||
// so it steps back one — onto the dead entry, still in the app — rather than leaving it. Its own file:
|
||||
// the entry is read as the module loads, so it is set up before the import.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "navigation");
|
||||
});
|
||||
|
||||
describe("an overlay reopened over a dead entry with nothing of this document below it", () => {
|
||||
it("closes one step back, staying in the document", async () => {
|
||||
window.history.replaceState({ sfPage: "feed", _ov: 1, _ovd: 1 }, "", "/?page=feed&watch=v1");
|
||||
window.history.pushState({ sfPage: "feed", _ov: 2, _ovd: 2 }, "", "/?page=feed&watch=v1");
|
||||
const { useBackToClose } = await import("./history");
|
||||
// Two steps back would leave the document; one stays in it.
|
||||
Object.defineProperty(window, "navigation", {
|
||||
configurable: true,
|
||||
value: {
|
||||
currentEntry: { index: 2 },
|
||||
entries: () => [{ sameDocument: false }, { sameDocument: true }, { sameDocument: true }],
|
||||
},
|
||||
});
|
||||
const go = vi.spyOn(window.history, "go");
|
||||
const back = vi.spyOn(window.history, "back");
|
||||
|
||||
const r = renderHook(() => useBackToClose(() => {}, { watch: "v1" }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||
r.unmount();
|
||||
await landed;
|
||||
});
|
||||
expect(go).not.toHaveBeenCalled();
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
expect(window.history.state?._ov).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -253,7 +253,7 @@ export function useHistorySubview<T>(
|
||||
function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void {
|
||||
const kept = url?.entry ? url.entry(next) : next;
|
||||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||||
window.history.replaceState({ ...window.history.state, _sub: kept }, "", href);
|
||||
replaceView({ ...window.history.state, _sub: kept }, href);
|
||||
}
|
||||
|
||||
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
|
||||
@@ -304,6 +304,21 @@ let historyDepth = 0;
|
||||
let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null;
|
||||
const reloadedDepth: unknown = window.history.state?._ovd;
|
||||
let deadBelow = 0;
|
||||
|
||||
/** Rewrite the current entry in place for another view (a refined search, another channel, a
|
||||
* sub-view followed): `state` and `href` as replaceState takes them. With no overlay open, the
|
||||
* entry's overlay marks go — the ones a reload under an overlay left. They named the view it was
|
||||
* left from, and the first overlay over the new one would take this entry over: closed, it stepped
|
||||
* back onto the entry below and the screen went back to the view before the rewrite. */
|
||||
export function replaceView(state: Record<string, unknown>, href?: string): void {
|
||||
if (historyDepth > 0) {
|
||||
window.history.replaceState(state, "", href);
|
||||
return;
|
||||
}
|
||||
reloadedOverlayEntry = null;
|
||||
const { _ov: _overlay, _ovd: _depth, ...view } = state;
|
||||
window.history.replaceState(view, "", href);
|
||||
}
|
||||
// Programmatic history steps back still awaiting their popstate — each must be ignored — and how
|
||||
// many entries they step over in all (a step over dead entries is one traversal of several).
|
||||
let pendingProgrammatic = 0;
|
||||
|
||||
@@ -10,8 +10,8 @@ import { installClientLog } from "./logHooks";
|
||||
const appCopy = (text: string | null) =>
|
||||
["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null;
|
||||
|
||||
// The pages' search boxes' keys, as main.tsx hands them over from the module definitions.
|
||||
const TYPED = ["fq", "yt"];
|
||||
// The pages' typed keys, as main.tsx hands them over from the module definitions.
|
||||
const TYPED = ["q", "fq", "yt"];
|
||||
|
||||
beforeAll(() => {
|
||||
installClientLog(appCopy, TYPED);
|
||||
|
||||
@@ -13,10 +13,10 @@ import { FRONTEND_SHA, FRONTEND_VERSION } from "./version";
|
||||
import { enabled, errorAttrs, log } from "./log";
|
||||
import { pathTemplate } from "./http";
|
||||
|
||||
// URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes").
|
||||
// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG. The
|
||||
// shell's own here; each page's search boxes join them at install (ModuleDef.typedUrlKeys).
|
||||
const TYPED_PARAMS = new Set(["q", "keywords", "search"]);
|
||||
// URL parameters that carry what the user TYPED — a search, a column's text filter. Logged only at
|
||||
// TRACE ("Teljes"). Every other parameter is filter/sort/view state: its NAME is kept at INFO, its
|
||||
// value at DEBUG. The shell has none; each page's join at install (ModuleDef.typedUrlKeys).
|
||||
const TYPED_PARAMS = new Set<string>();
|
||||
|
||||
type Via = "start" | "push" | "replace" | "pop";
|
||||
|
||||
|
||||
@@ -50,9 +50,9 @@ export type ModuleDef = {
|
||||
// along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to
|
||||
// one module and none is a shell key (moduleUrl.contract.test).
|
||||
urlKeys: readonly string[];
|
||||
// Those of them that carry what the user TYPED (a search box's text): the client log keeps their
|
||||
// values out below its most detailed level, as it does the shell's own (lib/logHooks). A new search
|
||||
// box's key is declared here with it (moduleUrl.contract.test holds them to urlKeys).
|
||||
// Those of them that carry what the user TYPED (a search box's text, a column filter map holding a
|
||||
// text filter): the client log keeps their values out below its most detailed level (lib/logHooks).
|
||||
// moduleUrl.contract.test sorts every key of urlKeys as typed or not, so a new one is decided.
|
||||
typedUrlKeys?: readonly string[];
|
||||
// Its page plays YouTube videos in the player and reopens it from an address that names one (a
|
||||
// reload, a bookmark — lib/playerUrlState), with its own list as the queue. A page without it gets
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
|
||||
// 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 { FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, TAG_CATEGORIES, type FeedFilters } from "./api";
|
||||
import { CHANNEL_PAGE_KEYS } from "./channelPageUrlState";
|
||||
import { PLAYER_KEYS } from "./playerUrlState";
|
||||
|
||||
@@ -55,8 +55,8 @@ export const SHELL_KEYS = [
|
||||
export function filtersToParams(f: FeedFilters): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
if (f.q) p.set("q", f.q);
|
||||
if (f.show && f.show !== "unwatched") p.set("show", f.show);
|
||||
if (f.sort && f.sort !== "newest") p.set("sort", f.sort);
|
||||
if (f.show && f.show !== FEED_DEFAULT_SHOW) p.set("show", f.show);
|
||||
if (f.sort && f.sort !== FEED_DEFAULT_SORT) p.set("sort", f.sort);
|
||||
if (f.scope === "all") p.set("scope", "all");
|
||||
// Library provenance filter (only meaningful in "all" scope); "organic" is the default.
|
||||
if (f.scope === "all" && f.librarySource && f.librarySource !== "organic")
|
||||
@@ -90,8 +90,8 @@ export function filterParamUpdates(f: FeedFilters | null): Record<string, string
|
||||
const out: Record<string, string | null> = {};
|
||||
for (const k of KEYS) out[k] = p.get(k);
|
||||
if (f) {
|
||||
out.show = f.show || "unwatched";
|
||||
out.sort = f.sort || "newest";
|
||||
out.show = f.show || FEED_DEFAULT_SHOW;
|
||||
out.sort = f.sort || FEED_DEFAULT_SORT;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -106,8 +106,8 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
|
||||
};
|
||||
|
||||
if (params.has("q")) f.q = params.get("q") ?? "";
|
||||
if (params.has("show")) f.show = params.get("show") || "unwatched";
|
||||
if (params.has("sort")) f.sort = params.get("sort") || "newest";
|
||||
if (params.has("show")) f.show = params.get("show") || FEED_DEFAULT_SHOW;
|
||||
if (params.has("sort")) f.sort = params.get("sort") || FEED_DEFAULT_SORT;
|
||||
if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
|
||||
if (params.has("source")) {
|
||||
const s = params.get("source");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
|
||||
import { DEFAULT_TAG_MODES, FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, type FeedFilters } from "./api";
|
||||
import { tagPickLabel } from "./tagPicks";
|
||||
import { useChannelNames } from "./useChannelNames";
|
||||
import { parseSort } from "./feedSort";
|
||||
@@ -20,11 +20,11 @@ const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
untagged: false,
|
||||
channelIds: [],
|
||||
sort: "newest",
|
||||
sort: FEED_DEFAULT_SORT,
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
show: FEED_DEFAULT_SHOW,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -74,19 +74,19 @@ export function useActiveFilters(
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.show !== "unwatched") {
|
||||
if (filters.show !== FEED_DEFAULT_SHOW) {
|
||||
out.push({
|
||||
key: "show",
|
||||
label: t("sidebar.show." + filters.show),
|
||||
remove: () => setFilters({ ...filters, show: "unwatched" }),
|
||||
remove: () => setFilters({ ...filters, show: FEED_DEFAULT_SHOW }),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.sort !== "newest" && filters.sort !== "relevance") {
|
||||
if (filters.sort !== FEED_DEFAULT_SORT && filters.sort !== "relevance") {
|
||||
out.push({
|
||||
key: "sort",
|
||||
label: t("feed.sortKey." + parseSort(filters.sort).key),
|
||||
remove: () => setFilters({ ...filters, sort: "newest", seed: undefined }),
|
||||
remove: () => setFilters({ ...filters, sort: FEED_DEFAULT_SORT, seed: undefined }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user