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:
+6
-4
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user