From 7b4e007680089dbd2d181131e7ef289d8a0469ff Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 27 Oct 2026 11:51:29 +0100 Subject: [PATCH] feat(nav): the live YouTube search and Home's budget are named in the address A YouTube search is `?yt=` (+ `ytn` for a non-default count), so a reload, a link or Back/Forward reopens it. It runs by itself only where it costs no quota: the free page scrape, a search asked for in this document, or results still cached. An API search reopened any other way shows its term and waits for "Search YouTube again". Home's time budget is `hb`; the question's draw stays on the history entry. An e2e spec opens every page's bookmark in a fresh browser context; CLAUDE.md and docs/development.md name the module URL codec as the place for view state. --- CLAUDE.md | 1 + docs/development.md | 6 + frontend/e2e/bookmark.spec.ts | 146 ++++++++++++ .../navigation/NavigationProvider.tsx | 113 ++++++--- .../NavigationProvider.ytSearch.dom.test.tsx | 198 ++++++++++++++++ .../features/navigation/navigationContext.ts | 15 +- frontend/src/i18n/locales/en/feed.json | 5 +- frontend/src/i18n/locales/hu/feed.json | 5 +- frontend/src/lib/homeUrlState.ts | 20 ++ frontend/src/lib/ytSearchUrlState.test.ts | 31 +++ frontend/src/lib/ytSearchUrlState.ts | 32 +++ frontend/src/moduleUrl.contract.test.ts | 5 +- .../src/modules/channels/ChannelsProvider.tsx | 2 +- frontend/src/modules/feed/Feed.tsx | 48 ++-- .../modules/feed/Feed.ytReopen.dom.test.tsx | 218 ++++++++++++++++++ frontend/src/modules/feed/module.ts | 4 +- frontend/src/modules/home/Home.dom.test.tsx | 36 ++- frontend/src/modules/home/Home.tsx | 17 +- frontend/src/modules/home/module.ts | 7 +- .../modules/scheduler/Scheduler.dom.test.tsx | 2 + 20 files changed, 847 insertions(+), 64 deletions(-) create mode 100644 frontend/e2e/bookmark.spec.ts create mode 100644 frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx create mode 100644 frontend/src/lib/homeUrlState.ts create mode 100644 frontend/src/lib/ytSearchUrlState.test.ts create mode 100644 frontend/src/lib/ytSearchUrlState.ts create mode 100644 frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 223ce247..0534ad64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory | FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals | | FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler | | FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `` | +| FE view state | the module's URL codec: its keys in `urlKeys` (`module.ts`), read and written by `lib/UrlState.ts` through `useUrlMirror` (contract-held, `e2e/bookmark.spec.ts`) | `useState` / sessionStorage alone: a reload, a bookmark or a shared link loses the view | | FE a page's header content | the page renders it into the floating header itself (`PageHeader`, `ui/PageShell`) | a `page === …` branch in the shell's header, or a bridge carrying a page's state up to it | | FE module store | its definition's `Store` (`module.ts`), mounted at the root by `ModuleStores`; it reads no other module's store (contract-held) | a provider hand-nested in `main.tsx` | | FE saved views | the module's own store from `features/filters` (`createSavedViewsStore` + `useSavedViewsStore`) | one store switched by the active page — every visit elsewhere reset it | diff --git a/docs/development.md b/docs/development.md index a81a6349..994a3d87 100644 --- a/docs/development.md +++ b/docs/development.md @@ -48,6 +48,12 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. `urlKeys` names every address-bar key its view writes (the URL is the view: a bookmark reopens it), its list kept in `lib/` beside its codec; the shell clears them on a page switch and opens a page without another page's keys. + Every setting the page shows — a tab, a sort, a filter, a search, the item open in it — goes there: a codec + in `lib/UrlState.ts` (read the params into the view, write the view as updates, a default left out), + written with `useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through + `useBackToClose`'s), read back first on mount (a link wins over anything remembered). Add its round trip to + `e2e/bookmark.spec.ts`. 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. 4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in diff --git a/frontend/e2e/bookmark.spec.ts b/frontend/e2e/bookmark.spec.ts new file mode 100644 index 00000000..85815399 --- /dev/null +++ b/frontend/e2e/bookmark.spec.ts @@ -0,0 +1,146 @@ +import { test, expect, type Browser, type Page } from "@playwright/test"; +import { + clickVisibleChannelLink, + expectContentType, + gotoPage, + openApp, + openFirstVideo, + toggleContentType, +} from "./helpers"; + +// 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, plus the +// overlays the shell opens over any page (the channel page, the player) and the live search. + +const STORAGE_STATE = "e2e/.auth/state.json"; + +/** The page's address, opened as a bookmark in a fresh context of the same account. */ +async function bookmarked(browser: Browser, page: Page, prepare?: (p: Page) => Promise) { + const url = page.url(); + const ctx = await browser.newContext({ storageState: STORAGE_STATE, locale: "en-US" }); + const fresh = await ctx.newPage(); + await prepare?.(fresh); + await openApp(fresh, url); + return fresh; +} + +const tab = (p: Page, name: string) => p.getByRole("tab", { name, exact: true }); + +test.describe("a bookmark reopens the view it was taken of", () => { + test("the feed's filters", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await toggleContentType(page, "includeLive"); + await expect(page).toHaveURL(/[?&]live=/); + const fresh = await bookmarked(browser, page); + await expectContentType(fresh, "includeLive", true); + await fresh.context().close(); + }); + + test("Home's time budget", async ({ page, browser }) => { + await openApp(page, "/?page=home"); + await page.getByRole("button", { name: "An evening", exact: true }).click(); + await expect(page).toHaveURL(/[?&]hb=long/); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByRole("button", { name: "An evening", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + await fresh.context().close(); + }); + + test("a Settings tab", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoPage(page, "settings"); + await tab(page, "Notifications").click(); + const fresh = await bookmarked(browser, page); + await expect(tab(fresh, "Notifications")).toHaveAttribute("aria-selected", "true"); + await fresh.context().close(); + }); + + test("an admin page's tab", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await page.getByTestId("nav-admin").click(); + await page.getByTestId("nav-admin-users").click(); + await tab(page, "Access requests").click(); + const fresh = await bookmarked(browser, page); + await expect(tab(fresh, "Access requests")).toHaveAttribute("aria-selected", "true"); + 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 Files search", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoPage(page, "files"); + await page.getByPlaceholder("Search files…").fill("zz"); + await expect(page).toHaveURL(/zz/); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByPlaceholder("Search files…")).toHaveValue("zz"); + await fresh.context().close(); + }); + + test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + expect(await clickVisibleChannelLink(page)).toBe(true); + const title = await page.getByTestId("channel-title").textContent(); + await tab(page, "About").click(); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByTestId("channel-title")).toHaveText(title ?? ""); + await expect(tab(fresh, "About")).toHaveAttribute("aria-selected", "true"); + await fresh.context().close(); + }); + + test("the player and the video it plays", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await openFirstVideo(page); + await expect(page).toHaveURL(/[?&]watch=/); + const watch = new URL(page.url()).searchParams.get("watch"); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByTestId("player-modal")).toBeVisible(); + expect(new URL(fresh.url()).searchParams.get("watch")).toBe(watch); + await fresh.context().close(); + }); + + // The search itself never reaches YouTube here: the answer is the server's shape, served by the + // test, and the request it answers is what the address named. + test("a live YouTube search, its term and its count", async ({ page, browser }) => { + const answer = async (p: Page, seen: string[]) => { + 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(); + }); +}); diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index 78ff10fa..a8eded41 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -21,6 +21,12 @@ import { import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry"; import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState"; import { PLAYER_KEYS } from "../../lib/playerUrlState"; +import { + DEFAULT_YT_COUNT, + ytCountFrom, + ytSearchFrom, + ytSearchParams, +} from "../../lib/ytSearchUrlState"; import { channelViewFrom, loadInitialPage, @@ -77,14 +83,19 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // and the address bar (stamped from it below) agree. const me = useMe(); const page = reachablePage(chosenPage, me); - // Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search - // spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides - // in history.state and is free to re-fetch. The popstate handler derives it from history.state so - // Back steps player → search → feed instead of the search vanishing without a history entry. - const [ytSearch, setYtSearch] = useState(() => { - const st = window.history.state; - return st?._yt && st?._ytScrape ? (st._yt as string) : null; - }); + // 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). Anything else + // — a reload, a link, a Back onto an older search — runs only when it costs no quota (Feed decides). + const [ytSearch, setYtSearch] = useState(() => + page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null, + ); + const [ytCount, setYtCountState] = useState(() => + ytCountFrom(new URLSearchParams(window.location.search)), + ); + const [ytAsked, setYtAsked] = useState(null); + const ytSearchRef = useLatest(ytSearch); // The open channel page rides in history.state (_chan = id, _chanName = a name to show before the // detail loads) AND is mirrored to the address bar as ?chan=, so Back closes it, a reload restores // it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app @@ -176,26 +187,52 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const enterYtSearch = useCallback((q: string) => { setYtSearch(q); - // Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it - // once the results resolve, so a reload only restores a confirmed scrape-sourced search. - const { _ytScrape: _drop, ...st } = window.history.state || {}; - if (st._yt) - window.history.replaceState({ ...st, _yt: q }, ""); // refine current search - else { - // New sub-view entry. - const { _ytScrape: _dropToo, ...left } = rememberNavPosition(); - window.history.pushState({ ...left, sfPage: "feed", _yt: q }, ""); + setYtAsked(q); + const st = window.history.state ?? {}; + const here = new URLSearchParams(window.location.search); + 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))), + ); + } else { + // New sub-view entry, at the default count. + setYtCountState(DEFAULT_YT_COUNT); + const left = rememberNavPosition(); + window.history.pushState( + { ...left, sfPage: "feed", _yt: q }, + "", + urlWithParams(ytSearchParams(q, DEFAULT_YT_COUNT)), + ); } }, []); + // The press that runs the search on screen, where it did not run by itself. + const askYtSearch = useCallback(() => setYtAsked(ytSearchRef.current), [ytSearchRef]); + + const setYtCount = useCallback( + (n: number) => { + setYtCountState(n); + window.history.replaceState( + window.history.state, + "", + urlWithParams(ytSearchParams(ytSearchRef.current, n)), + ); + }, + [ytSearchRef], + ); + const exitYtSearch = useCallback(() => { // Pop our search entry (so Forward still works); fall back to a clear in place if we don't own // one, or there is no entry of this document below it (a full tab, see backStaysInDocument). if (window.history.state?._yt && backStaysInDocument()) window.history.back(); else { setYtSearch(null); - const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {}; - window.history.replaceState(st, ""); + const { _yt: _q, ...st } = window.history.state ?? {}; + window.history.replaceState(st, "", urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT))); } }, []); @@ -244,9 +281,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // then sync state from history.state on popstate — the browser restores each entry's URL for us, so // the popstate handler only needs to read history.state. useEffect(() => { - // A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape - // so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName - // are always kept so a channel page survives F5. + // The entry's `_yt` mark stays while the address still names its search (a reload of it); a mark + // left without one (an older build's entry) goes. _chan/_chanName are always kept so a channel + // page survives F5. const st = window.history.state || {}; // Mirror the resolved initial page to the address bar (the feed too) so a page reached from the // stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any @@ -264,7 +301,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)), page: mountPage, }); - if (st._yt && st._ytScrape) { + if (st._yt && ytSearchFrom(new URLSearchParams(window.location.search)) != null) { window.history.replaceState({ ...st, sfPage: mountPage }, "", url); } else { const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; @@ -287,9 +324,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) { ); setPageState(p); setAccountRaw(PAGE_KEY, p); - // The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward - // restores or clears them to match the entry we landed on. - setYtSearch((e.state?._yt as string) ?? null); + // 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 — not asked for: an API search waits for a press. + const here = new URLSearchParams(window.location.search); + setYtSearch(p === "feed" ? ytSearchFrom(here) : null); + setYtCountState(ytCountFrom(here)); // The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark // entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a // popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom @@ -301,12 +340,26 @@ export function NavigationProvider({ children }: { children: ReactNode }) { }, [mountPage]); const state = useMemo( - () => ({ page, channelView, ytSearch }), - [page, channelView, ytSearch], + () => ({ + page, + channelView, + ytSearch, + ytCount, + ytSearchAsked: ytSearch != null && ytAsked === ytSearch, + }), + [page, channelView, ytSearch, ytCount, ytAsked], ); const actions = useMemo( - () => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }), - [setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch], + () => ({ + setPage, + openChannel, + closeChannel, + enterYtSearch, + exitYtSearch, + askYtSearch, + setYtCount, + }), + [setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch, askYtSearch, setYtCount], ); return ( diff --git a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx new file mode 100644 index 00000000..a610ed4a --- /dev/null +++ b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx @@ -0,0 +1,198 @@ +// @vitest-environment jsdom +// +// The live YouTube search in the address: sent from the search box it gets its own entry naming the +// term (and its count), a reload or a link opens it again, Back/Forward read it from the address the +// browser restored — and only a search asked for in this document counts as asked (an API search +// reopened any other way waits for a press, Feed's part). +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { renderHook, act, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), + }; +}); + +import { NavigationProvider } from "./NavigationProvider"; +import { useNavigation, useNavigationActions } from "./navigationContext"; +import { qk } from "../../lib/queryKeys"; +import { claimLocalData } from "../../lib/storage"; + +let qc = new QueryClient(); +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); +const render = () => + renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper }); +const search = () => new URLSearchParams(window.location.search); + +/** No entry of this document under the current one (a bookmark opened in a new tab). */ +function stubNoEntryBelow() { + Object.defineProperty(window, "navigation", { + configurable: true, + value: { + currentEntry: { index: 1 }, + entries: () => [{ sameDocument: false }, { sameDocument: true }], + }, + }); +} + +beforeEach(() => { + qc = new QueryClient(); + qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true }); + localStorage.clear(); + claimLocalData(1); + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi"); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + Reflect.deleteProperty(window, "navigation"); +}); + +describe("NavigationProvider · the YouTube search in the address", () => { + it("names a search sent from the box on an entry of its own, over the feed's", () => { + const { result } = render(); + const depth = window.history.length; + act(() => result.current.enterYtSearch("lofi")); + expect(window.history.length).toBe(depth + 1); + expect(search().get("yt")).toBe("lofi"); + expect(search().get("q")).toBe("lofi"); // the feed's own keys stay + expect(search().get("ytn")).toBeNull(); // the default count is left out + expect(result.current.ytSearch).toBe("lofi"); + expect(result.current.ytSearchAsked).toBe(true); + }); + + it("refines the search on screen in place, at the count it shows", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.setYtCount(60)); + expect(search().get("ytn")).toBe("60"); + const depth = window.history.length; + act(() => result.current.enterYtSearch("lofi beats")); + expect(window.history.length).toBe(depth); + expect(search().get("yt")).toBe("lofi beats"); + expect(search().get("ytn")).toBe("60"); + expect(result.current.ytCount).toBe(60); + }); + + it("a new search starts at the default count", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.setYtCount(40)); + stubNoEntryBelow(); // left in place, so the next search is a new entry + vi.spyOn(window.history, "back").mockImplementation(() => {}); + act(() => result.current.exitYtSearch()); + act(() => result.current.enterYtSearch("jazz")); + expect(result.current.ytCount).toBe(20); + expect(search().get("ytn")).toBeNull(); + }); + + it("writes the count, and clears it back at the default", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.setYtCount(100)); + expect(search().get("ytn")).toBe("100"); + act(() => result.current.setYtCount(20)); + expect(search().get("ytn")).toBeNull(); + }); + + it("reopens the search a reload or a link names, not as asked", () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40"); + const { result } = render(); + expect(result.current.ytSearch).toBe("lofi"); + expect(result.current.ytCount).toBe(40); + expect(result.current.ytSearchAsked).toBe(false); + expect(search().get("yt")).toBe("lofi"); // the mount's own stamp keeps it + act(() => result.current.askYtSearch()); + expect(result.current.ytSearchAsked).toBe(true); + }); + + it("refines a search a link opened in place too, at the count it shows", () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40"); + const { result } = render(); + const depth = window.history.length; + act(() => result.current.enterYtSearch("jazz")); + expect(window.history.length).toBe(depth); + expect([search().get("yt"), search().get("ytn")]).toEqual(["jazz", "40"]); + }); + + it("Back onto an entry whose address alone names the search reopens it", () => { + const { result } = render(); + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearch).toBe("lofi"); + }); + + it("keeps the search mark of an entry the reload started from", () => { + window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi"); + render(); + expect(window.history.state?._yt).toBe("lofi"); + }); + + it("drops an older build's search mark the address does not name", () => { + window.history.replaceState( + { sfPage: "feed", _yt: "lofi", _ytScrape: true }, + "", + "/?page=feed", + ); + const { result } = render(); + expect(result.current.ytSearch).toBeNull(); + expect(window.history.state?._yt).toBeUndefined(); + expect(window.history.state?._ytScrape).toBeUndefined(); + }); + + it("names no search under another page", () => { + window.history.replaceState(null, "", "/?page=plex&yt=lofi"); + const { result } = render(); + expect(result.current.page).toBe("plex"); + expect(result.current.ytSearch).toBeNull(); + expect(search().get("yt")).toBeNull(); + }); + + it("Back/Forward read the search from the address the browser restored, not as asked", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + window.history.replaceState({ sfPage: "feed", _yt: "jazz" }, "", "/?page=feed&yt=jazz&ytn=60"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearch).toBe("jazz"); + expect(result.current.ytCount).toBe(60); + expect(result.current.ytSearchAsked).toBe(false); + + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearch).toBeNull(); + }); + + it("leaving a search with no entry of this document below clears it from the address", () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40"); + const { result } = render(); + stubNoEntryBelow(); + const back = vi.spyOn(window.history, "back").mockImplementation(() => {}); + act(() => result.current.exitYtSearch()); + expect(back).not.toHaveBeenCalled(); + expect(result.current.ytSearch).toBeNull(); + expect(search().get("yt")).toBeNull(); + expect(search().get("ytn")).toBeNull(); + }); + + it("a page switch takes the search off the address", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.setPage("home")); + expect(result.current.ytSearch).toBeNull(); + expect(search().get("yt")).toBeNull(); + }); +}); diff --git a/frontend/src/features/navigation/navigationContext.ts b/frontend/src/features/navigation/navigationContext.ts index b033ae96..ab173013 100644 --- a/frontend/src/features/navigation/navigationContext.ts +++ b/frontend/src/features/navigation/navigationContext.ts @@ -1,5 +1,6 @@ import { createContext, useContext } from "react"; import type { Page } from "../../lib/urlState"; +import { DEFAULT_YT_COUNT } from "../../lib/ytSearchUrlState"; import type { ChannelView } from "./navigationInit"; // The navigation contexts + their hooks, split out of NavigationProvider so that file exports only @@ -15,8 +16,13 @@ export interface NavigationState { page: Page; /** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */ channelView: ChannelView | null; - /** Live YouTube search term (null = normal feed). A feed sub-view; rides in history.state._yt. */ + /** Live YouTube search term (null = normal feed). A feed sub-view, named in the address (`yt`). */ ytSearch: string | null; + /** How many results the search gathers (`ytn`). */ + ytCount: number; + /** The search on screen was asked for in this document (sent from the search box, or run again by + * a press), not only reopened by a reload, a link or Back. An API search runs only when asked. */ + ytSearchAsked: boolean; } export interface NavigationActions { @@ -33,12 +39,17 @@ export interface NavigationActions { closeChannel: () => void; enterYtSearch: (q: string) => void; exitYtSearch: () => void; + /** Run the search on screen: the "Search YouTube again" press. */ + askYtSearch: () => void; + setYtCount: (n: number) => void; } export const NavigationStateContext = createContext({ page: "feed", channelView: null, ytSearch: null, + ytCount: DEFAULT_YT_COUNT, + ytSearchAsked: false, }); export const NavigationActionsContext = createContext({ setPage: () => {}, @@ -46,6 +57,8 @@ export const NavigationActionsContext = createContext({ closeChannel: () => {}, enterYtSearch: () => {}, exitYtSearch: () => {}, + askYtSearch: () => {}, + setYtCount: () => {}, }); export function useNavigation(): NavigationState { diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 64b53f9f..ce0941ad 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -75,6 +75,9 @@ "clearNow": "Clear now", "cleared_one": "Cleared {{count}} result", "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": "This search hasn't run again", + "notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload or a link doesn't start one by itself.", + "runAgain": "Search YouTube again" } } diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index e2fea925..111fe5b2 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -75,6 +75,9 @@ "clearNow": "Törlés most", "cleared_one": "{{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 nem futott újra", + "notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltés vagy link nem indít el egyet magától.", + "runAgain": "Keresés újra a YouTube-on" } } diff --git a/frontend/src/lib/homeUrlState.ts b/frontend/src/lib/homeUrlState.ts new file mode 100644 index 00000000..bf3efdbf --- /dev/null +++ b/frontend/src/lib/homeUrlState.ts @@ -0,0 +1,20 @@ +// Home's time budget in the address bar (`?page=home&hb=short`): a page SETTING, like a feed filter, +// so it is a link and survives a reload. What Home draws under it is not: the question's seed, the +// press and the cards already offered stay on the history entry (Home.tsx), because the same question +// draws other cards once what you have watched moves on — an address cannot promise "that view". +import { DISCOVER_BUDGETS, type DiscoverBudget } from "./api"; + +const HOME_BUDGET_KEY = "hb"; +export const HOME_KEYS = [HOME_BUDGET_KEY] as const; +export const DEFAULT_BUDGET: DiscoverBudget = "any"; + +/** The budget a URL names, or null when it names none (or none Home offers). */ +export function budgetFrom(params: URLSearchParams): DiscoverBudget | null { + const raw = params.get(HOME_BUDGET_KEY); + return DISCOVER_BUDGETS.includes(raw as DiscoverBudget) ? (raw as DiscoverBudget) : null; +} + +/** The URL update for a budget: the default clears its key. */ +export function budgetParams(budget: DiscoverBudget): Record { + return { [HOME_BUDGET_KEY]: budget === DEFAULT_BUDGET ? null : budget }; +} diff --git a/frontend/src/lib/ytSearchUrlState.test.ts b/frontend/src/lib/ytSearchUrlState.test.ts new file mode 100644 index 00000000..e23e31c6 --- /dev/null +++ b/frontend/src/lib/ytSearchUrlState.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { ytCountFrom, ytSearchFrom, ytSearchParams } from "./ytSearchUrlState"; + +const p = (q: string) => new URLSearchParams(q); + +describe("the live YouTube search's address", () => { + it("reads the term, trimmed; a blank one names no search", () => { + expect(ytSearchFrom(p("yt=%20lofi%20"))).toBe("lofi"); + expect(ytSearchFrom(p("yt=%20%20"))).toBeNull(); + expect(ytSearchFrom(p("q=lofi"))).toBeNull(); + }); + + it("reads one of the offered counts, else the default", () => { + expect(ytCountFrom(p("ytn=60"))).toBe(60); + expect(ytCountFrom(p("ytn=61"))).toBe(20); + expect(ytCountFrom(p("ytn=x"))).toBe(20); + expect(ytCountFrom(p(""))).toBe(20); + }); + + it("writes the count only off its default, and nothing with no search", () => { + expect(ytSearchParams("lofi", 40)).toEqual({ yt: "lofi", ytn: "40" }); + expect(ytSearchParams("lofi", 20)).toEqual({ yt: "lofi", ytn: null }); + expect(ytSearchParams(null, 40)).toEqual({ yt: null, ytn: null }); + }); + + it("round-trips", () => { + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(ytSearchParams("lofi beats", 100))) if (v) q.set(k, v); + expect([ytSearchFrom(q), ytCountFrom(q)]).toEqual(["lofi beats", 100]); + }); +}); diff --git a/frontend/src/lib/ytSearchUrlState.ts b/frontend/src/lib/ytSearchUrlState.ts new file mode 100644 index 00000000..6c8985ec --- /dev/null +++ b/frontend/src/lib/ytSearchUrlState.ts @@ -0,0 +1,32 @@ +// The feed's live YouTube search in the address bar (`?page=feed&yt=lofi&ytn=40`), so a search is a +// link and survives a reload. Only the term and how many results to gather: which source answers is +// the instance's setting at the moment the link opens (an admin may have switched it since), and that +// decides whether the search runs by itself (the free page scrape) or waits for a press (an API +// search spends 100 quota units, so nobody's reload or bookmark spends them unasked). + +const YT_SEARCH_KEY = "yt"; +const YT_COUNT_KEY = "ytn"; +export const YT_SEARCH_KEYS = [YT_SEARCH_KEY, YT_COUNT_KEY] as const; + +/** How many live-search results one fetch gathers; the first is the default, left out of the URL. */ +export const YT_COUNTS = [20, 40, 60, 100] as const; +export const DEFAULT_YT_COUNT = YT_COUNTS[0]; + +/** The search a URL names, or null: a blank term names none (the search box never sends one). */ +export function ytSearchFrom(params: URLSearchParams): string | null { + return params.get(YT_SEARCH_KEY)?.trim() || null; +} + +/** The result count a URL names; anything but one of the offered counts is the default. */ +export function ytCountFrom(params: URLSearchParams): number { + const n = Number(params.get(YT_COUNT_KEY)); + return (YT_COUNTS as readonly number[]).includes(n) ? n : DEFAULT_YT_COUNT; +} + +/** The URL updates for a search (null = none): a default count clears its key. */ +export function ytSearchParams(term: string | null, count: number): Record { + return { + [YT_SEARCH_KEY]: term, + [YT_COUNT_KEY]: term != null && count !== DEFAULT_YT_COUNT ? String(count) : null, + }; +} diff --git a/frontend/src/moduleUrl.contract.test.ts b/frontend/src/moduleUrl.contract.test.ts index 54050448..8ee94f78 100644 --- a/frontend/src/moduleUrl.contract.test.ts +++ b/frontend/src/moduleUrl.contract.test.ts @@ -16,6 +16,8 @@ import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState"; import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState"; import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState"; import { FILES_KEYS } from "./lib/filesUrlState"; +import { ytSearchParams } from "./lib/ytSearchUrlState"; +import { budgetParams } from "./lib/homeUrlState"; import { PLAYER_KEYS, playerParamUpdates } from "./lib/playerUrlState"; import { playlistViewParamUpdates } from "./lib/playlistsUrlState"; import { SETTINGS_TAB_KEY } from "./lib/settingsTab"; @@ -43,7 +45,8 @@ const FEED: FeedFilters = { // What each module's own encoders write, from the encoders themselves. Every module that declares a // key has a row, so a new one cannot declare keys its codec does not write, or the other way round. const WRITES: Partial string[]>> = { - feed: () => Object.keys(filterParamUpdates(FEED)), + feed: () => [...Object.keys(filterParamUpdates(FEED)), ...Object.keys(ytSearchParams("x", 40))], + home: () => Object.keys(budgetParams("short")), channels: () => Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {}, search: "" })), plex: () => [ diff --git a/frontend/src/modules/channels/ChannelsProvider.tsx b/frontend/src/modules/channels/ChannelsProvider.tsx index 87d9c0cf..771b7134 100644 --- a/frontend/src/modules/channels/ChannelsProvider.tsx +++ b/frontend/src/modules/channels/ChannelsProvider.tsx @@ -185,7 +185,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { // column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see. setFilters({ sync: "needsFull" }); setView("subscribed"); // the sync filter applies to the subscriptions tab - setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips + setFiltersResetToken((n) => n + 1); // also clear the name search + tag chips setPage("channels"); }, [setFilters, setView, setPage]); diff --git a/frontend/src/modules/feed/Feed.tsx b/frontend/src/modules/feed/Feed.tsx index db2ea7a9..baf69e7a 100644 --- a/frontend/src/modules/feed/Feed.tsx +++ b/frontend/src/modules/feed/Feed.tsx @@ -40,6 +40,8 @@ import { } from "../../features/video/videoWrites"; import { notify } from "../../lib/notifications"; import { useMe } from "../../lib/useMe"; +import { YT_COUNTS } from "../../lib/ytSearchUrlState"; +import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import { useDebounced } from "../../lib/useDebounced"; import { markedRows, @@ -148,9 +150,6 @@ const withoutWrite = (o: Overrides, id: string, seq: number): Overrides api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)), initialPageParam: null as string | null, getNextPageParam: (last) => last.next_cursor ?? undefined, - enabled: ytActive, + enabled: ytRuns, staleTime: 5 * 60_000, retry: false, }); - // Remember (in history.state) that this live search was served by the zero-quota scrape source, - // so a reload restores the results instead of dropping to the feed — re-fetching scrape pages - // costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units). - useEffect(() => { - if (!ytActive) return; - const st = window.history.state; - if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry - if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) { - window.history.replaceState({ ...st, _ytScrape: true }, ""); - } - }, [ytActive, ytSearch, ytQuery.data]); - // Switching to the relevance sort when a search starts happens atomically in the header's // input onChange (race-free with the query update). Here we only handle the reverse: when // the term is cleared, fall back to the default sort — relevance has no dropdown option and @@ -556,7 +552,8 @@ export default function Feed({ const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? ""); // The backend reports which source served the search; scrape spends no search quota, so we // drop the quota warning + "uses quota" wording in that mode. - const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape"; + // Before an answer (a search waiting for its press), the instance's own source says it. + const zeroQuota = (ytQuery.data?.pages?.[0]?.source ?? me.search_source) === "scrape"; return (
@@ -638,7 +635,16 @@ export default function Feed({ )}
- {ytQuery.isLoading ? ( + {!ytRuns && ytQuery.data === undefined ? ( + // Reopened, not asked for: the term is on screen, the search waits for a press. +
+

{t("feed.yt.notRunTitle")}

+

{t("feed.yt.notRunBody")}

+ +
+ ) : ytQuery.isLoading ? (
{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 diff --git a/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx b/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx new file mode 100644 index 00000000..676a13d0 --- /dev/null +++ b/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx @@ -0,0 +1,218 @@ +// @vitest-environment jsdom +// +// 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. +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"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("../../features/wizard/wizardContext", () => ({ + useWizard: () => ({ openWizard: vi.fn() }), +})); +vi.mock("./VirtualFeed", () => ({ + default: ({ items }: { items: { id: string; title: string }[] }) => ( +
    + {items.map((v) => ( +
  1. {v.title}
  2. + ))} +
+ ), +})); + +import Feed from "./Feed"; +import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api"; +import { qk } from "../../lib/queryKeys"; +import { + NavigationActionsContext, + NavigationStateContext, + type NavigationActions, +} from "../../features/navigation/navigationContext"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const FILTERS: FeedFilters = { + tags: [], + tagModes: DEFAULT_TAG_MODES, + channelIds: [], + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", +}; + +const hit = (id: string): Video => + ({ + id, + title: `hit ${id}`, + channel_id: "UCx", + channel_title: "Channel", + status: "new", + position_seconds: 0, + published_at: "2026-01-01T00:00:00Z", + watch_url: `https://www.youtube.com/watch?v=${id}`, + }) as unknown as Video; + +const actions = (over: Partial = {}): NavigationActions => ({ + setPage: () => {}, + openChannel: () => {}, + closeChannel: () => {}, + enterYtSearch: () => {}, + exitYtSearch: () => {}, + askYtSearch: () => {}, + setYtCount: () => {}, + ...over, +}); + +function mount({ + source, + asked, + enabled = true, + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }), + nav = actions(), +}: { + source: "scrape" | "api"; + asked: boolean; + enabled?: boolean; + qc?: QueryClient; + nav?: NavigationActions; +}) { + qc.setQueryData(qk.me(), { + id: 1, + can_write: true, + prefs: {}, + search_source: source, + search_enabled: enabled, + }); + const tree = (count: number) => ( + + + + {}} + view="cards" + setView={() => {}} + canRead + ytSearch="lofi" + onYtSearch={() => {}} + onExitYtSearch={() => {}} + /> + + + + ); + const view = render(tree(20)); + return { showing: (count: number) => view.rerender(tree(count)) }; +} + +const settle = () => new Promise((r) => setTimeout(r, 30)); + +describe("Feed — a live search the address reopened", () => { + it("waits for a press under the API source, and spends nothing meanwhile", async () => { + const search = vi.spyOn(api, "searchYoutube"); + const askYtSearch = vi.fn(); + mount({ source: "api", asked: false, nav: actions({ askYtSearch }) }); + expect(screen.getByText("feed.yt.notRunTitle")).toBeTruthy(); + expect(screen.getByText("feed.yt.resultsFor")).toBeTruthy(); // the term stays on screen + expect(screen.getByText("feed.yt.quotaNote")).toBeTruthy(); + await settle(); + expect(search).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "feed.yt.runAgain" })); + expect(askYtSearch).toHaveBeenCalledTimes(1); + }); + + it("runs an API search asked for in this document", async () => { + const search = vi + .spyOn(api, "searchYoutube") + .mockResolvedValue({ items: [hit("a")], next_cursor: null, source: "api" } as never); + mount({ source: "api", asked: true }); + await waitFor(() => + expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit a"), + ); + expect(search).toHaveBeenCalledWith("lofi", null, 20); + expect(screen.queryByText("feed.yt.notRunTitle")).toBeNull(); + }); + + it("runs by itself under the free scrape source", async () => { + const search = vi + .spyOn(api, "searchYoutube") + .mockResolvedValue({ items: [hit("a")], next_cursor: null, source: "scrape" } as never); + mount({ source: "scrape", asked: false }); + await waitFor(() => + expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit a"), + ); + expect(search).toHaveBeenCalledTimes(1); + expect(screen.getByText("feed.yt.freeNote")).toBeTruthy(); + }); + + it("shows an API search's results still cached, without asking again", async () => { + const search = vi.spyOn(api, "searchYoutube"); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(qk.ytSearch("lofi", 20), { + pages: [{ items: [hit("c")], next_cursor: null, source: "api" }], + pageParams: [null], + }); + mount({ source: "api", asked: false, qc }); + expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit c"); + expect(screen.queryByText("feed.yt.notRunTitle")).toBeNull(); + await settle(); + expect(search).not.toHaveBeenCalled(); + }); + + it("asks again for another count — each count is a search of its own", async () => { + const search = vi + .spyOn(api, "searchYoutube") + .mockResolvedValue({ items: [hit("a")], next_cursor: null, source: "scrape" } as never); + const { showing } = mount({ source: "scrape", asked: false }); + await waitFor(() => expect(search).toHaveBeenCalledWith("lofi", null, 20)); + showing(40); + await waitFor(() => expect(search).toHaveBeenCalledWith("lofi", null, 40)); + }); + + it("says the instance's own source while the first answer is on its way", () => { + vi.spyOn(api, "searchYoutube").mockReturnValue(new Promise(() => {})); + mount({ source: "scrape", asked: false }); + expect(screen.getByText("feed.yt.searching")).toBeTruthy(); + expect(screen.getByText("feed.yt.freeNote")).toBeTruthy(); + }); + + it("turned off on this instance, lets the server's refusal say so — no press offered", async () => { + vi.spyOn(api, "searchYoutube").mockRejectedValue( + new HttpError(409, "Live search is off", { code: "refused", reason: "search_disabled" }), + ); + mount({ source: "api", asked: false, enabled: false }); + expect(await screen.findByText("Live search is off")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "feed.yt.runAgain" })).toBeNull(); + }); +}); diff --git a/frontend/src/modules/feed/module.ts b/frontend/src/modules/feed/module.ts index 059e9dc2..ff1fb34c 100644 --- a/frontend/src/modules/feed/module.ts +++ b/frontend/src/modules/feed/module.ts @@ -2,6 +2,7 @@ import { YouTubeIcon } from "../../lib/brandIcons"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; import { FEED_KEYS } from "../../lib/urlState"; +import { YT_SEARCH_KEYS } from "../../lib/ytSearchUrlState"; import { FeedStore } from "./FeedStore"; // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. @@ -16,7 +17,8 @@ export const feedModule: ModuleDef = { Rail, Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail), Store: FeedStore, - urlKeys: FEED_KEYS, + // Its filters, and the live YouTube search shown in its place (lib/ytSearchUrlState). + urlKeys: [...FEED_KEYS, ...YT_SEARCH_KEYS], playsVideos: true, }; diff --git a/frontend/src/modules/home/Home.dom.test.tsx b/frontend/src/modules/home/Home.dom.test.tsx index 7dc1eb56..cfb8c0e9 100644 --- a/frontend/src/modules/home/Home.dom.test.tsx +++ b/frontend/src/modules/home/Home.dom.test.tsx @@ -174,8 +174,9 @@ function mountWithClient() { } beforeEach(() => { - // Home keeps its question on the history entry (Back restores it): each test starts a fresh one. - window.history.replaceState(null, ""); + // Home keeps its question on the history entry (Back restores it) and its budget in the address: + // each test starts a fresh one of both. + window.history.replaceState(null, "", "/"); discover.mockReset(); discoverPick.mockReset(); setPage.mockReset(); @@ -636,6 +637,37 @@ describe("Home", () => { await waitFor(() => expect(discoverPick.mock.lastCall).toEqual(asked)); }); + it("names the chosen budget in the address, and the default by leaving it out", async () => { + mount(); + await screen.findByText("Hero v1"); + fireEvent.click(screen.getByText("home.budget.long")); + await waitFor(() => expect(window.location.search).toBe("?hb=long")); + fireEvent.click(screen.getByText("home.budget.any")); + await waitFor(() => expect(window.location.search).toBe("")); + }); + + it("opens on the budget a link names — the question's draw is not the link's", async () => { + window.history.replaceState( + { sfHome: { budget: "short", seed: 7, press: 1, seen: ["youtube:v1"] } }, + "", + "/?page=home&hb=long", + ); + mount(); + await waitFor(() => expect(discover).toHaveBeenLastCalledWith("long", 0, "en")); + expect(discoverPick).not.toHaveBeenCalled(); // a question left under another budget is not reused + }); + + it("an address naming no budget opens on the default, over a question left under another", async () => { + window.history.replaceState( + { sfHome: { budget: "short", seed: 7, press: 1, seen: [] } }, + "", + "/?page=home", + ); + mount(); + await waitFor(() => expect(discover).toHaveBeenLastCalledWith("any", 0, "en")); + expect(discoverPick).not.toHaveBeenCalled(); + }); + // Measured in the browser (2026-09-07), before the fix: the palette's "Meglepetés" arrived at Home // as `start=2` — two picks deep before the page had painted once — because StrictMode invokes a // mount effect twice with the same render snapshot, and a reroll is not idempotent. The token is diff --git a/frontend/src/modules/home/Home.tsx b/frontend/src/modules/home/Home.tsx index 4549ddf4..42bfb30c 100644 --- a/frontend/src/modules/home/Home.tsx +++ b/frontend/src/modules/home/Home.tsx @@ -79,6 +79,8 @@ import { } from "../../components/ui/mediaCardStyles"; import RemoteImage from "../../components/ui/RemoteImage"; import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer"; +import { budgetFrom, budgetParams, DEFAULT_BUDGET } from "../../lib/homeUrlState"; +import { useUrlMirror } from "../../lib/useUrlMirror"; // The Home surface — "Mit nézzek most?". // @@ -701,6 +703,15 @@ function readSavedQuestion(): SavedQuestion | null { }; } +/** The question the page opens with: the budget its address names (a link wins, and an address + * naming none means the default), with the rest of what the entry was left with only when that was + * asked under the same budget. */ +function openingQuestion(): { budget: DiscoverBudget; saved: SavedQuestion | null } { + const budget = budgetFrom(new URLSearchParams(window.location.search)) ?? DEFAULT_BUDGET; + const saved = readSavedQuestion(); + return { budget, saved: saved?.budget === budget ? saved : null }; +} + export default function Home() { const { t, i18n } = useTranslation(); const qc = useQueryClient(); @@ -710,8 +721,10 @@ export default function Home() { // The question on screen lives on this page's history ENTRY as well: opening a card leaves Home // (it unmounts), and Back used to come back to "Doesn't matter" and a different hero — whose card // then left the Continue rail, so the keyboard landed on its neighbour (prod UAT 2026-09-19). - const [saved] = useState(readSavedQuestion); - const [budget, setBudget] = useState(saved?.budget ?? "any"); + // The budget alone is also in the address (lib/homeUrlState): a setting, unlike the draw under it. + const [{ budget: openedBudget, saved }] = useState(openingQuestion); + const [budget, setBudget] = useState(openedBudget); + useUrlMirror(budgetParams(budget)); // 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so // pressing the button does not refetch the rails under the user's cursor. const [seed, setSeed] = useState(saved?.seed ?? 0); diff --git a/frontend/src/modules/home/module.ts b/frontend/src/modules/home/module.ts index 384aab70..2bcc583f 100644 --- a/frontend/src/modules/home/module.ts +++ b/frontend/src/modules/home/module.ts @@ -1,6 +1,7 @@ import { Home } from "lucide-react"; import { lazyWithPreload } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { HOME_KEYS } from "../../lib/homeUrlState"; // This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily. export const homeModule: ModuleDef = { @@ -11,8 +12,8 @@ export const homeModule: ModuleDef = { icon: Home, Page: lazyWithPreload(() => import("./Home")), maxWidth: "80rem", - // Nothing of its own: the question is an answer, not a setting — the same question draws other - // cards once what you have watched moves on. - urlKeys: [], + // Its time budget, a setting. Not the question's draw: the same question draws other cards once + // what you have watched moves on (lib/homeUrlState). + urlKeys: HOME_KEYS, playsVideos: true, }; diff --git a/frontend/src/modules/scheduler/Scheduler.dom.test.tsx b/frontend/src/modules/scheduler/Scheduler.dom.test.tsx index 6c668dce..7244bb55 100644 --- a/frontend/src/modules/scheduler/Scheduler.dom.test.tsx +++ b/frontend/src/modules/scheduler/Scheduler.dom.test.tsx @@ -380,6 +380,8 @@ describe("a row's history", () => { closeChannel: () => {}, enterYtSearch: () => {}, exitYtSearch: () => {}, + askYtSearch: () => {}, + setYtCount: () => {}, }} >