From 6d57d62ac898cb1299b8f87939ee38832d31aa0a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 27 Oct 2026 16:08:52 +0100 Subject: [PATCH] fix(nav): a linked player, file and search survive what the app does by itself (review round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- docs/development.md | 10 +- frontend/e2e/bookmark.spec.ts | 179 ++++++++-------- ...ationProvider.reloadedRewrite.dom.test.tsx | 57 +++++ .../navigation/NavigationProvider.tsx | 37 ++-- .../NavigationProvider.ytSearch.dom.test.tsx | 28 +++ .../features/navigation/navigationContext.ts | 2 +- .../player/useOpenLinkedPlayer.dom.test.tsx | 54 +++++ .../features/player/useOpenLinkedPlayer.ts | 30 ++- frontend/src/i18n/locales/en/feed.json | 4 +- frontend/src/i18n/locales/en/files.json | 1 + frontend/src/i18n/locales/hu/feed.json | 4 +- frontend/src/i18n/locales/hu/files.json | 1 + frontend/src/lib/api.ts | 2 +- frontend/src/lib/api/feed.ts | 5 + frontend/src/lib/filterMapParam.ts | 5 +- .../history.reloadedNestedBack.dom.test.tsx | 37 ++++ .../history.reloadedNestedEdge.dom.test.tsx | 43 ++++ frontend/src/lib/history.ts | 17 +- frontend/src/lib/logHooks.dom.test.ts | 4 +- frontend/src/lib/logHooks.ts | 8 +- frontend/src/lib/moduleDef.ts | 6 +- frontend/src/lib/urlState.ts | 14 +- frontend/src/lib/useActiveFilters.ts | 14 +- frontend/src/moduleSearch.url.dom.test.tsx | 2 +- frontend/src/moduleUrl.contract.test.ts | 197 +++++++++++++++++- frontend/src/modules/channels/module.ts | 2 +- frontend/src/modules/feed/Feed.tsx | 10 +- .../modules/feed/Feed.ytLoadMore.dom.test.tsx | 2 +- .../modules/feed/Feed.ytReopen.dom.test.tsx | 2 +- frontend/src/modules/feed/feedFiltersInit.ts | 13 +- .../src/modules/files/Files.menu.dom.test.tsx | 26 ++- frontend/src/modules/files/Files.tsx | 4 +- frontend/src/modules/files/FilesProvider.tsx | 6 +- .../modules/playlists/Playlists.dom.test.tsx | 48 ++++- frontend/src/modules/playlists/Playlists.tsx | 18 +- .../modules/playlists/PlaylistsProvider.tsx | 4 +- frontend/src/modules/playlists/module.ts | 2 +- frontend/src/modules/scheduler/Scheduler.tsx | 6 +- 38 files changed, 717 insertions(+), 187 deletions(-) create mode 100644 frontend/src/features/navigation/NavigationProvider.reloadedRewrite.dom.test.tsx create mode 100644 frontend/src/lib/history.reloadedNestedBack.dom.test.tsx create mode 100644 frontend/src/lib/history.reloadedNestedEdge.dom.test.tsx diff --git a/docs/development.md b/docs/development.md index a5d4be5e..465b327c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -53,8 +53,9 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in 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), 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 - what was typed out). Add its round trip to `e2e/bookmark.spec.ts`. Only what an address cannot promise to + (a link wins over anything remembered). A key that carries typed text — a search box's, a column filter + 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: "@" }`). Only what an address cannot promise to 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 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//` (the layering is in 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 -belongs to one module and matches what its codec writes; a writer outside `lib/` computes no key; no key literal -written or read from the live query undeclared; every page with keys in the bookmark net), the `local/layers` lint rule (imports only go +belongs to one module and matches what its codec writes; a writer outside `lib/` is handed a literal or a `lib/` +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 i18n parity test. diff --git a/frontend/e2e/bookmark.spec.ts b/frontend/e2e/bookmark.spec.ts index fbf8d45a..730ae30a 100644 --- a/frontend/e2e/bookmark.spec.ts +++ b/frontend/e2e/bookmark.spec.ts @@ -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 // 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 -// view (moduleUrl.contract.test holds the list to the module definitions), plus the overlays the -// shell opens over any page (the channel page, the player) and the live search. Not Plex: what it -// shows is the instance's own library, which a test stack may not have. +// view, tagged with the page (moduleUrl.contract.test holds the tags to the module definitions), +// plus the overlays the shell opens over any page (the channel page, the player) and the live +// 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"; @@ -37,7 +37,7 @@ async function gotoAdminPage(page: Page, id: string): Promise { } 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 toggleContentType(page, "includeLive"); await expect(page).toHaveURL(/[?&]live=/); @@ -46,7 +46,7 @@ test.describe("a bookmark reopens the view it was taken of", () => { 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 page.getByRole("button", { name: "An evening", exact: true }).click(); 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(); }); - test("a Settings tab", async ({ page, browser }) => { + test("a Settings tab", { tag: "@settings" }, async ({ page, browser }) => { await openApp(page, "/?page=feed"); await gotoPage(page, "settings"); await tab(page, "Notifications").click(); @@ -67,7 +67,7 @@ test.describe("a bookmark reopens the view it was taken of", () => { 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 gotoAdminPage(page, "users"); 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(); }); - test("a Configuration group", async ({ page, browser }) => { + test("a Configuration group", { tag: "@config" }, async ({ page, browser }) => { await openApp(page, "/?page=feed"); await gotoAdminPage(page, "config"); 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(); }); - 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 gotoAdminPage(page, "activity"); 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(); }); - 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 gotoPage(page, "playlists"); 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(); }); - test("the Channels manager's tab and name search", async ({ page, browser }) => { - await openApp(page, "/?page=feed"); - await gotoPage(page, "channels"); - await tab(page, "Hidden").click(); - await page.getByPlaceholder("Search channels…").fill("zz"); - await expect(page).toHaveURL(/zz/); // written once the typing settles - 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 Channels manager's tab and name search", + { tag: "@channels" }, + async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoPage(page, "channels"); + await tab(page, "Hidden").click(); + await page.getByPlaceholder("Search channels…").fill("zz"); + await expect(page).toHaveURL(/zz/); // written once the typing settles + 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 gotoPage(page, "files"); 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 // it was found. - test("the Files sort, and the file open in the viewer", async ({ page, browser }) => { - await openApp(page, "/?page=feed"); - const NAME = "bookmark-note"; - // One a run cut short left behind goes first. - const left = (await (await page.request.get("/api/files")).json()) as Record[]; - for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`))) - await page.request.delete(`/api/files/${String(f.id)}`); - const up = await page.request.post("/api/files/upload", { - headers: { "X-Upload-Filename": `${NAME}.txt` }, - data: Buffer.from("a note the bookmark reopens\n"), - }); - expect(up.ok(), await up.text()).toBeTruthy(); - const { id } = (await up.json()) as { id: number }; - try { - await gotoPage(page, "files"); - await column(page, "Name").getByRole("button").first().click(); - await expect(page).toHaveURL(/[?&]fsort=name/); - const sorted = await column(page, "Name").getAttribute("aria-sort"); - await page.getByText(NAME, { exact: true }).first().click(); - await expect(page).toHaveURL(new RegExp(`[?&]fview=${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( + "the Files sort, and the file open in the viewer", + { tag: "@files" }, + async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + const NAME = "bookmark-note"; + // One a run cut short left behind goes first. + const left = (await (await page.request.get("/api/files")).json()) as Record< + string, + unknown + >[]; + for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`))) + await page.request.delete(`/api/files/${String(f.id)}`); + const up = await page.request.post("/api/files/upload", { + headers: { "X-Upload-Filename": `${NAME}.txt` }, + data: Buffer.from("a note the bookmark reopens\n"), + }); + expect(up.ok(), await up.text()).toBeTruthy(); + const { id } = (await up.json()) as { id: number }; + try { + await gotoPage(page, "files"); + await column(page, "Name").getByRole("button").first().click(); + await expect(page).toHaveURL(/[?&]fsort=name/); + const sorted = await column(page, "Name").getAttribute("aria-sort"); + await page.getByText(NAME, { exact: true }).first().click(); + await expect(page).toHaveURL(new RegExp(`[?&]fview=${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 }) => { 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 // 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. - test("a live YouTube search, its term and its count", async ({ page, browser }) => { - const answer = async (p: Page, seen: string[]) => { - await p.route("**/api/me", async (route) => { - const me = (await (await route.fetch()).json()) as Record; - return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } }); - }); - await p.route("**/api/search/youtube?**", (route) => { - seen.push(route.request().url()); - return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } }); - }); - }; - await answer(page, []); - await openApp(page, "/?page=feed"); - await page.getByPlaceholder("Search your subscriptions…").fill("lofi"); - await page.getByRole("button", { name: "Search YouTube", exact: true }).click(); - 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/); + test( + "a live YouTube search, its term and its count", + { tag: "@feed" }, + async ({ page, browser }) => { + const answer = async (p: Page, seen: string[]) => { + await p.route("**/api/me", async (route) => { + const me = (await (await route.fetch()).json()) as Record; + return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } }); + }); + await p.route("**/api/search/youtube?**", (route) => { + seen.push(route.request().url()); + return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } }); + }); + }; + await answer(page, []); + await openApp(page, "/?page=feed"); + await page.getByPlaceholder("Search your subscriptions…").fill("lofi"); + await page.getByRole("button", { name: "Search YouTube", exact: true }).click(); + 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 fresh = await bookmarked(browser, page, (p) => answer(p, seen)); - await expect(fresh.getByText("YouTube results for “lofi”")).toBeVisible(); - await expect(fresh.getByRole("button", { name: "40", exact: true })).toHaveAttribute( - "aria-pressed", - "true", - ); - await expect.poll(() => seen.length).toBeGreaterThan(0); - const asked = new URL(seen[0]!).searchParams; - expect([asked.get("q"), asked.get("limit")]).toEqual(["lofi", "40"]); - await fresh.context().close(); - }); + const seen: string[] = []; + const fresh = await bookmarked(browser, page, (p) => answer(p, seen)); + await expect(fresh.getByText("YouTube results for “lofi”")).toBeVisible(); + await expect(fresh.getByRole("button", { name: "40", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect.poll(() => seen.length).toBeGreaterThan(0); + const asked = new URL(seen[0]!).searchParams; + expect([asked.get("q"), asked.get("limit")]).toEqual(["lofi", "40"]); + await fresh.context().close(); + }, + ); }); diff --git a/frontend/src/features/navigation/NavigationProvider.reloadedRewrite.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.reloadedRewrite.dom.test.tsx new file mode 100644 index 00000000..b104771d --- /dev/null +++ b/frontend/src/features/navigation/NavigationProvider.reloadedRewrite.dom.test.tsx @@ -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()), + 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 }) => ( + + {children} + + ); + 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) +}); diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index fa18c807..810497c8 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -17,6 +17,7 @@ import { nextNavId, notifyNav, rememberNavPosition, + replaceView, } from "../../lib/history"; import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry"; 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) // 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 - // 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 // a Forward onto a search — runs only when it costs no quota (Feed decides). const [ytSearch, setYtSearch] = useState(() => @@ -136,7 +137,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) { closeOverlaysThenNavigate(() => { // 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). - 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?.(); return; } @@ -161,7 +163,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { notifyNav(); 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 @@ -174,7 +176,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const url = channelUrl(id); if (window.history.state?._chan) { const st = window.history.state; - window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url); + replaceView({ ...st, _chan: id, _chanName: name ?? null }, url); } else { const st = rememberNavPosition(); 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. setChannelView(null); const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {}; - window.history.replaceState( + replaceView( st, - "", urlWithParams({ ...closedChannelParams(), ...ytSearchParams(ytSearchRef.current, ytCountRef.current), @@ -212,11 +213,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { if (st._yt || ytSearchFrom(here) != null) { // 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). - window.history.replaceState( - { ...st, _yt: q }, - "", - urlWithParams(ytSearchParams(q, ytCountFrom(here))), - ); + replaceView({ ...st, _yt: q }, urlWithParams(ytSearchParams(q, ytCountFrom(here)))); } else { // New sub-view entry, at the default count. setYtCountState(DEFAULT_YT_COUNT); @@ -235,9 +232,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const setYtCount = useCallback( (n: number) => { setYtCountState(n); - window.history.replaceState( - window.history.state, - "", + replaceView( + window.history.state ?? {}, urlWithParams(ytSearchParams(ytSearchRef.current, n)), ); }, @@ -251,7 +247,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { else { endYtSearch(); 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]); @@ -263,10 +259,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const [pageSearchWasOn, setPageSearchWasOn] = useState(page); if (pageSearchWasOn !== page) { setPageSearchWasOn(page); - if (page !== "feed") { - setYtSearch(null); - setYtAsked(null); - } + if (page !== "feed") endYtSearch(); } // 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 // 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. + // 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 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); if (landed == null) setYtAsked(null); setYtCountState(ytCountFrom(here)); diff --git a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx index 353f402e..fd610561 100644 --- a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx @@ -227,6 +227,34 @@ describe("NavigationProvider · the YouTube search in the address", () => { 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", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); diff --git a/frontend/src/features/navigation/navigationContext.ts b/frontend/src/features/navigation/navigationContext.ts index ab173013..08d5aa7a 100644 --- a/frontend/src/features/navigation/navigationContext.ts +++ b/frontend/src/features/navigation/navigationContext.ts @@ -39,7 +39,7 @@ export interface NavigationActions { closeChannel: () => void; enterYtSearch: (q: string) => void; exitYtSearch: () => void; - /** Run the search on screen: the "Search YouTube again" press. */ + /** Run the search on screen: the "Search YouTube" press. */ askYtSearch: () => void; setYtCount: (n: number) => void; } diff --git a/frontend/src/features/player/useOpenLinkedPlayer.dom.test.tsx b/frontend/src/features/player/useOpenLinkedPlayer.dom.test.tsx index c9269080..1c19b974 100644 --- a/frontend/src/features/player/useOpenLinkedPlayer.dom.test.tsx +++ b/frontend/src/features/player/useOpenLinkedPlayer.dom.test.tsx @@ -83,6 +83,60 @@ describe("useOpenLinkedPlayer", () => { 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 () => { window.history.replaceState(null, "", "/?q=talks"); const row = vi.spyOn(api, "videoRow"); diff --git a/frontend/src/features/player/useOpenLinkedPlayer.ts b/frontend/src/features/player/useOpenLinkedPlayer.ts index 4731b4a2..308f0f61 100644 --- a/frontend/src/features/player/useOpenLinkedPlayer.ts +++ b/frontend/src/features/player/useOpenLinkedPlayer.ts @@ -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 * 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( ready: boolean, find: (id: string) => Video | undefined, @@ -24,14 +25,17 @@ export function useOpenLinkedPlayer( const { t } = useTranslation(); const [linked] = useState(() => linkedPlayer()?.watch ?? null); 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 [settled, setSettled] = useState(linked == null); 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({ queryKey: qk.videoRow(linked ?? ""), queryFn: () => api.videoRow(linked!), - enabled: ready && !moved && linked != null && !listed, + enabled: waiting && !listed, retry: false, }); const openIt = useEffectEvent(open); @@ -41,12 +45,18 @@ export function useOpenLinkedPlayer( }); useEffect(() => { if (done.current) return; - if (moved) { + const settle = () => { done.current = true; - // The address goes on naming it under the new list (a reload would open it there) — unless a - // player opened since names its own. - if (linkedPlayer()?.watch === linked) clearParams(...PLAYER_KEYS); - return; + // State, not derived: a one-way decision taken as the list or the row arrives. Derived from + // `listed`, the lookup and the row fetch would come back whenever the video left the list. + setSettled(true); + }; + // 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 // 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.isError) failed(row.error); else return; - done.current = true; + settle(); }, [linked, moved, ready, listed, row.data, row.isError, row.error]); } diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 1572832c..07fb53f8 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -77,7 +77,7 @@ "cleared_other": "Cleared {{count}} results", "showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.", "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.", - "runAgain": "Search YouTube again" + "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" } } diff --git a/frontend/src/i18n/locales/en/files.json b/frontend/src/i18n/locales/en/files.json index 8f7ab657..ff10fb41 100644 --- a/frontend/src/i18n/locales/en/files.json +++ b/frontend/src/i18n/locales/en/files.json @@ -1,5 +1,6 @@ { "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", "searchPlaceholder": "Search files…", "count_one": "{{count}} item", diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index ca264e61..d76f9c49 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -77,7 +77,7 @@ "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.", "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.", - "runAgain": "Keresés újra a YouTube-on" + "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 a YouTube-on" } } diff --git a/frontend/src/i18n/locales/hu/files.json b/frontend/src/i18n/locales/hu/files.json index a6deff3d..88354b9a 100644 --- a/frontend/src/i18n/locales/hu/files.json +++ b/frontend/src/i18n/locales/hu/files.json @@ -1,5 +1,6 @@ { "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", "searchPlaceholder": "Keresés a fájlok között…", "count_one": "{{count}} elem", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1fa83174..8f27f625 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -20,7 +20,7 @@ import { setupApi } from "./api/setup"; export type * from "./api/setup"; import { feedApi } from "./api/feed"; export type * from "./api/feed"; -export { TAKEOUT_JOB } from "./api/feed"; +export { FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, TAKEOUT_JOB } from "./api/feed"; import { discoverApi } from "./api/discover"; export type * from "./api/discover"; export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover"; diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index 408bf728..53625713 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -81,6 +81,11 @@ export interface FeedResponse { // or optional (dead surface). Re-add if a consumer ever needs it. } +/** The feed's view and order with nothing chosen: what a bookmark of the plain feed names, and what + * the active-filter chips read "nothing applied" against. */ +export const FEED_DEFAULT_SHOW = "unwatched"; +export const FEED_DEFAULT_SORT = "newest"; + export interface FeedFilters { tags: number[]; /** Per-category tag match (language / topic / other each independently any/all). */ diff --git a/frontend/src/lib/filterMapParam.ts b/frontend/src/lib/filterMapParam.ts index 4431a559..00b64df9 100644 --- a/frontend/src/lib/filterMapParam.ts +++ b/frontend/src/lib/filterMapParam.ts @@ -3,8 +3,9 @@ import { type FilterMap } from "./tableColumns"; // A table's column-filter map as one address-bar value (the channel manager's `cf`, a playlist's `plf`). // The map mixes scalar column filters (select, text, the sync chip) with array ones (multi), so it's // carried as a compact JSON blob rather than a nested query string — JSON round-trips both shapes -// exactly (and future multi columns for free), and the values are opaque enum/id tokens where a -// readable querystring buys little. Empty entries are dropped so a cleared funnel emits nothing. +// exactly (and future multi columns for free) where a readable querystring buys little. A text +// column's entry is what the user typed, so a map with one is a typed key (ModuleDef.typedUrlKeys). +// Empty entries are dropped so a cleared funnel emits nothing. export function encodeFilterMap(map: FilterMap): string | null { const clean: FilterMap = {}; for (const [k, v] of Object.entries(map)) { diff --git a/frontend/src/lib/history.reloadedNestedBack.dom.test.tsx b/frontend/src/lib/history.reloadedNestedBack.dom.test.tsx new file mode 100644 index 00000000..44a051db --- /dev/null +++ b/frontend/src/lib/history.reloadedNestedBack.dom.test.tsx @@ -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"); + }); +}); diff --git a/frontend/src/lib/history.reloadedNestedEdge.dom.test.tsx b/frontend/src/lib/history.reloadedNestedEdge.dom.test.tsx new file mode 100644 index 00000000..ebf9836a --- /dev/null +++ b/frontend/src/lib/history.reloadedNestedEdge.dom.test.tsx @@ -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); + }); +}); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 48e7e1b5..8086f7b7 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -253,7 +253,7 @@ export function useHistorySubview( function writeSubEntry(next: T, url: SubviewUrl | undefined): void { const kept = url?.entry ? url.entry(next) : next; const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; - window.history.replaceState({ ...window.history.state, _sub: kept }, "", href); + replaceView({ ...window.history.state, _sub: kept }, href); } // --- Overlays / modals (one history entry each, nesting-safe) ------------------------------- @@ -304,6 +304,21 @@ let historyDepth = 0; let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null; const reloadedDepth: unknown = window.history.state?._ovd; let deadBelow = 0; + +/** Rewrite the current entry in place for another view (a refined search, another channel, a + * sub-view followed): `state` and `href` as replaceState takes them. With no overlay open, the + * entry's overlay marks go — the ones a reload under an overlay left. They named the view it was + * left from, and the first overlay over the new one would take this entry over: closed, it stepped + * back onto the entry below and the screen went back to the view before the rewrite. */ +export function replaceView(state: Record, href?: string): void { + if (historyDepth > 0) { + window.history.replaceState(state, "", href); + return; + } + reloadedOverlayEntry = null; + const { _ov: _overlay, _ovd: _depth, ...view } = state; + window.history.replaceState(view, "", href); +} // Programmatic history steps back still awaiting their popstate — each must be ignored — and how // many entries they step over in all (a step over dead entries is one traversal of several). let pendingProgrammatic = 0; diff --git a/frontend/src/lib/logHooks.dom.test.ts b/frontend/src/lib/logHooks.dom.test.ts index 57da00f4..d3a9cb86 100644 --- a/frontend/src/lib/logHooks.dom.test.ts +++ b/frontend/src/lib/logHooks.dom.test.ts @@ -10,8 +10,8 @@ import { installClientLog } from "./logHooks"; const appCopy = (text: string | null) => ["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null; -// The pages' search boxes' keys, as main.tsx hands them over from the module definitions. -const TYPED = ["fq", "yt"]; +// The pages' typed keys, as main.tsx hands them over from the module definitions. +const TYPED = ["q", "fq", "yt"]; beforeAll(() => { installClientLog(appCopy, TYPED); diff --git a/frontend/src/lib/logHooks.ts b/frontend/src/lib/logHooks.ts index c2168511..fe4c041e 100644 --- a/frontend/src/lib/logHooks.ts +++ b/frontend/src/lib/logHooks.ts @@ -13,10 +13,10 @@ import { FRONTEND_SHA, FRONTEND_VERSION } from "./version"; import { enabled, errorAttrs, log } from "./log"; import { pathTemplate } from "./http"; -// URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes"). -// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG. The -// shell's own here; each page's search boxes join them at install (ModuleDef.typedUrlKeys). -const TYPED_PARAMS = new Set(["q", "keywords", "search"]); +// URL parameters that carry what the user TYPED — a search, a column's text filter. Logged only at +// TRACE ("Teljes"). Every other parameter is filter/sort/view state: its NAME is kept at INFO, its +// value at DEBUG. The shell has none; each page's join at install (ModuleDef.typedUrlKeys). +const TYPED_PARAMS = new Set(); type Via = "start" | "push" | "replace" | "pop"; diff --git a/frontend/src/lib/moduleDef.ts b/frontend/src/lib/moduleDef.ts index bf1dd813..17346244 100644 --- a/frontend/src/lib/moduleDef.ts +++ b/frontend/src/lib/moduleDef.ts @@ -50,9 +50,9 @@ export type ModuleDef = { // along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to // one module and none is a shell key (moduleUrl.contract.test). urlKeys: readonly string[]; - // Those of them that carry what the user TYPED (a search box's text): the client log keeps their - // values out below its most detailed level, as it does the shell's own (lib/logHooks). A new search - // box's key is declared here with it (moduleUrl.contract.test holds them to urlKeys). + // Those of them that carry what the user TYPED (a search box's text, a column filter map holding a + // text filter): the client log keeps their values out below its most detailed level (lib/logHooks). + // moduleUrl.contract.test sorts every key of urlKeys as typed or not, so a new one is decided. typedUrlKeys?: readonly string[]; // Its page plays YouTube videos in the player and reopens it from an address that names one (a // reload, a bookmark — lib/playerUrlState), with its own list as the queue. A page without it gets diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 2c45ae38..82e2ba58 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -4,7 +4,7 @@ // bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is // retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays // the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it. -import { TAG_CATEGORIES, type FeedFilters } from "./api"; +import { FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, TAG_CATEGORIES, type FeedFilters } from "./api"; import { CHANNEL_PAGE_KEYS } from "./channelPageUrlState"; import { PLAYER_KEYS } from "./playerUrlState"; @@ -55,8 +55,8 @@ export const SHELL_KEYS = [ export function filtersToParams(f: FeedFilters): URLSearchParams { const p = new URLSearchParams(); if (f.q) p.set("q", f.q); - if (f.show && f.show !== "unwatched") p.set("show", f.show); - if (f.sort && f.sort !== "newest") p.set("sort", f.sort); + if (f.show && f.show !== FEED_DEFAULT_SHOW) p.set("show", f.show); + if (f.sort && f.sort !== FEED_DEFAULT_SORT) p.set("sort", f.sort); if (f.scope === "all") p.set("scope", "all"); // Library provenance filter (only meaningful in "all" scope); "organic" is the default. if (f.scope === "all" && f.librarySource && f.librarySource !== "organic") @@ -90,8 +90,8 @@ export function filterParamUpdates(f: FeedFilters | null): Record = {}; for (const k of KEYS) out[k] = p.get(k); if (f) { - out.show = f.show || "unwatched"; - out.sort = f.sort || "newest"; + out.show = f.show || FEED_DEFAULT_SHOW; + out.sort = f.sort || FEED_DEFAULT_SORT; } return out; } @@ -106,8 +106,8 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee }; if (params.has("q")) f.q = params.get("q") ?? ""; - if (params.has("show")) f.show = params.get("show") || "unwatched"; - if (params.has("sort")) f.sort = params.get("sort") || "newest"; + if (params.has("show")) f.show = params.get("show") || FEED_DEFAULT_SHOW; + if (params.has("sort")) f.sort = params.get("sort") || FEED_DEFAULT_SORT; if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my"; if (params.has("source")) { const s = params.get("source"); diff --git a/frontend/src/lib/useActiveFilters.ts b/frontend/src/lib/useActiveFilters.ts index c37fd6fd..ed9ea940 100644 --- a/frontend/src/lib/useActiveFilters.ts +++ b/frontend/src/lib/useActiveFilters.ts @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { DEFAULT_TAG_MODES, type FeedFilters } from "./api"; +import { DEFAULT_TAG_MODES, FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, type FeedFilters } from "./api"; import { tagPickLabel } from "./tagPicks"; import { useChannelNames } from "./useChannelNames"; import { parseSort } from "./feedSort"; @@ -20,11 +20,11 @@ const DEFAULT_SIDEBAR_FILTERS: Omit = { tagModes: DEFAULT_TAG_MODES, untagged: false, channelIds: [], - sort: "newest", + sort: FEED_DEFAULT_SORT, includeNormal: true, includeShorts: false, includeLive: false, - show: "unwatched", + show: FEED_DEFAULT_SHOW, }; /** @@ -74,19 +74,19 @@ export function useActiveFilters( }); } - if (filters.show !== "unwatched") { + if (filters.show !== FEED_DEFAULT_SHOW) { out.push({ key: "show", label: t("sidebar.show." + filters.show), - remove: () => setFilters({ ...filters, show: "unwatched" }), + remove: () => setFilters({ ...filters, show: FEED_DEFAULT_SHOW }), }); } - if (filters.sort !== "newest" && filters.sort !== "relevance") { + if (filters.sort !== FEED_DEFAULT_SORT && filters.sort !== "relevance") { out.push({ key: "sort", label: t("feed.sortKey." + parseSort(filters.sort).key), - remove: () => setFilters({ ...filters, sort: "newest", seed: undefined }), + remove: () => setFilters({ ...filters, sort: FEED_DEFAULT_SORT, seed: undefined }), }); } diff --git a/frontend/src/moduleSearch.url.dom.test.tsx b/frontend/src/moduleSearch.url.dom.test.tsx index 7c46c42b..0e4472b0 100644 --- a/frontend/src/moduleSearch.url.dom.test.tsx +++ b/frontend/src/moduleSearch.url.dom.test.tsx @@ -113,7 +113,7 @@ describe.each([ act(() => nav.current!.setPage("feed")); act(() => nav.current!.setPage(page as "files" | "playlists")); 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(); }); }); diff --git a/frontend/src/moduleUrl.contract.test.ts b/frontend/src/moduleUrl.contract.test.ts index 61cc7798..01dc3773 100644 --- a/frontend/src/moduleUrl.contract.test.ts +++ b/frontend/src/moduleUrl.contract.test.ts @@ -4,9 +4,9 @@ // 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 // 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 -// keys, checked here) — never a key of its own making — and every key literal read from the live -// query is declared. +// what its encoders write, a writer outside lib/ hands the address a lib/ function's result (or +// literal keys, checked here) — never a key of its own making — every key literal read from the live +// 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 { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; @@ -90,8 +90,96 @@ function sources(dir: string): string[] { const WRITER_OBJECTS = [ /(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/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 }); + + 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 // `.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; } +// 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 // RETIRED) — no module writes them any more. 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)", () => { const seen = new Set(queryKeyLiterals().map(({ key }) => key)); // 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(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 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)", () => { @@ -221,7 +394,11 @@ describe("module URL keys", () => { page, ).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. expect(readFileSync(join(SRC, "main.tsx"), "utf8")).toContain( "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. describe("the bookmark net", () => { 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) => - spec.includes(`page=${page}`) || - spec.includes(`gotoPage(page, "${page}")`) || - spec.includes(`gotoAdminPage(page, "${page}")`); + tests.some((t) => t.includes(`tag: "@${page}"`) && t.includes("bookmarked(")); it.each(REGISTERED_PAGES.filter((p) => moduleDef(p).urlKeys.length > 0 && p !== "plex"))( "takes a round trip through %s", diff --git a/frontend/src/modules/channels/module.ts b/frontend/src/modules/channels/module.ts index fd411622..346f9c9d 100644 --- a/frontend/src/modules/channels/module.ts +++ b/frontend/src/modules/channels/module.ts @@ -15,5 +15,5 @@ export const channelsModule: ModuleDef = { maxWidth: "96rem", Store: ChannelsProvider, urlKeys: CHANNELS_KEYS, - typedUrlKeys: ["cq"], + typedUrlKeys: ["cq", "cf"], }; diff --git a/frontend/src/modules/feed/Feed.tsx b/frontend/src/modules/feed/Feed.tsx index 83d85d7b..29b53679 100644 --- a/frontend/src/modules/feed/Feed.tsx +++ b/frontend/src/modules/feed/Feed.tsx @@ -269,7 +269,7 @@ export default function Feed({ const { setYtCount, askYtSearch } = useNavigationActions(); // 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 - // 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 // 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); @@ -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. const ytShown = (rows: Video[]): Video[] => 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 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 @@ -520,7 +520,7 @@ export default function Feed({ // list it was linked over: leaving the search first gives it up. useOpenLinkedPlayer( 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) => { openVideo(video); // 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). if (ytActive) { const ytItems: Video[] = ytShown(ytLoaded); - const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden"); + const ytPlayerQueue: Video[] = ytQueueOf(ytItems); const ytError = ytQuery.error instanceof HttpError ? ytQuery.error.detail || t("feed.yt.error") @@ -649,7 +649,7 @@ export default function Feed({
{t("feed.yt.searching")}
) : ytError && !ytItems.length ? ( // 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. { }); 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 () => { const search = vi .spyOn(api, "searchYoutube") diff --git a/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx b/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx index 224a6d17..583d79ac 100644 --- a/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx +++ b/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx @@ -2,7 +2,7 @@ // // 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 -// 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 { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; diff --git a/frontend/src/modules/feed/feedFiltersInit.ts b/frontend/src/modules/feed/feedFiltersInit.ts index c203c476..0c956d1d 100644 --- a/frontend/src/modules/feed/feedFiltersInit.ts +++ b/frontend/src/modules/feed/feedFiltersInit.ts @@ -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"; // 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, channelIds: [], q: "", - sort: "newest", + sort: FEED_DEFAULT_SORT, scope: "my", includeNormal: true, includeShorts: false, includeLive: false, - show: "unwatched", + show: FEED_DEFAULT_SHOW, }; /** The watch-state choices, in the order the filter offers them. */ diff --git a/frontend/src/modules/files/Files.menu.dom.test.tsx b/frontend/src/modules/files/Files.menu.dom.test.tsx index 26f7bb1c..676f8d02 100644 --- a/frontend/src/modules/files/Files.menu.dom.test.tsx +++ b/frontend/src/modules/files/Files.menu.dom.test.tsx @@ -170,8 +170,11 @@ const YT = folder(1, { source_key: "youtube" }); const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null }); const HUB = folder(4, { kind: "hub", retention_days: null, count: 0 }); -afterEach(() => { +afterEach(async () => { 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(); // The page names its sort in the address: one case's sort must not open the next. 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 // 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 () => { - // 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"); mocks.files = 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.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( diff --git a/frontend/src/modules/files/Files.tsx b/frontend/src/modules/files/Files.tsx index 5ba1dc73..7399601e 100644 --- a/frontend/src/modules/files/Files.tsx +++ b/frontend/src/modules/files/Files.tsx @@ -294,7 +294,9 @@ export default function Files() { const listIn = wide ? wideIn && wideQ.data !== undefined : filesQ.data !== undefined; const linkedRow = linkedFile && listIn ? rows.find((f) => f.id === linkedFile.id) : 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) { notify({ level: "warning", message: t("files.linkedGone") }); clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY); diff --git a/frontend/src/modules/files/FilesProvider.tsx b/frontend/src/modules/files/FilesProvider.tsx index 2f65dfcd..4c8fb3dc 100644 --- a/frontend/src/modules/files/FilesProvider.tsx +++ b/frontend/src/modules/files/FilesProvider.tsx @@ -102,8 +102,10 @@ export function FilesProvider({ children }: { children: ReactNode }) { const shown = urlPage(nav) === "files"; // Written once the typing pauses (one address-bar write per keystroke is churn a browser throttles); - // an arrival's cleared box at once. - const typed = useDebounced(search.trim(), 300, focusRaw?.token); + // an emptied box at once — an arrival's, or the page left: back on it within the pause, the address + // would name the ended search again. + const debounced = useDebounced(search.trim(), 300, focusRaw?.token); + const typed = search.trim() ? debounced : ""; useUrlMirror( filesListParams( shown ? { search: typed, folder: viewParam(view), item: focus ? focus.id : null } : null, diff --git a/frontend/src/modules/playlists/Playlists.dom.test.tsx b/frontend/src/modules/playlists/Playlists.dom.test.tsx index 4beca9c7..1b59c041 100644 --- a/frontend/src/modules/playlists/Playlists.dom.test.tsx +++ b/frontend/src/modules/playlists/Playlists.dom.test.tsx @@ -133,13 +133,16 @@ let qc: QueryClient; function mount( lists: Record, prefs: Record = {}, - { strictOverCache = false } = {}, + { + strictOverCache = false, + detail, + }: { strictOverCache?: boolean; detail?: (id: number) => Promise } = {}, ) { vi.spyOn(api, "playlists").mockResolvedValue( Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never, ); 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.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"]); }); + 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(() => {}) : 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 () => { window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z"); const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0)); diff --git a/frontend/src/modules/playlists/Playlists.tsx b/frontend/src/modules/playlists/Playlists.tsx index c28e86b9..5aad7d0b 100644 --- a/frontend/src/modules/playlists/Playlists.tsx +++ b/frontend/src/modules/playlists/Playlists.tsx @@ -586,18 +586,26 @@ function PlaylistDetail() { // (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 // 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 = + linkedOutOfReach || (detail != null && (detail.items.length === 0 || items.length > 0)) || - (selectedId != null && detailQuery.isError) || - (selectedId == null && listQuery.data?.length === 0); + (selectedId != null && detailQuery.isError); useOpenLinkedPlayer( linkedListIn, - (id) => shown.find((v) => v.id === id), + (id) => (linkedOutOfReach ? undefined : shown.find((v) => v.id === id)), (video, inList) => { 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) { diff --git a/frontend/src/modules/playlists/PlaylistsProvider.tsx b/frontend/src/modules/playlists/PlaylistsProvider.tsx index 5b9b1d06..f8ee283c 100644 --- a/frontend/src/modules/playlists/PlaylistsProvider.tsx +++ b/frontend/src/modules/playlists/PlaylistsProvider.tsx @@ -46,7 +46,9 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) { const shown = urlPage(nav) === "playlists"; // Leaving the page ends the search: adjusted during the render that leaves it. 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, "")); const state = useMemo(() => ({ search, selectedId }), [search, selectedId]); diff --git a/frontend/src/modules/playlists/module.ts b/frontend/src/modules/playlists/module.ts index fb773d1c..136346f6 100644 --- a/frontend/src/modules/playlists/module.ts +++ b/frontend/src/modules/playlists/module.ts @@ -19,6 +19,6 @@ export const playlistsModule: ModuleDef = { Store: PlaylistsProvider, // The playlist shown (PlaylistsProvider, playlistsInit). urlKeys: PLAYLISTS_KEYS, - typedUrlKeys: ["plq"], + typedUrlKeys: ["plq", "plf"], playsVideos: true, }; diff --git a/frontend/src/modules/scheduler/Scheduler.tsx b/frontend/src/modules/scheduler/Scheduler.tsx index 8da7744a..f3f708d5 100644 --- a/frontend/src/modules/scheduler/Scheduler.tsx +++ b/frontend/src/modules/scheduler/Scheduler.tsx @@ -47,6 +47,7 @@ import { jobFailureText, jobResultText } from "../../lib/jobResult"; import { StatusDot, type DotKey } from "../activity"; import { useNavigationActions } from "../../features/navigation/navigationContext"; import { urlWithParams } from "../../lib/urlState"; +import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "../../lib/activityUrlState"; const POLL_MS = 4000; // 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.state, "", - urlWithParams({ page: "activity", rjob: job }), + urlWithParams({ + page: "activity", + ...activityParamUpdates({ ...DEFAULT_ACTIVITY_STATE, job }), + }), ), ) }