feat(nav): the live YouTube search and Home's budget are named in the address
A YouTube search is `?yt=<term>` (+ `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.
This commit is contained in:
@@ -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<void>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(() => {
|
||||
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<string | null>(() =>
|
||||
page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null,
|
||||
);
|
||||
const [ytCount, setYtCountState] = useState(() =>
|
||||
ytCountFrom(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
const [ytAsked, setYtAsked] = useState<string | null>(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=<id>, 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<NavigationState>(
|
||||
() => ({ page, channelView, ytSearch }),
|
||||
[page, channelView, ytSearch],
|
||||
() => ({
|
||||
page,
|
||||
channelView,
|
||||
ytSearch,
|
||||
ytCount,
|
||||
ytSearchAsked: ytSearch != null && ytAsked === ytSearch,
|
||||
}),
|
||||
[page, channelView, ytSearch, ytCount, ytAsked],
|
||||
);
|
||||
const actions = useMemo<NavigationActions>(
|
||||
() => ({ 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 (
|
||||
|
||||
@@ -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<typeof import("react-i18next")>();
|
||||
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 }) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<NavigationProvider>{children}</NavigationProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<NavigationState>({
|
||||
page: "feed",
|
||||
channelView: null,
|
||||
ytSearch: null,
|
||||
ytCount: DEFAULT_YT_COUNT,
|
||||
ytSearchAsked: false,
|
||||
});
|
||||
export const NavigationActionsContext = createContext<NavigationActions>({
|
||||
setPage: () => {},
|
||||
@@ -46,6 +57,8 @@ export const NavigationActionsContext = createContext<NavigationActions>({
|
||||
closeChannel: () => {},
|
||||
enterYtSearch: () => {},
|
||||
exitYtSearch: () => {},
|
||||
askYtSearch: () => {},
|
||||
setYtCount: () => {},
|
||||
});
|
||||
|
||||
export function useNavigation(): NavigationState {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string | null> {
|
||||
return { [HOME_BUDGET_KEY]: budget === DEFAULT_BUDGET ? null : budget };
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | null> {
|
||||
return {
|
||||
[YT_SEARCH_KEY]: term,
|
||||
[YT_COUNT_KEY]: term != null && count !== DEFAULT_YT_COUNT ? String(count) : null,
|
||||
};
|
||||
}
|
||||
@@ -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<Record<Page, () => 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: () => [
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T
|
||||
return next;
|
||||
};
|
||||
|
||||
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
|
||||
const YT_COUNTS = [20, 40, 60, 100] as const;
|
||||
|
||||
// The live-search refusals a retry cannot answer: the day's cap, turned off on this instance, the
|
||||
// shared budget spent (ours, or YouTube's own) — tomorrow or the admin does.
|
||||
const SEARCH_FINAL_REASONS = new Set([
|
||||
@@ -221,7 +220,8 @@ export default function Feed({
|
||||
const qc = useQueryClient();
|
||||
const videoMarks = useVideoMarks();
|
||||
// Live search turned off on this instance: the empty feed offers no search that would be refused.
|
||||
const searchOn = useMe().search_enabled;
|
||||
const me = useMe();
|
||||
const searchOn = me.search_enabled;
|
||||
|
||||
// What changed state while the player was open, and the writes still in flight: closing it
|
||||
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
|
||||
@@ -263,8 +263,16 @@ export default function Feed({
|
||||
|
||||
const ytActive = !!ytSearch;
|
||||
// How many live-search results to gather (the count selector replaces a manual "load more";
|
||||
// the free scrape source pages through continuations until this many are collected).
|
||||
const [ytCount, setYtCount] = useState(20);
|
||||
// the free scrape source pages through continuations until this many are collected). Named in the
|
||||
// address with the term, so it is the navigation's, like the term.
|
||||
const { ytCount, ytSearchAsked } = useNavigation();
|
||||
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
|
||||
// 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);
|
||||
// How to order the loaded results. Its OWN pref, not `filters.sort`: sorting a live-search page
|
||||
// must not silently re-sort the library you go back to. Default `relevance` — the provider's
|
||||
// ranking, which is what the view always showed before it had a control.
|
||||
@@ -298,23 +306,11 @@ export default function Feed({
|
||||
stampFetch(() => 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 (
|
||||
<div className="p-4">
|
||||
@@ -638,7 +635,16 @@ export default function Feed({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ytQuery.isLoading ? (
|
||||
{!ytRuns && ytQuery.data === undefined ? (
|
||||
// Reopened, not asked for: the term is on screen, the search waits for a press.
|
||||
<div className="py-16 text-center max-w-md mx-auto">
|
||||
<p className="font-semibold">{t("feed.yt.notRunTitle")}</p>
|
||||
<p className="mt-1 text-sm text-muted">{t("feed.yt.notRunBody")}</p>
|
||||
<Button className="mt-4" icon={<YouTubeIcon />} onClick={askYtSearch}>
|
||||
{t("feed.yt.runAgain")}
|
||||
</Button>
|
||||
</div>
|
||||
) : ytQuery.isLoading ? (
|
||||
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
|
||||
) : ytError && !ytItems.length ? (
|
||||
// A first page that failed is said as the error it is, with Retry where trying again can
|
||||
|
||||
@@ -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<typeof import("react-i18next")>()),
|
||||
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 }[] }) => (
|
||||
<ol aria-label="results">
|
||||
{items.map((v) => (
|
||||
<li key={v.id}>{v.title}</li>
|
||||
))}
|
||||
</ol>
|
||||
),
|
||||
}));
|
||||
|
||||
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> = {}): 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) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<NavigationActionsContext.Provider value={nav}>
|
||||
<NavigationStateContext.Provider
|
||||
value={{
|
||||
page: "feed",
|
||||
channelView: null,
|
||||
ytSearch: "lofi",
|
||||
ytCount: count,
|
||||
ytSearchAsked: asked,
|
||||
}}
|
||||
>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch="lofi"
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</NavigationStateContext.Provider>
|
||||
</NavigationActionsContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<DiscoverBudget>(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<DiscoverBudget>(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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -380,6 +380,8 @@ describe("a row's history", () => {
|
||||
closeChannel: () => {},
|
||||
enterYtSearch: () => {},
|
||||
exitYtSearch: () => {},
|
||||
askYtSearch: () => {},
|
||||
setYtCount: () => {},
|
||||
}}
|
||||
>
|
||||
<QueryClientProvider client={qc}>
|
||||
|
||||
Reference in New Issue
Block a user