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:
2026-10-27 16:08:52 +01:00
parent 0d5c6273f1
commit 6d57d62ac8
38 changed files with 717 additions and 187 deletions
+6 -4
View File
@@ -53,8 +53,9 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
or always written, when an address naming none falls back to a view remembered elsewhere), written with or always written, when an address naming none falls back to a view remembered elsewhere), written with
`useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through `useBackToClose`'s), `useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through `useBackToClose`'s),
always as the codec's result: a writer outside `lib/` computes no key of its own. Read back first on mount always as the codec's result: a writer outside `lib/` computes no key of its own. Read back first on mount
(a link wins over anything remembered). A search box's key is also in `typedUrlKeys` (the client log keeps (a link wins over anything remembered). A key that carries typed text — a search box's, a column filter
what was typed out). Add its round trip to `e2e/bookmark.spec.ts`. Only what an address cannot promise to map with a text filter — is also in `typedUrlKeys` (the client log keeps what was typed out). Add its round
trip to `e2e/bookmark.spec.ts`, tagged with the page (`{ tag: "@<id>" }`). Only what an address cannot promise to
reopen stays out, said in its codec (Home's draw, `lib/homeUrlState`). reopen stays out, said in its codec (Home's draw, `lib/homeUrlState`).
3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's 3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's
hooks). Another folder reaches the module through this file or not at all. hooks). Another folder reaches the module through this file or not at all.
@@ -69,8 +70,9 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily), What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily),
`moduleStores.contract.test.tsx` (no store reads another module), `moduleUrl.contract.test.ts` (each URL key `moduleStores.contract.test.tsx` (no store reads another module), `moduleUrl.contract.test.ts` (each URL key
belongs to one module and matches what its codec writes; a writer outside `lib/` computes no key; no key literal belongs to one module and matches what its codec writes; a writer outside `lib/` is handed a literal or a `lib/`
written or read from the live query undeclared; every page with keys in the bookmark net), the `local/layers` lint rule (imports only go function's result, never a key of its own; no key literal written or read from the live query undeclared; every key
sorted as typed or not; every page with keys in a tagged bookmark round trip), the `local/layers` lint rule (imports only go
down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the
i18n parity test. i18n parity test.
+97 -82
View File
@@ -11,9 +11,9 @@ import {
// The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW // The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW
// browser context, so nothing this tab remembered (its history entries, sessionStorage, the view // browser context, so nothing this tab remembered (its history entries, sessionStorage, the view
// storage it wrote) can stand in for what the address says. One round trip per page that names its // storage it wrote) can stand in for what the address says. One round trip per page that names its
// view (moduleUrl.contract.test holds the list to the module definitions), plus the overlays the // view, tagged with the page (moduleUrl.contract.test holds the tags to the module definitions),
// shell opens over any page (the channel page, the player) and the live search. Not Plex: what it // plus the overlays the shell opens over any page (the channel page, the player) and the live
// shows is the instance's own library, which a test stack may not have. // search. Not Plex: what it shows is the instance's own library, which a test stack may not have.
const STORAGE_STATE = "e2e/.auth/state.json"; const STORAGE_STATE = "e2e/.auth/state.json";
@@ -37,7 +37,7 @@ async function gotoAdminPage(page: Page, id: string): Promise<void> {
} }
test.describe("a bookmark reopens the view it was taken of", () => { test.describe("a bookmark reopens the view it was taken of", () => {
test("the feed's filters", async ({ page, browser }) => { test("the feed's filters", { tag: "@feed" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
await toggleContentType(page, "includeLive"); await toggleContentType(page, "includeLive");
await expect(page).toHaveURL(/[?&]live=/); await expect(page).toHaveURL(/[?&]live=/);
@@ -46,7 +46,7 @@ test.describe("a bookmark reopens the view it was taken of", () => {
await fresh.context().close(); await fresh.context().close();
}); });
test("Home's time budget", async ({ page, browser }) => { test("Home's time budget", { tag: "@home" }, async ({ page, browser }) => {
await openApp(page, "/?page=home"); await openApp(page, "/?page=home");
await page.getByRole("button", { name: "An evening", exact: true }).click(); await page.getByRole("button", { name: "An evening", exact: true }).click();
await expect(page).toHaveURL(/[?&]hb=long/); await expect(page).toHaveURL(/[?&]hb=long/);
@@ -58,7 +58,7 @@ test.describe("a bookmark reopens the view it was taken of", () => {
await fresh.context().close(); await fresh.context().close();
}); });
test("a Settings tab", async ({ page, browser }) => { test("a Settings tab", { tag: "@settings" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
await gotoPage(page, "settings"); await gotoPage(page, "settings");
await tab(page, "Notifications").click(); await tab(page, "Notifications").click();
@@ -67,7 +67,7 @@ test.describe("a bookmark reopens the view it was taken of", () => {
await fresh.context().close(); await fresh.context().close();
}); });
test("an admin page's tab", async ({ page, browser }) => { test("an admin page's tab", { tag: "@users" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
await gotoAdminPage(page, "users"); await gotoAdminPage(page, "users");
await tab(page, "Access requests").click(); await tab(page, "Access requests").click();
@@ -76,7 +76,7 @@ test.describe("a bookmark reopens the view it was taken of", () => {
await fresh.context().close(); await fresh.context().close();
}); });
test("a Configuration group", async ({ page, browser }) => { test("a Configuration group", { tag: "@config" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
await gotoAdminPage(page, "config"); await gotoAdminPage(page, "config");
const second = page.getByRole("tab").nth(1); const second = page.getByRole("tab").nth(1);
@@ -91,7 +91,7 @@ test.describe("a bookmark reopens the view it was taken of", () => {
await fresh.context().close(); await fresh.context().close();
}); });
test("the Activity tab and its time window", async ({ page, browser }) => { test("the Activity tab and its time window", { tag: "@activity" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
await gotoAdminPage(page, "activity"); await gotoAdminPage(page, "activity");
await tab(page, "Server log").click(); await tab(page, "Server log").click();
@@ -106,7 +106,7 @@ test.describe("a bookmark reopens the view it was taken of", () => {
await fresh.context().close(); await fresh.context().close();
}); });
test("a playlist and its sort", async ({ page, browser }) => { test("a playlist and its sort", { tag: "@playlists" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
await gotoPage(page, "playlists"); await gotoPage(page, "playlists");
await expect(column(page, "Title")).toBeVisible(); await expect(column(page, "Title")).toBeVisible();
@@ -121,19 +121,23 @@ test.describe("a bookmark reopens the view it was taken of", () => {
await fresh.context().close(); await fresh.context().close();
}); });
test("the Channels manager's tab and name search", async ({ page, browser }) => { test(
await openApp(page, "/?page=feed"); "the Channels manager's tab and name search",
await gotoPage(page, "channels"); { tag: "@channels" },
await tab(page, "Hidden").click(); async ({ page, browser }) => {
await page.getByPlaceholder("Search channels…").fill("zz"); await openApp(page, "/?page=feed");
await expect(page).toHaveURL(/zz/); // written once the typing settles await gotoPage(page, "channels");
const fresh = await bookmarked(browser, page); await tab(page, "Hidden").click();
await expect(tab(fresh, "Hidden")).toHaveAttribute("aria-selected", "true"); await page.getByPlaceholder("Search channels…").fill("zz");
await expect(fresh.getByPlaceholder("Search channels…")).toHaveValue("zz"); await expect(page).toHaveURL(/zz/); // written once the typing settles
await fresh.context().close(); const fresh = await bookmarked(browser, page);
}); await expect(tab(fresh, "Hidden")).toHaveAttribute("aria-selected", "true");
await expect(fresh.getByPlaceholder("Search channels…")).toHaveValue("zz");
await fresh.context().close();
},
);
test("the Files search", async ({ page, browser }) => { test("the Files search", { tag: "@files" }, async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
await gotoPage(page, "files"); await gotoPage(page, "files");
await page.getByPlaceholder("Search files…").fill("zz"); await page.getByPlaceholder("Search files…").fill("zz");
@@ -145,35 +149,42 @@ test.describe("a bookmark reopens the view it was taken of", () => {
// A file of the test's own, uploaded for it and deleted after: the account's library is left as // A file of the test's own, uploaded for it and deleted after: the account's library is left as
// it was found. // it was found.
test("the Files sort, and the file open in the viewer", async ({ page, browser }) => { test(
await openApp(page, "/?page=feed"); "the Files sort, and the file open in the viewer",
const NAME = "bookmark-note"; { tag: "@files" },
// One a run cut short left behind goes first. async ({ page, browser }) => {
const left = (await (await page.request.get("/api/files")).json()) as Record<string, unknown>[]; await openApp(page, "/?page=feed");
for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`))) const NAME = "bookmark-note";
await page.request.delete(`/api/files/${String(f.id)}`); // One a run cut short left behind goes first.
const up = await page.request.post("/api/files/upload", { const left = (await (await page.request.get("/api/files")).json()) as Record<
headers: { "X-Upload-Filename": `${NAME}.txt` }, string,
data: Buffer.from("a note the bookmark reopens\n"), unknown
}); >[];
expect(up.ok(), await up.text()).toBeTruthy(); for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`)))
const { id } = (await up.json()) as { id: number }; await page.request.delete(`/api/files/${String(f.id)}`);
try { const up = await page.request.post("/api/files/upload", {
await gotoPage(page, "files"); headers: { "X-Upload-Filename": `${NAME}.txt` },
await column(page, "Name").getByRole("button").first().click(); data: Buffer.from("a note the bookmark reopens\n"),
await expect(page).toHaveURL(/[?&]fsort=name/); });
const sorted = await column(page, "Name").getAttribute("aria-sort"); expect(up.ok(), await up.text()).toBeTruthy();
await page.getByText(NAME, { exact: true }).first().click(); const { id } = (await up.json()) as { id: number };
await expect(page).toHaveURL(new RegExp(`[?&]fview=${id}`)); try {
const fresh = await bookmarked(browser, page); await gotoPage(page, "files");
await expect(fresh.getByRole("dialog", { name: NAME })).toBeVisible(); await column(page, "Name").getByRole("button").first().click();
await fresh.keyboard.press("Escape"); await expect(page).toHaveURL(/[?&]fsort=name/);
await expect(column(fresh, "Name")).toHaveAttribute("aria-sort", sorted ?? ""); const sorted = await column(page, "Name").getAttribute("aria-sort");
await fresh.context().close(); await page.getByText(NAME, { exact: true }).first().click();
} finally { await expect(page).toHaveURL(new RegExp(`[?&]fview=${id}`));
await page.request.delete(`/api/files/${id}`); const fresh = await bookmarked(browser, page);
} await expect(fresh.getByRole("dialog", { name: NAME })).toBeVisible();
}); await fresh.keyboard.press("Escape");
await expect(column(fresh, "Name")).toHaveAttribute("aria-sort", sorted ?? "");
await fresh.context().close();
} finally {
await page.request.delete(`/api/files/${id}`);
}
},
);
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => { test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
@@ -202,36 +213,40 @@ test.describe("a bookmark reopens the view it was taken of", () => {
// test, and the request it answers is what the address named. The instance's search settings are // test, and the request it answers is what the address named. The instance's search settings are
// pinned too (the free source, switched on): a stack left on the API source reopens the search // pinned too (the free source, switched on): a stack left on the API source reopens the search
// waiting for its press, by design, and one with search off offers none. // waiting for its press, by design, and one with search off offers none.
test("a live YouTube search, its term and its count", async ({ page, browser }) => { test(
const answer = async (p: Page, seen: string[]) => { "a live YouTube search, its term and its count",
await p.route("**/api/me", async (route) => { { tag: "@feed" },
const me = (await (await route.fetch()).json()) as Record<string, unknown>; async ({ page, browser }) => {
return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } }); const answer = async (p: Page, seen: string[]) => {
}); await p.route("**/api/me", async (route) => {
await p.route("**/api/search/youtube?**", (route) => { const me = (await (await route.fetch()).json()) as Record<string, unknown>;
seen.push(route.request().url()); return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } });
return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } }); });
}); await p.route("**/api/search/youtube?**", (route) => {
}; seen.push(route.request().url());
await answer(page, []); return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } });
await openApp(page, "/?page=feed"); });
await page.getByPlaceholder("Search your subscriptions…").fill("lofi"); };
await page.getByRole("button", { name: "Search YouTube", exact: true }).click(); await answer(page, []);
await expect(page.getByText("YouTube results for “lofi”")).toBeVisible(); await openApp(page, "/?page=feed");
await expect(page).toHaveURL(/[?&]yt=lofi/); // named as it opens, not only once the count is set await page.getByPlaceholder("Search your subscriptions…").fill("lofi");
await page.getByRole("button", { name: "40", exact: true }).click(); await page.getByRole("button", { name: "Search YouTube", exact: true }).click();
await expect(page).toHaveURL(/[?&]ytn=40/); await expect(page.getByText("YouTube results for “lofi”")).toBeVisible();
await expect(page).toHaveURL(/[?&]yt=lofi/); // named as it opens, not only once the count is set
await page.getByRole("button", { name: "40", exact: true }).click();
await expect(page).toHaveURL(/[?&]ytn=40/);
const seen: string[] = []; const seen: string[] = [];
const fresh = await bookmarked(browser, page, (p) => answer(p, seen)); const fresh = await bookmarked(browser, page, (p) => answer(p, seen));
await expect(fresh.getByText("YouTube results for “lofi”")).toBeVisible(); await expect(fresh.getByText("YouTube results for “lofi”")).toBeVisible();
await expect(fresh.getByRole("button", { name: "40", exact: true })).toHaveAttribute( await expect(fresh.getByRole("button", { name: "40", exact: true })).toHaveAttribute(
"aria-pressed", "aria-pressed",
"true", "true",
); );
await expect.poll(() => seen.length).toBeGreaterThan(0); await expect.poll(() => seen.length).toBeGreaterThan(0);
const asked = new URL(seen[0]!).searchParams; const asked = new URL(seen[0]!).searchParams;
expect([asked.get("q"), asked.get("limit")]).toEqual(["lofi", "40"]); expect([asked.get("q"), asked.get("limit")]).toEqual(["lofi", "40"]);
await fresh.context().close(); await fresh.context().close();
}); },
);
}); });
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
//
// A reload under a dialog that does not reopen (About) loads on the dialog's entry, its overlay mark
// still on it. A view rewritten in place there (the search refined) is another view: the next overlay
// pushes its own entry over it rather than taking this one over — taken over, closing it stepped back
// onto the entry below, and the screen went back to the search before the refine. Its own file: the
// entry is read as the history module loads, so it is set up before the import.
import { describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
describe("a view rewritten in place on the entry a reload under a dialog started from", () => {
it("is where an overlay over it closes to", async () => {
window.history.replaceState({ sfPage: "feed", _yt: "dogs" }, "", "/?page=feed&yt=dogs");
window.history.pushState(
{ sfPage: "feed", _yt: "dogs", _ov: 1, _ovd: 1 },
"",
"/?page=feed&yt=dogs",
);
const { NavigationProvider } = await import("./NavigationProvider");
const { useNavigation, useNavigationActions } = await import("./navigationContext");
const { useBackToClose } = await import("../../lib/history");
const { qk } = await import("../../lib/queryKeys");
const { claimLocalData } = await import("../../lib/storage");
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 1, role: "admin" });
claimLocalData(1);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>
<NavigationProvider>{children}</NavigationProvider>
</QueryClientProvider>
);
const nav = renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
act(() => nav.result.current.enterYtSearch("cats"));
const depth = window.history.length;
const overlay = renderHook(() => useBackToClose(() => {}));
await act(async () => {
await Promise.resolve();
});
expect(window.history.length).toBe(depth + 1);
await act(async () => {
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
overlay.unmount();
await landed;
});
expect(new URLSearchParams(window.location.search).get("yt")).toBe("cats");
expect(nav.result.current.ytSearch).toBe("cats");
}, 30_000); // the provider's whole import graph loads inside the case (see the header)
});
@@ -17,6 +17,7 @@ import {
nextNavId, nextNavId,
notifyNav, notifyNav,
rememberNavPosition, rememberNavPosition,
replaceView,
} from "../../lib/history"; } from "../../lib/history";
import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry"; import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry";
import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState"; import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState";
@@ -86,7 +87,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState) // The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState)
// so a reload or a link opens it again; its own history entry, so Back steps player → search → feed. // so a reload or a link opens it again; its own history entry, so Back steps player → search → feed.
// Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was // Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was
// asked to search for (sent from the search box, or "Search YouTube again" pressed), on the entries // asked to search for (sent from the search box, or "Search YouTube" pressed), on the entries
// it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or // it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or
// a Forward onto a search — runs only when it costs no quota (Feed decides). // a Forward onto a search — runs only when it costs no quota (Feed decides).
const [ytSearch, setYtSearch] = useState<string | null>(() => const [ytSearch, setYtSearch] = useState<string | null>(() =>
@@ -136,7 +137,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
closeOverlaysThenNavigate(() => { closeOverlaysThenNavigate(() => {
// A channel page overlays the content column, so a nav click must close it even when the // A channel page overlays the content column, so a nav click must close it even when the
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === pageRef.current && !channelViewRef.current) { // A live search on it is one too: the press opens the page's root, as over a channel.
if (next === pageRef.current && !channelViewRef.current && ytSearchRef.current == null) {
then?.(); then?.();
return; return;
} }
@@ -161,7 +163,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
notifyNav(); notifyNav();
then?.(); then?.();
}), }),
[pageRef, channelViewRef, endYtSearch], [pageRef, channelViewRef, ytSearchRef, endYtSearch],
); );
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands // A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
@@ -174,7 +176,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const url = channelUrl(id); const url = channelUrl(id);
if (window.history.state?._chan) { if (window.history.state?._chan) {
const st = window.history.state; const st = window.history.state;
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url); replaceView({ ...st, _chan: id, _chanName: name ?? null }, url);
} else { } else {
const st = rememberNavPosition(); const st = rememberNavPosition();
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url); window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
@@ -193,9 +195,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// again: the channel's address carried none of it. // again: the channel's address carried none of it.
setChannelView(null); setChannelView(null);
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {}; const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
window.history.replaceState( replaceView(
st, st,
"",
urlWithParams({ urlWithParams({
...closedChannelParams(), ...closedChannelParams(),
...ytSearchParams(ytSearchRef.current, ytCountRef.current), ...ytSearchParams(ytSearchRef.current, ytCountRef.current),
@@ -212,11 +213,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
if (st._yt || ytSearchFrom(here) != null) { if (st._yt || ytSearchFrom(here) != null) {
// Refine the search on screen, in place, at the count it shows (one a link opened too: it // Refine the search on screen, in place, at the count it shows (one a link opened too: it
// is the first entry of this document, so its new `_yt` mark has nothing of ours below it). // is the first entry of this document, so its new `_yt` mark has nothing of ours below it).
window.history.replaceState( replaceView({ ...st, _yt: q }, urlWithParams(ytSearchParams(q, ytCountFrom(here))));
{ ...st, _yt: q },
"",
urlWithParams(ytSearchParams(q, ytCountFrom(here))),
);
} else { } else {
// New sub-view entry, at the default count. // New sub-view entry, at the default count.
setYtCountState(DEFAULT_YT_COUNT); setYtCountState(DEFAULT_YT_COUNT);
@@ -235,9 +232,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const setYtCount = useCallback( const setYtCount = useCallback(
(n: number) => { (n: number) => {
setYtCountState(n); setYtCountState(n);
window.history.replaceState( replaceView(
window.history.state, window.history.state ?? {},
"",
urlWithParams(ytSearchParams(ytSearchRef.current, n)), urlWithParams(ytSearchParams(ytSearchRef.current, n)),
); );
}, },
@@ -251,7 +247,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
else { else {
endYtSearch(); endYtSearch();
const { _yt: _q, ...st } = window.history.state ?? {}; const { _yt: _q, ...st } = window.history.state ?? {};
window.history.replaceState(st, "", urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT))); replaceView(st, urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT)));
} }
}, [endYtSearch]); }, [endYtSearch]);
@@ -263,10 +259,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const [pageSearchWasOn, setPageSearchWasOn] = useState(page); const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
if (pageSearchWasOn !== page) { if (pageSearchWasOn !== page) {
setPageSearchWasOn(page); setPageSearchWasOn(page);
if (page !== "feed") { if (page !== "feed") endYtSearch();
setYtSearch(null);
setYtAsked(null);
}
} }
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"): // The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
@@ -349,8 +342,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// The YouTube search is read from the address the browser restored, so Back/Forward opens or // The YouTube search is read from the address the browser restored, so Back/Forward opens or
// clears it to match the entry we landed on. One landed on without it has ended: a Forward back // clears it to match the entry we landed on. One landed on without it has ended: a Forward back
// onto it is not asked for, and an API search waits for a press. // onto it is not asked for, and an API search waits for a press.
// A channel page opened from its results stands over it: its entry carries the search's mark
// (its address does not), and the search stays under it — closing a player over that channel
// lands there, and is no leaving of the search.
const here = new URLSearchParams(window.location.search); const here = new URLSearchParams(window.location.search);
const landed = p === "feed" ? ytSearchFrom(here) : null; const under = e.state?._chan && typeof e.state._yt === "string" ? e.state._yt : null;
const landed = p === "feed" ? (ytSearchFrom(here) ?? under) : null;
setYtSearch(landed); setYtSearch(landed);
if (landed == null) setYtAsked(null); if (landed == null) setYtAsked(null);
setYtCountState(ytCountFrom(here)); setYtCountState(ytCountFrom(here));
@@ -227,6 +227,34 @@ describe("NavigationProvider · the YouTube search in the address", () => {
expect(search().get("yt")).toBeNull(); expect(search().get("yt")).toBeNull();
}); });
it("the Feed press on the search itself opens the plain feed, as over a channel", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
const depth = window.history.length;
act(() => result.current.setPage("feed"));
expect(result.current.ytSearch).toBeNull();
expect(search().get("yt")).toBeNull();
expect(window.history.length).toBe(depth + 1); // its own entry: Back returns to the search
});
it("a landing on a channel opened from the search (an overlay over it closing) keeps the search", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
act(() => result.current.openChannel("UC1"));
// A player over the channel closed: the step back lands on the channel's own entry.
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
expect(result.current.ytSearch).toBe("lofi");
expect(result.current.ytSearchAsked).toBe(true);
// Back off the channel: the search it stood over is still the one asked for.
window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
expect(result.current.ytSearchAsked).toBe(true);
});
it("a channel closed in place over the search names the search again", () => { it("a channel closed in place over the search names the search again", () => {
const { result } = render(); const { result } = render();
act(() => result.current.enterYtSearch("lofi")); act(() => result.current.enterYtSearch("lofi"));
@@ -39,7 +39,7 @@ export interface NavigationActions {
closeChannel: () => void; closeChannel: () => void;
enterYtSearch: (q: string) => void; enterYtSearch: (q: string) => void;
exitYtSearch: () => void; exitYtSearch: () => void;
/** Run the search on screen: the "Search YouTube again" press. */ /** Run the search on screen: the "Search YouTube" press. */
askYtSearch: () => void; askYtSearch: () => void;
setYtCount: (n: number) => void; setYtCount: (n: number) => void;
} }
@@ -83,6 +83,60 @@ describe("useOpenLinkedPlayer", () => {
expect(new URLSearchParams(window.location.search).has("watch")).toBe(false); expect(new URLSearchParams(window.location.search).has("watch")).toBe(false);
}); });
it("leaves a player opened while the row was on its way alone, and its address", async () => {
window.history.replaceState(null, "", "/?watch=Z");
let answer: (v: Video) => void = () => {};
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r)));
const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
// A card clicked meanwhile: its player names itself in the address.
window.history.replaceState(null, "", "/?watch=Y");
answer(vid("Z"));
await new Promise((r) => setTimeout(r, 20));
expect(open).not.toHaveBeenCalled();
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
});
it("does not clear the address of a player opened since when its catalog read fails", async () => {
window.history.replaceState(null, "", "/?watch=Z");
let refuse: (e: Error) => void = () => {};
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((_, r) => (refuse = r)));
renderHook(() => useOpenLinkedPlayer(true, () => undefined, vi.fn()), { wrapper });
window.history.replaceState(null, "", "/?watch=Y");
refuse(new Error("not found"));
await new Promise((r) => setTimeout(r, 20));
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
});
it("given up over a moved list, keeps the address of a player opened since", async () => {
window.history.replaceState(null, "", "/?watch=Z");
const { rerender } = renderHook(
({ scope }) => useOpenLinkedPlayer(false, () => undefined, vi.fn(), scope),
{ wrapper, initialProps: { scope: 1 } },
);
window.history.replaceState(null, "", "/?watch=Y");
rerender({ scope: 2 });
await new Promise((r) => setTimeout(r, 20));
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
});
it("stops looking once it opened: the video leaving the list asks the catalog for nothing", async () => {
window.history.replaceState(null, "", "/?watch=B");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("B"));
const find = vi.fn((id: string) => (id === "B" ? vid("B") : undefined));
const { rerender } = renderHook(
({ rows }) => useOpenLinkedPlayer(true, rows ? find : () => undefined, vi.fn()),
{ wrapper, initialProps: { rows: true } },
);
await waitFor(() => expect(find).toHaveBeenCalled());
find.mockClear();
rerender({ rows: false });
rerender({ rows: true });
await new Promise((r) => setTimeout(r, 20));
expect(find).not.toHaveBeenCalled();
expect(row).not.toHaveBeenCalled();
});
it("opens nothing, and asks for nothing, for an address that names no player", async () => { it("opens nothing, and asks for nothing, for an address that names no player", async () => {
window.history.replaceState(null, "", "/?q=talks"); window.history.replaceState(null, "", "/?q=talks");
const row = vi.spyOn(api, "videoRow"); const row = vi.spyOn(api, "videoRow");
@@ -14,7 +14,8 @@ import { notifyActionError } from "../../lib/actionErrors";
* *
* `scope` names the list the address put it over (a playlist, a live search): when the host moves to * `scope` names the list the address put it over (a playlist, a live search): when the host moves to
* another before it opened, the address under it named something else, and the player is given up — * another before it opened, the address under it named something else, and the player is given up —
* opened later, it would start over a list nobody linked, unasked. */ * opened later, it would start over a list nobody linked, unasked. So it is when a player opened
* meanwhile (a card clicked while the row was on its way): the address names that one now. */
export function useOpenLinkedPlayer( export function useOpenLinkedPlayer(
ready: boolean, ready: boolean,
find: (id: string) => Video | undefined, find: (id: string) => Video | undefined,
@@ -24,14 +25,17 @@ export function useOpenLinkedPlayer(
const { t } = useTranslation(); const { t } = useTranslation();
const [linked] = useState(() => linkedPlayer()?.watch ?? null); const [linked] = useState(() => linkedPlayer()?.watch ?? null);
const [linkedScope] = useState(scope); const [linkedScope] = useState(scope);
// Opened (or said to be gone) once; a ref, as nothing on screen reads it. // Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must
// not open it twice); the state stops the lookup and the row fetch on screen after it.
const done = useRef(linked == null); const done = useRef(linked == null);
const [settled, setSettled] = useState(linked == null);
const moved = scope !== linkedScope; const moved = scope !== linkedScope;
const listed = ready && !moved && linked != null ? find(linked) : undefined; const waiting = ready && !moved && !settled && linked != null;
const listed = waiting ? find(linked) : undefined;
const row = useQuery({ const row = useQuery({
queryKey: qk.videoRow(linked ?? ""), queryKey: qk.videoRow(linked ?? ""),
queryFn: () => api.videoRow(linked!), queryFn: () => api.videoRow(linked!),
enabled: ready && !moved && linked != null && !listed, enabled: waiting && !listed,
retry: false, retry: false,
}); });
const openIt = useEffectEvent(open); const openIt = useEffectEvent(open);
@@ -41,12 +45,18 @@ export function useOpenLinkedPlayer(
}); });
useEffect(() => { useEffect(() => {
if (done.current) return; if (done.current) return;
if (moved) { const settle = () => {
done.current = true; done.current = true;
// The address goes on naming it under the new list (a reload would open it there) — unless a // State, not derived: a one-way decision taken as the list or the row arrives. Derived from
// player opened since names its own. // `listed`, the lookup and the row fetch would come back whenever the video left the list.
if (linkedPlayer()?.watch === linked) clearParams(...PLAYER_KEYS); setSettled(true);
return; };
// A player opened since names its own: this one is not wanted any more.
if (linkedPlayer()?.watch !== linked) return settle();
if (moved) {
// The address goes on naming it under the new list (a reload would open it there).
clearParams(...PLAYER_KEYS);
return settle();
} }
// Not before the host is ready: the row's cache entry is shared, so another opener's fetch fills // Not before the host is ready: the row's cache entry is shared, so another opener's fetch fills
// it here too (a disabled query still reads the cache) — the shell's player over a page that // it here too (a disabled query still reads the cache) — the shell's player over a page that
@@ -56,6 +66,6 @@ export function useOpenLinkedPlayer(
else if (row.data) openIt(row.data, false); else if (row.data) openIt(row.data, false);
else if (row.isError) failed(row.error); else if (row.isError) failed(row.error);
else return; else return;
done.current = true; settle();
}, [linked, moved, ready, listed, row.data, row.isError, row.error]); }, [linked, moved, ready, listed, row.data, row.isError, row.error]);
} }
+2 -2
View File
@@ -77,7 +77,7 @@
"cleared_other": "Cleared {{count}} results", "cleared_other": "Cleared {{count}} results",
"showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.", "showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.",
"notRunTitle": "Not searched yet", "notRunTitle": "Not searched yet",
"notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload, a link or Back doesn't start one by itself.", "notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload, a link, Back or Forward doesn't start one by itself.",
"runAgain": "Search YouTube again" "runAgain": "Search YouTube"
} }
} }
+1
View File
@@ -1,5 +1,6 @@
{ {
"linkedGone": "The file in the address is no longer in this list", "linkedGone": "The file in the address is no longer in this list",
"linkedNotListed": "The file in the link isn't among the files listed here — it may be further down the list",
"navLabel": "Files", "navLabel": "Files",
"searchPlaceholder": "Search files…", "searchPlaceholder": "Search files…",
"count_one": "{{count}} item", "count_one": "{{count}} item",
+2 -2
View File
@@ -77,7 +77,7 @@
"cleared_other": "{{count}} találat törölve", "cleared_other": "{{count}} találat törölve",
"showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább.", "showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább.",
"notRunTitle": "Ez a keresés még nem futott le", "notRunTitle": "Ez a keresés még nem futott le",
"notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltéskor, linkről vagy visszalépéskor nem indul el magától.", "notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltéskor, linkről, vissza- vagy előrelépéskor nem indul el magától.",
"runAgain": "Keresés újra a YouTube-on" "runAgain": "Keresés a YouTube-on"
} }
} }
+1
View File
@@ -1,5 +1,6 @@
{ {
"linkedGone": "A linkben szereplő fájl már nincs ebben a listában", "linkedGone": "A linkben szereplő fájl már nincs ebben a listában",
"linkedNotListed": "A linkben szereplő fájl nincs a listázottak között — lehet, hogy lejjebb van",
"navLabel": "Fájlok", "navLabel": "Fájlok",
"searchPlaceholder": "Keresés a fájlok között…", "searchPlaceholder": "Keresés a fájlok között…",
"count_one": "{{count}} elem", "count_one": "{{count}} elem",
+1 -1
View File
@@ -20,7 +20,7 @@ import { setupApi } from "./api/setup";
export type * from "./api/setup"; export type * from "./api/setup";
import { feedApi } from "./api/feed"; import { feedApi } from "./api/feed";
export type * 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"; import { discoverApi } from "./api/discover";
export type * from "./api/discover"; export type * from "./api/discover";
export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover"; export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover";
+5
View File
@@ -81,6 +81,11 @@ export interface FeedResponse {
// or optional (dead surface). Re-add if a consumer ever needs it. // 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 { export interface FeedFilters {
tags: number[]; tags: number[];
/** Per-category tag match (language / topic / other each independently any/all). */ /** Per-category tag match (language / topic / other each independently any/all). */
+3 -2
View File
@@ -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`). // 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 // 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 // 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 // exactly (and future multi columns for free) where a readable querystring buys little. A text
// readable querystring buys little. Empty entries are dropped so a cleared funnel emits nothing. // 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 { export function encodeFilterMap(map: FilterMap): string | null {
const clean: FilterMap = {}; const clean: FilterMap = {};
for (const [k, v] of Object.entries(map)) { 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);
});
});
+16 -1
View File
@@ -253,7 +253,7 @@ export function useHistorySubview<T>(
function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void { function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void {
const kept = url?.entry ? url.entry(next) : next; const kept = url?.entry ? url.entry(next) : next;
const href = url ? urlWithParams({ [url.key]: url.encode(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) ------------------------------- // --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
@@ -304,6 +304,21 @@ let historyDepth = 0;
let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null; let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null;
const reloadedDepth: unknown = window.history.state?._ovd; const reloadedDepth: unknown = window.history.state?._ovd;
let deadBelow = 0; 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 // 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). // many entries they step over in all (a step over dead entries is one traversal of several).
let pendingProgrammatic = 0; let pendingProgrammatic = 0;
+2 -2
View File
@@ -10,8 +10,8 @@ import { installClientLog } from "./logHooks";
const appCopy = (text: string | null) => const appCopy = (text: string | null) =>
["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : 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. // The pages' typed keys, as main.tsx hands them over from the module definitions.
const TYPED = ["fq", "yt"]; const TYPED = ["q", "fq", "yt"];
beforeAll(() => { beforeAll(() => {
installClientLog(appCopy, TYPED); installClientLog(appCopy, TYPED);
+4 -4
View File
@@ -13,10 +13,10 @@ import { FRONTEND_SHA, FRONTEND_VERSION } from "./version";
import { enabled, errorAttrs, log } from "./log"; import { enabled, errorAttrs, log } from "./log";
import { pathTemplate } from "./http"; import { pathTemplate } from "./http";
// URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes"). // URL parameters that carry what the user TYPED — a search, a column's text filter. Logged only at
// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG. The // TRACE ("Teljes"). Every other parameter is filter/sort/view state: its NAME is kept at INFO, its
// shell's own here; each page's search boxes join them at install (ModuleDef.typedUrlKeys). // value at DEBUG. The shell has none; each page's join at install (ModuleDef.typedUrlKeys).
const TYPED_PARAMS = new Set(["q", "keywords", "search"]); const TYPED_PARAMS = new Set<string>();
type Via = "start" | "push" | "replace" | "pop"; type Via = "start" | "push" | "replace" | "pop";
+3 -3
View File
@@ -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 // 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). // one module and none is a shell key (moduleUrl.contract.test).
urlKeys: readonly string[]; urlKeys: readonly string[];
// Those of them that carry what the user TYPED (a search box's text): the client log keeps their // Those of them that carry what the user TYPED (a search box's text, a column filter map holding a
// values out below its most detailed level, as it does the shell's own (lib/logHooks). A new search // text filter): the client log keeps their values out below its most detailed level (lib/logHooks).
// box's key is declared here with it (moduleUrl.contract.test holds them to urlKeys). // moduleUrl.contract.test sorts every key of urlKeys as typed or not, so a new one is decided.
typedUrlKeys?: readonly string[]; typedUrlKeys?: readonly string[];
// Its page plays YouTube videos in the player and reopens it from an address that names one (a // 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 // reload, a bookmark — lib/playerUrlState), with its own list as the queue. A page without it gets
+7 -7
View File
@@ -4,7 +4,7 @@
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is // 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 // 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. // 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 { CHANNEL_PAGE_KEYS } from "./channelPageUrlState";
import { PLAYER_KEYS } from "./playerUrlState"; import { PLAYER_KEYS } from "./playerUrlState";
@@ -55,8 +55,8 @@ export const SHELL_KEYS = [
export function filtersToParams(f: FeedFilters): URLSearchParams { export function filtersToParams(f: FeedFilters): URLSearchParams {
const p = new URLSearchParams(); const p = new URLSearchParams();
if (f.q) p.set("q", f.q); if (f.q) p.set("q", f.q);
if (f.show && f.show !== "unwatched") p.set("show", f.show); if (f.show && f.show !== FEED_DEFAULT_SHOW) p.set("show", f.show);
if (f.sort && f.sort !== "newest") p.set("sort", f.sort); if (f.sort && f.sort !== FEED_DEFAULT_SORT) p.set("sort", f.sort);
if (f.scope === "all") p.set("scope", "all"); if (f.scope === "all") p.set("scope", "all");
// Library provenance filter (only meaningful in "all" scope); "organic" is the default. // Library provenance filter (only meaningful in "all" scope); "organic" is the default.
if (f.scope === "all" && f.librarySource && f.librarySource !== "organic") 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> = {}; const out: Record<string, string | null> = {};
for (const k of KEYS) out[k] = p.get(k); for (const k of KEYS) out[k] = p.get(k);
if (f) { if (f) {
out.show = f.show || "unwatched"; out.show = f.show || FEED_DEFAULT_SHOW;
out.sort = f.sort || "newest"; out.sort = f.sort || FEED_DEFAULT_SORT;
} }
return out; 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("q")) f.q = params.get("q") ?? "";
if (params.has("show")) f.show = params.get("show") || "unwatched"; if (params.has("show")) f.show = params.get("show") || FEED_DEFAULT_SHOW;
if (params.has("sort")) f.sort = params.get("sort") || "newest"; 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("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
if (params.has("source")) { if (params.has("source")) {
const s = params.get("source"); const s = params.get("source");
+7 -7
View File
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; 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 { tagPickLabel } from "./tagPicks";
import { useChannelNames } from "./useChannelNames"; import { useChannelNames } from "./useChannelNames";
import { parseSort } from "./feedSort"; import { parseSort } from "./feedSort";
@@ -20,11 +20,11 @@ const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tagModes: DEFAULT_TAG_MODES, tagModes: DEFAULT_TAG_MODES,
untagged: false, untagged: false,
channelIds: [], channelIds: [],
sort: "newest", sort: FEED_DEFAULT_SORT,
includeNormal: true, includeNormal: true,
includeShorts: false, includeShorts: false,
includeLive: 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({ out.push({
key: "show", key: "show",
label: t("sidebar.show." + filters.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({ out.push({
key: "sort", key: "sort",
label: t("feed.sortKey." + parseSort(filters.sort).key), label: t("feed.sortKey." + parseSort(filters.sort).key),
remove: () => setFilters({ ...filters, sort: "newest", seed: undefined }), remove: () => setFilters({ ...filters, sort: FEED_DEFAULT_SORT, seed: undefined }),
}); });
} }
+1 -1
View File
@@ -113,7 +113,7 @@ describe.each([
act(() => nav.current!.setPage("feed")); act(() => nav.current!.setPage("feed"));
act(() => nav.current!.setPage(page as "files" | "playlists")); act(() => nav.current!.setPage(page as "files" | "playlists"));
await waitFor(() => expect(seen.current[page]).toBe("")); await waitFor(() => expect(seen.current[page]).toBe(""));
await new Promise((r) => setTimeout(r, 350)); // past the address write's typing pause // At once, not after the typing pause: a reload right after the return opens it unsearched.
expect(search().get(key)).toBeNull(); expect(search().get(key)).toBeNull();
}); });
}); });
+187 -10
View File
@@ -4,9 +4,9 @@
// shell clears them on a page switch and opens a page without another page's keys (NavigationProvider, // shell clears them on a page switch and opens a page without another page's keys (NavigationProvider,
// derived from these declarations). That only holds while the declarations are true, so this pins // derived from these declarations). That only holds while the declarations are true, so this pins
// them: each key belongs to one module and is no shell key, the keys a module declares are exactly // them: each key belongs to one module and is no shell key, the keys a module declares are exactly
// what its encoders write, a writer outside lib/ hands the address an encoder's result (or literal // what its encoders write, a writer outside lib/ hands the address a lib/ function's result (or
// keys, checked here) — never a key of its own making — and every key literal read from the live // literal keys, checked here) — never a key of its own making — every key literal read from the live
// query is declared. // query is declared, and every key is sorted as typed text or not (the client log's part).
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs"; import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path"; import { join, relative } from "node:path";
@@ -90,8 +90,96 @@ function sources(dir: string): string[] {
const WRITER_OBJECTS = [ const WRITER_OBJECTS = [
/(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/g, /(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/g,
/\burl=\{\{([^}]*)\}/g, /\burl=\{\{([^}]*)\}/g,
/useBackToClose\([^,()]+,\s*\{([^}]*)\}/g, // Its first argument a name, a call or an arrow (`() => setOpen(false)`).
/useBackToClose\((?:[^,()]|\([^()]*\))+,\s*\{([^}]*)\}/g,
]; ];
// One of each form: a regex that matched nothing in the tree would pass every scan.
const WRITER_FIXTURE = `
useUrlMirror({ fxa: x });
<Modal url={{ fxb: String(id) }} />
useBackToClose(onClose, { fxc: "1" });
useBackToClose(() => setOpen(false), { fxd: "1" });
`;
// Where a writer outside lib/ is handed its value: the address writers' argument, and an
// overlay's (Modal's `url`, `useBackToClose`'s second). An object literal (its keys are scanned), a
// call of a lib/ function — the codecs, and the shell's own (pageRootParams, …) — or a choice
// between them; anything else (a variable, a helper of the module's own) is a key no scan reads.
const WRITER_CALLS = /\b(useUrlMirror|urlWithParams|useBackToClose)\(|\burl=\{/g;
function closing(text: string, open: number): number {
let depth = 0;
for (let i = open; i < text.length; i++) {
const c = text[i]!;
if ("([{".includes(c)) depth++;
else if (")]}".includes(c) && --depth === 0) return i;
}
return text.length;
}
/** The top-level pieces of `text` split at `sep` (outside any brackets). */
function topLevel(text: string, sep: string): string[] {
const out: string[] = [];
let depth = 0;
let from = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if ("([{".includes(c)) depth++;
else if (")]}".includes(c)) depth--;
else if (depth === 0 && c === sep) {
out.push(text.slice(from, i));
from = i + 1;
}
}
out.push(text.slice(from));
return out.map((s) => s.trim());
}
const LIB_EXPORTS = new Set(
sources(join(SRC, "lib")).flatMap((f) =>
[...readFileSync(f, "utf8").matchAll(/export (?:function|const) (\w+)/g)].map((m) => m[1]!),
),
);
function writerValueOk(value: string): boolean {
const v = value.trim();
if (v === "null" || v === "undefined") return true;
const choice = topLevel(v, "?");
if (choice.length === 2) return topLevel(choice[1]!, ":").every(writerValueOk);
if (v.startsWith("{")) {
// Its spreads are a lib/ function's result too.
return topLevel(v.slice(1, closing(v, 0)), ",")
.filter((piece) => piece.startsWith("..."))
.every((piece) => writerValueOk(piece.slice(3)));
}
const call = /^(\w+)\(/.exec(v);
return (
call != null && LIB_EXPORTS.has(call[1]!) && closing(v, call[0].length - 1) === v.length - 1
);
}
function writerValues(text: string): string[] {
const out: string[] = [];
for (const m of text.matchAll(WRITER_CALLS)) {
const open = m.index + m[0].length - 1;
const args = topLevel(text.slice(open + 1, closing(text, open)), ",");
if (m[1] === "useBackToClose") {
if (args.length > 1) out.push(args[1]!);
} else if (args[0]) out.push(args[0]);
}
return out;
}
function unreadWriterValues(): string[] {
const out: string[] = [];
for (const file of sources(SRC)) {
const where = relative(SRC, file).split("\\").join("/");
if (where.startsWith("lib/")) continue;
for (const value of writerValues(readFileSync(file, "utf8"))) {
// ui/Modal hands its own `url` prop on: its callers' values are the ones checked.
if (where === "components/ui/Modal.tsx" && value === "url") continue;
// The shell's page switch writes what the declarations name (moduleUrlKeys, foreignUrlKeys —
// every module's keys cleared) and the shell's own: its helpers are that, held above.
if (where === "features/navigation/NavigationProvider.tsx") continue;
if (!writerValueOk(value)) out.push(`${where}: ${value.slice(0, 60)}`);
}
}
return out;
}
// Key literals that reach the address bar's QUERY: the literal keys of a writer's object, and a // Key literals that reach the address bar's QUERY: the literal keys of a writer's object, and a
// `.get("…")` / `.has("…")` in a file that reads the live query (any params object there: the // `.get("…")` / `.has("…")` in a file that reads the live query (any params object there: the
@@ -132,6 +220,66 @@ function computedWriterKeys(inLib = false): string[] {
return out; return out;
} }
// The keys whose value is what the user typed (a search box, a column filter map with a text
// filter in it), and every other one: chosen from what the page offers (a tab, a sort, a filter's
// option, an id).
const TYPED_KEYS = ["aq", "cf", "cq", "fq", "plf", "plq", "pq", "q", "yt"];
const PICKED_KEYS = [
"aact",
"at",
"au",
"aw",
"cfgtab",
"channel",
"csort",
"ctab",
"folder",
"fplay",
"from",
"fsort",
"fview",
"hb",
"item",
"lev",
"lgrp",
"live",
"ljob",
"lreq",
"lrun",
"lsrc",
"maxage",
"maxdur",
"mindur",
"normal",
"notag",
"pf",
"pl",
"plgroup",
"plsort",
"pqf",
"pscope",
"pshow",
"psort",
"rjob",
"rst",
"scope",
"shorts",
"show",
"sort",
"source",
"stab",
"tagmode",
"tagmode_language",
"tagmode_other",
"tagmode_topic",
"tags",
"to",
"utab",
"v",
"year",
"ytn",
];
// A retired page's link keys: read once to open the page that replaced it, and cleared (lib/urlState // A retired page's link keys: read once to open the page that replaced it, and cleared (lib/urlState
// RETIRED) — no module writes them any more. // RETIRED) — no module writes them any more.
const RETIRED_LINK_KEYS = ["lu", "lq", "lwin"]; const RETIRED_LINK_KEYS = ["lu", "lq", "lwin"];
@@ -200,11 +348,36 @@ describe("module URL keys", () => {
it("the literal scan sees the call sites it is meant to (a regex that matched nothing would pass)", () => { it("the literal scan sees the call sites it is meant to (a regex that matched nothing would pass)", () => {
const seen = new Set(queryKeyLiterals().map(({ key }) => key)); const seen = new Set(queryKeyLiterals().map(({ key }) => key));
// A writer's literal, a one-line read, a two-line read, a retired link's. // A writer's literal, a one-line read, a two-line read, a retired link's.
for (const key of ["page", "rjob", "link", "plq", "folder", "lq"]) expect(seen).toContain(key); for (const key of ["page", "link", "plq", "folder", "lq"]) expect(seen).toContain(key);
}); });
it("are written outside lib/ only through the codecs that WRITES calls", () => { it("the writer scans see each form they are meant to", () => {
const keys = WRITER_OBJECTS.flatMap((re) =>
[...WRITER_FIXTURE.matchAll(re)].map((m) => /(\w+)\s*:/.exec(m[1]!)![1]),
);
expect(keys.sort()).toEqual(["fxa", "fxb", "fxc", "fxd"]);
expect(writerValues(WRITER_FIXTURE)).toHaveLength(4);
});
it("are written outside lib/ only as a literal or a lib/ function's result", () => {
expect(computedWriterKeys()).toEqual([]); expect(computedWriterKeys()).toEqual([]);
expect(unreadWriterValues()).toEqual([]);
// …which the check tells apart (a check that passed everything would pass here too).
expect(writerValueOk("{ page: p, ...activityParamUpdates(state) }")).toBe(true);
expect(writerValueOk('shown ? playlistsParams(id, q) : playlistsParams(null, "")')).toBe(true);
expect(writerValueOk("upd")).toBe(false);
expect(writerValueOk("myOwnParams(v)")).toBe(false);
expect(writerValueOk("{ ...st, zz: 1 }")).toBe(false);
expect(writerValueOk("Object.fromEntries(rows)")).toBe(false);
});
// A sub-view's address key is written by the history plumbing in lib/, from its definition: each
// one is in a WRITES row (Plex's) or the shell's keys (the welcome card's), by name.
it("name their sub-views' keys", () => {
const defined = sources(SRC).flatMap((f) =>
[...readFileSync(f, "utf8").matchAll(/const (\w+): SubviewUrl</g)].map((m) => m[1]!),
);
expect(defined.sort()).toEqual(["MODE_URL", "PLEX_SUB_URL"]);
}); });
it("the computed-key scan sees what it is meant to (lib's own writers compute theirs)", () => { it("the computed-key scan sees what it is meant to (lib's own writers compute theirs)", () => {
@@ -221,7 +394,11 @@ describe("module URL keys", () => {
page, page,
).toEqual([]); ).toEqual([]);
} }
expect([...typedUrlKeys()].sort()).toEqual(["aq", "cq", "fq", "plq", "pq", "q", "yt"]); // Every key sorted one way or the other, by name: a new one fails here until it is decided.
const typed = new Set(typedUrlKeys());
const all = REGISTERED_PAGES.flatMap((p) => moduleDef(p).urlKeys);
expect([...typed].sort()).toEqual(TYPED_KEYS);
expect(all.filter((k) => !typed.has(k)).sort()).toEqual(PICKED_KEYS);
// …which the client log is handed as it installs. // …which the client log is handed as it installs.
expect(readFileSync(join(SRC, "main.tsx"), "utf8")).toContain( expect(readFileSync(join(SRC, "main.tsx"), "utf8")).toContain(
"installClientLog(appCopyOf, typedUrlKeys());", "installClientLog(appCopyOf, typedUrlKeys());",
@@ -233,10 +410,10 @@ describe("module URL keys", () => {
// by its address — Plex excepted, by name there: its view is the instance's own library. // by its address — Plex excepted, by name there: its view is the instance's own library.
describe("the bookmark net", () => { describe("the bookmark net", () => {
const spec = readFileSync(join(SRC, "..", "e2e", "bookmark.spec.ts"), "utf8"); const spec = readFileSync(join(SRC, "..", "e2e", "bookmark.spec.ts"), "utf8");
// A test tagged with the page that opens a bookmark of what it set (`bookmarked(`).
const tests = spec.split(/\n\s*test\(/).slice(1);
const opened = (page: Page) => const opened = (page: Page) =>
spec.includes(`page=${page}`) || tests.some((t) => t.includes(`tag: "@${page}"`) && t.includes("bookmarked("));
spec.includes(`gotoPage(page, "${page}")`) ||
spec.includes(`gotoAdminPage(page, "${page}")`);
it.each(REGISTERED_PAGES.filter((p) => moduleDef(p).urlKeys.length > 0 && p !== "plex"))( it.each(REGISTERED_PAGES.filter((p) => moduleDef(p).urlKeys.length > 0 && p !== "plex"))(
"takes a round trip through %s", "takes a round trip through %s",
+1 -1
View File
@@ -15,5 +15,5 @@ export const channelsModule: ModuleDef = {
maxWidth: "96rem", maxWidth: "96rem",
Store: ChannelsProvider, Store: ChannelsProvider,
urlKeys: CHANNELS_KEYS, urlKeys: CHANNELS_KEYS,
typedUrlKeys: ["cq"], typedUrlKeys: ["cq", "cf"],
}; };
+5 -5
View File
@@ -269,7 +269,7 @@ export default function Feed({
const { setYtCount, askYtSearch } = useNavigationActions(); const { setYtCount, askYtSearch } = useNavigationActions();
// A search runs by itself only where nobody's reload or link can spend quota with it: the free // A search runs by itself only where nobody's reload or link can spend quota with it: the free
// page scrape, or a search asked for in this document. An API search reopened by a reload, a link // page scrape, or a search asked for in this document. An API search reopened by a reload, a link
// or Back waits for "Search YouTube again" — unless its results are still cached, which a disabled // or Back waits for "Search YouTube" — unless its results are still cached, which a disabled
// query shows all the same. Turned off on this instance it runs too: the server refuses it before // query shows all the same. Turned off on this instance it runs too: the server refuses it before
// any quota, and its refusal is what says so (no press offered that could only be refused). // any quota, and its refusal is what says so (no press offered that could only be refused).
const ytRuns = ytActive && (me.search_source !== "api" || ytSearchAsked || !searchOn); const ytRuns = ytActive && (me.search_source !== "api" || ytSearchAsked || !searchOn);
@@ -506,7 +506,7 @@ export default function Feed({
// The live search's cards, in the order picked for them, and the player's queue over them. // The live search's cards, in the order picked for them, and the player's queue over them.
const ytShown = (rows: Video[]): Video[] => const ytShown = (rows: Video[]): Video[] =>
sortYtResults(withOverrides(rows, true), ytSort.key as SortKey, ytSort.dir, lang); sortYtResults(withOverrides(rows, true), ytSort.key as SortKey, ytSort.dir, lang);
const ytQueue = (rows: Video[]): Video[] => ytShown(rows).filter((v) => v.status !== "hidden"); const ytQueueOf = (shown: Video[]): Video[] => shown.filter((v) => v.status !== "hidden");
const merged: Video[] = withOverrides(loaded); const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show)); const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the // The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
@@ -520,7 +520,7 @@ export default function Feed({
// list it was linked over: leaving the search first gives it up. // list it was linked over: leaving the search first gives it up.
useOpenLinkedPlayer( useOpenLinkedPlayer(
ytActive ? ytQuery.data !== undefined || ytQuery.isError || !ytRuns : !query.isLoading, ytActive ? ytQuery.data !== undefined || ytQuery.isError || !ytRuns : !query.isLoading,
(id) => (ytActive ? ytQueue(ytLoaded) : playerQueue).find((v) => v.id === id), (id) => (ytActive ? ytQueueOf(ytShown(ytLoaded)) : playerQueue).find((v) => v.id === id),
(video, inList) => { (video, inList) => {
openVideo(video); openVideo(video);
// Not in what this list has loaded (further down it, or no longer in it): it plays alone. // Not in what this list has loaded (further down it, or no longer in it): it plays alone.
@@ -541,7 +541,7 @@ export default function Feed({
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota). // user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
if (ytActive) { if (ytActive) {
const ytItems: Video[] = ytShown(ytLoaded); const ytItems: Video[] = ytShown(ytLoaded);
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden"); const ytPlayerQueue: Video[] = ytQueueOf(ytItems);
const ytError = const ytError =
ytQuery.error instanceof HttpError ytQuery.error instanceof HttpError
? ytQuery.error.detail || t("feed.yt.error") ? ytQuery.error.detail || t("feed.yt.error")
@@ -649,7 +649,7 @@ export default function Feed({
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div> <div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError && !ytItems.length ? ( ) : ytError && !ytItems.length ? (
// A first page that failed is said as the error it is, with Retry where trying again can // A first page that failed is said as the error it is, with Retry where trying again can
// help (YouTube busy or unreachable): Search YouTube again with the same term reads the // help (YouTube busy or unreachable): Search YouTube with the same term reads the
// failure from the cache and sends nothing. // failure from the cache and sends nothing.
<StateMessage <StateMessage
tone="error" tone="error"
@@ -147,7 +147,7 @@ describe("Feed — a live search's Load more that fails", () => {
}); });
describe("Feed — a live search whose first page fails", () => { describe("Feed — a live search whose first page fails", () => {
// Search YouTube again with the same term reads the cached failure: Retry is the way to ask again. // Search YouTube with the same term reads the cached failure: Retry is the way to ask again.
it("offers Retry where trying again can help, and reads the page on it", async () => { it("offers Retry where trying again can help, and reads the page on it", async () => {
const search = vi const search = vi
.spyOn(api, "searchYoutube") .spyOn(api, "searchYoutube")
@@ -2,7 +2,7 @@
// //
// A live YouTube search the address reopens (a reload, a link, Back) runs by itself only where it // A live YouTube search the address reopens (a reload, a link, Back) runs by itself only where it
// costs no quota: the free page scrape. An API search spends 100 units, so it waits for "Search // costs no quota: the free page scrape. An API search spends 100 units, so it waits for "Search
// YouTube again" — unless its results are still cached, or it was asked for in this document. // YouTube" — unless its results are still cached, or it was asked for in this document.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+10 -3
View File
@@ -1,4 +1,11 @@
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api"; import {
coerceTagModes,
coerceUntagged,
DEFAULT_TAG_MODES,
FEED_DEFAULT_SHOW,
FEED_DEFAULT_SORT,
type FeedFilters,
} from "../../lib/api";
import { LS, ownKey, readJSON } from "../../lib/storage"; import { LS, ownKey, readJSON } from "../../lib/storage";
// The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider // The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider
@@ -10,12 +17,12 @@ export const DEFAULT_FILTERS: FeedFilters = {
untagged: false, untagged: false,
channelIds: [], channelIds: [],
q: "", q: "",
sort: "newest", sort: FEED_DEFAULT_SORT,
scope: "my", scope: "my",
includeNormal: true, includeNormal: true,
includeShorts: false, includeShorts: false,
includeLive: false, includeLive: false,
show: "unwatched", show: FEED_DEFAULT_SHOW,
}; };
/** The watch-state choices, in the order the filter offers them. */ /** The watch-state choices, in the order the filter offers them. */
@@ -170,8 +170,11 @@ const YT = folder(1, { source_key: "youtube" });
const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null }); const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null });
const HUB = folder(4, { kind: "hub", retention_days: null, count: 0 }); const HUB = folder(4, { kind: "hub", retention_days: null, count: 0 });
afterEach(() => { afterEach(async () => {
cleanup(); cleanup();
// A viewer the cleanup closed steps the history back: jsdom lands that traversal a few tasks later,
// and on whatever case is current by then. Let it land here, before the address is reset.
for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
vi.restoreAllMocks(); vi.restoreAllMocks();
// The page names its sort in the address: one case's sort must not open the next. // The page names its sort in the address: one case's sort must not open the next.
window.history.replaceState(null, "", "/"); window.history.replaceState(null, "", "/");
@@ -813,15 +816,30 @@ describe("Files · a file the address names", () => {
// A list the cap cut cannot tell a deleted file from one beyond its end: it says the file is not // A list the cap cut cannot tell a deleted file from one beyond its end: it says the file is not
// among the ones listed, and keeps the link. // among the ones listed, and keeps the link.
it("past the cap with no search or sort to widen it, says it is not listed and keeps the link", async () => { it("past the cap with no search or sort to widen it, says it is not listed and keeps the link", async () => {
// The case before closed a viewer: its step back lands a moment later, on whatever is current.
await new Promise((r) => setTimeout(r, 30));
window.history.replaceState(null, "", "/?page=files&fsort=none&fview=9999"); window.history.replaceState(null, "", "/?page=files&fsort=none&fview=9999");
mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1)); mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
mocks.folders = [YT]; mocks.folders = [YT];
mount(); mount();
await waitFor(() => await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith( expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "files.arrival.notListed" }), expect.objectContaining({ level: "info", message: "files.linkedNotListed" }),
),
);
expect(mocks.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "files.linkedGone" }),
);
expect(here().get("fview")).toBe("9999");
}, 30_000); // a full page of FILES_LIST_CAP rows
it("past the cap, the server's sorted list cut too: not listed, and the link stays", async () => {
window.history.replaceState(null, "", "/?page=files&fsort=name:asc&fview=9999");
mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
mocks.searched = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
mocks.folders = [YT];
mount();
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "files.linkedNotListed" }),
), ),
); );
expect(mocks.notify).not.toHaveBeenCalledWith( expect(mocks.notify).not.toHaveBeenCalledWith(
+3 -1
View File
@@ -294,7 +294,9 @@ export default function Files() {
const listIn = wide ? wideIn && wideQ.data !== undefined : filesQ.data !== undefined; const listIn = wide ? wideIn && wideQ.data !== undefined : filesQ.data !== undefined;
const linkedRow = linkedFile && listIn ? rows.find((f) => f.id === linkedFile.id) : undefined; const linkedRow = linkedFile && listIn ? rows.find((f) => f.id === linkedFile.id) : undefined;
const openLinked = useEffectEvent((file: LibraryFile | undefined) => { const openLinked = useEffectEvent((file: LibraryFile | undefined) => {
if (!file && capped && !wide) notify({ level: "info", message: t("files.arrival.notListed") }); // Looked for in the list it was shown in: a cut one cannot tell (the newest files, or the first by
// its sort or search), as the arrival decides it.
if (!file && cut) notify({ level: "info", message: t("files.linkedNotListed") });
else if (!file) { else if (!file) {
notify({ level: "warning", message: t("files.linkedGone") }); notify({ level: "warning", message: t("files.linkedGone") });
clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY); clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY);
+4 -2
View File
@@ -102,8 +102,10 @@ export function FilesProvider({ children }: { children: ReactNode }) {
const shown = urlPage(nav) === "files"; const shown = urlPage(nav) === "files";
// Written once the typing pauses (one address-bar write per keystroke is churn a browser throttles); // Written once the typing pauses (one address-bar write per keystroke is churn a browser throttles);
// an arrival's cleared box at once. // an emptied box at once — an arrival's, or the page left: back on it within the pause, the address
const typed = useDebounced(search.trim(), 300, focusRaw?.token); // would name the ended search again.
const debounced = useDebounced(search.trim(), 300, focusRaw?.token);
const typed = search.trim() ? debounced : "";
useUrlMirror( useUrlMirror(
filesListParams( filesListParams(
shown ? { search: typed, folder: viewParam(view), item: focus ? focus.id : null } : null, shown ? { search: typed, folder: viewParam(view), item: focus ? focus.id : null } : null,
@@ -133,13 +133,16 @@ let qc: QueryClient;
function mount( function mount(
lists: Record<number, Video[]>, lists: Record<number, Video[]>,
prefs: Record<string, unknown> = {}, prefs: Record<string, unknown> = {},
{ strictOverCache = false } = {}, {
strictOverCache = false,
detail,
}: { strictOverCache?: boolean; detail?: (id: number) => Promise<unknown> } = {},
) { ) {
vi.spyOn(api, "playlists").mockResolvedValue( vi.spyOn(api, "playlists").mockResolvedValue(
Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never, Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never,
); );
vi.spyOn(api, "playlist").mockImplementation( vi.spyOn(api, "playlist").mockImplementation(
async (id: number) => list(id, lists[id] ?? []) as never, (detail ?? (async (id: number) => list(id, lists[id] ?? []))) as never,
); );
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: prefs }); qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: prefs });
@@ -220,6 +223,47 @@ describe("Playlists — a player the address names", () => {
expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]); expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]);
}); });
it("plays alone over a playlist this account does not hold, as the rail moves on by itself", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=7&watch=Z");
h.selectedId = 7;
// The catalog answers after the rail moved on, as in the app (the move is in the same tick).
let answer: (v: Video) => void = () => {};
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r)) as never);
// Its read never answers: the rail moves on before it could.
const { rerender } = mount(
{ 1: [vid("A", 0)] },
{},
{
detail: async (id) => (id === 7 ? new Promise<never>(() => {}) : list(id, [vid("A", 0)])),
},
);
// The rail's keep-valid step, once the account's list is in: 7 is not in it.
await waitFor(() => expect(qc.getQueryData(qk.playlists())).toBeDefined());
h.selectedId = 1;
rerender();
await screen.findByText("Video A");
act(() => answer(vid("Z", 0)));
await waitFor(() => expect(h.player?.video.id).toBe("Z"));
expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]);
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Z");
});
it("plays alone when the linked playlist's read is refused", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z");
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0));
mount({ 1: [vid("A", 0)] }, {}, { detail: () => Promise.reject(new Error("refused")) });
await waitFor(() => expect(h.player?.video.id).toBe("Z"));
expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]);
});
it("plays alone with no playlists at all", async () => {
window.history.replaceState(null, "", "/?page=playlists&watch=Z");
h.selectedId = null;
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0));
mount({});
await waitFor(() => expect(h.player?.video.id).toBe("Z"));
});
it("is given up when another playlist is picked before it opened, and leaves the address", async () => { it("is given up when another playlist is picked before it opened, and leaves the address", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z"); window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0)); const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0));
+13 -5
View File
@@ -586,18 +586,26 @@ function PlaylistDetail() {
// (filtered out, gone from the list, the list emptied or out of reach) plays alone, and nothing // (filtered out, gone from the list, the list emptied or out of reach) plays alone, and nothing
// removes it from a list. In: the order seeded from it (an empty one has none to wait for), or its // removes it from a list. In: the order seeded from it (an empty one has none to wait for), or its
// read refused. Scoped to the playlist it was linked over: another one picked first gives it up. // read refused. Scoped to the playlist it was linked over: another one picked first gives it up.
// One the account's list does not hold (deleted since, another account's link) is out of reach
// as soon as that list is in: the rail then moves to another one by itself, which is no pick.
const [linkedOver] = useState(selectedId);
const linkedOutOfReach =
listQuery.data != null && !listQuery.data.some((p) => p.id === linkedOver);
const linkedListIn = const linkedListIn =
linkedOutOfReach ||
(detail != null && (detail.items.length === 0 || items.length > 0)) || (detail != null && (detail.items.length === 0 || items.length > 0)) ||
(selectedId != null && detailQuery.isError) || (selectedId != null && detailQuery.isError);
(selectedId == null && listQuery.data?.length === 0);
useOpenLinkedPlayer( useOpenLinkedPlayer(
linkedListIn, linkedListIn,
(id) => shown.find((v) => v.id === id), (id) => (linkedOutOfReach ? undefined : shown.find((v) => v.id === id)),
(video, inList) => { (video, inList) => {
if (inList) openPlayer(video.id); if (inList) openPlayer(video.id);
else setPlaying({ video, queue: [video], index: 0, listId: selectedId, editable: false }); else {
const listId = linkedOutOfReach ? null : selectedId;
setPlaying({ video, queue: [video], index: 0, listId, editable: false });
}
}, },
selectedId, linkedOutOfReach ? linkedOver : selectedId,
); );
async function moveItemTo(video: Video, target: Playlist) { async function moveItemTo(video: Video, target: Playlist) {
@@ -46,7 +46,9 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
const shown = urlPage(nav) === "playlists"; const shown = urlPage(nav) === "playlists";
// Leaving the page ends the search: adjusted during the render that leaves it. // Leaving the page ends the search: adjusted during the render that leaves it.
if (nav.page !== "playlists" && search) setSearchState(""); if (nav.page !== "playlists" && search) setSearchState("");
const typed = useDebounced(search.trim(), 300); // An emptied box at once: back on the page within the pause, the address would name it again.
const debounced = useDebounced(search.trim(), 300);
const typed = search.trim() ? debounced : "";
useUrlMirror(shown ? playlistsParams(selectedId, typed) : playlistsParams(null, "")); useUrlMirror(shown ? playlistsParams(selectedId, typed) : playlistsParams(null, ""));
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]); const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
+1 -1
View File
@@ -19,6 +19,6 @@ export const playlistsModule: ModuleDef = {
Store: PlaylistsProvider, Store: PlaylistsProvider,
// The playlist shown (PlaylistsProvider, playlistsInit). // The playlist shown (PlaylistsProvider, playlistsInit).
urlKeys: PLAYLISTS_KEYS, urlKeys: PLAYLISTS_KEYS,
typedUrlKeys: ["plq"], typedUrlKeys: ["plq", "plf"],
playsVideos: true, playsVideos: true,
}; };
+5 -1
View File
@@ -47,6 +47,7 @@ import { jobFailureText, jobResultText } from "../../lib/jobResult";
import { StatusDot, type DotKey } from "../activity"; import { StatusDot, type DotKey } from "../activity";
import { useNavigationActions } from "../../features/navigation/navigationContext"; import { useNavigationActions } from "../../features/navigation/navigationContext";
import { urlWithParams } from "../../lib/urlState"; import { urlWithParams } from "../../lib/urlState";
import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "../../lib/activityUrlState";
const POLL_MS = 4000; const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks. // While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -164,7 +165,10 @@ function HistoryButton({ job }: { job: string }) {
window.history.replaceState( window.history.replaceState(
window.history.state, window.history.state,
"", "",
urlWithParams({ page: "activity", rjob: job }), urlWithParams({
page: "activity",
...activityParamUpdates({ ...DEFAULT_ACTIVITY_STATE, job }),
}),
), ),
) )
} }