fix(nav): the address follows the view through overlays, reloads and Back (review round 1)

- A URL mirror names the view again on every entry Back or Forward lands on, so a change made
  under a dialog, a drawer or a sub-view is not undone by the next reload.
- A view pushed from the entry a reload under a dialog started from no longer inherits its overlay
  mark; a reload under two overlays closes onto the page in one step.
- The linked player reopens over a live search, gives itself up when its list moves first, and
  plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve.
- The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and
  Playlists searches end with their page; the feed always names its view and debounces its search.
- Search-box keys are declared per module and kept out of the client log below TRACE.
- Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and
  two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the
  Files sort and viewer added).
This commit is contained in:
2026-10-27 13:52:42 +01:00
parent 7b4e007680
commit 0d5c6273f1
50 changed files with 928 additions and 157 deletions
+9 -6
View File
@@ -49,11 +49,13 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
address-bar key its view writes (the URL is the view: a bookmark reopens it), its list kept in `lib/` beside 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. 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 Every setting the page shows — a tab, a sort, a filter, a search, the item open in it — goes there: a codec
in `lib/<id>UrlState.ts` (read the params into the view, write the view as updates, a default left out), in `lib/<id>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 or always written, when an address naming none falls back to a view remembered elsewhere), written with
`useBackToClose`'s), read back first on mount (a link wins over anything remembered). Add its round trip to `useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through `useBackToClose`'s),
`e2e/bookmark.spec.ts`. Only what an address cannot promise to reopen stays out, said in its codec (Home's always as the codec's result: a writer outside `lib/` computes no key of its own. Read back first on mount
draw, `lib/homeUrlState`). (a link wins over anything remembered). A search box's key is also in `typedUrlKeys` (the client log keeps
what was typed out). Add its round trip to `e2e/bookmark.spec.ts`. Only what an address cannot promise to
reopen stays out, said in its codec (Home's draw, `lib/homeUrlState`).
3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's 3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's
hooks). Another folder reaches the module through this file or not at all. hooks). Another folder reaches the module through this file or not at all.
4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in 4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in
@@ -67,7 +69,8 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily), What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily),
`moduleStores.contract.test.tsx` (no store reads another module), `moduleUrl.contract.test.ts` (each URL key `moduleStores.contract.test.tsx` (no store reads another module), `moduleUrl.contract.test.ts` (each URL key
belongs to one module and matches what its codec writes; no undeclared key literal), the `local/layers` lint rule (imports only go belongs to one module and matches what its codec writes; a writer outside `lib/` computes no key; no key literal
written or read from the live query undeclared; every page with keys in the bookmark net), the `local/layers` lint rule (imports only go
down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the
i18n parity test. i18n parity test.
+96 -5
View File
@@ -10,8 +10,10 @@ import {
// The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW // The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW
// browser context, so nothing this tab remembered (its history entries, sessionStorage, the view // browser context, so nothing this tab remembered (its history entries, sessionStorage, the view
// storage it wrote) can stand in for what the address says. One round trip per page, plus the // storage it wrote) can stand in for what the address says. One round trip per page that names its
// overlays the shell opens over any page (the channel page, the player) and the live search. // view (moduleUrl.contract.test holds the list to the module definitions), plus the overlays the
// shell opens over any page (the channel page, the player) and the live search. Not Plex: what it
// shows is the instance's own library, which a test stack may not have.
const STORAGE_STATE = "e2e/.auth/state.json"; const STORAGE_STATE = "e2e/.auth/state.json";
@@ -26,6 +28,13 @@ async function bookmarked(browser: Browser, page: Page, prepare?: (p: Page) => P
} }
const tab = (p: Page, name: string) => p.getByRole("tab", { name, exact: true }); const tab = (p: Page, name: string) => p.getByRole("tab", { name, exact: true });
const column = (p: Page, name: string) =>
p.getByRole("columnheader", { name: new RegExp(`^${name}`) });
async function gotoAdminPage(page: Page, id: string): Promise<void> {
await page.getByTestId("nav-admin").click();
await page.getByTestId(`nav-admin-${id}`).click();
}
test.describe("a bookmark reopens the view it was taken of", () => { test.describe("a bookmark reopens the view it was taken of", () => {
test("the feed's filters", async ({ page, browser }) => { test("the feed's filters", async ({ page, browser }) => {
@@ -60,14 +69,58 @@ test.describe("a bookmark reopens the view it was taken of", () => {
test("an admin page's tab", async ({ page, browser }) => { test("an admin page's tab", async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
await page.getByTestId("nav-admin").click(); await gotoAdminPage(page, "users");
await page.getByTestId("nav-admin-users").click();
await tab(page, "Access requests").click(); await tab(page, "Access requests").click();
const fresh = await bookmarked(browser, page); const fresh = await bookmarked(browser, page);
await expect(tab(fresh, "Access requests")).toHaveAttribute("aria-selected", "true"); await expect(tab(fresh, "Access requests")).toHaveAttribute("aria-selected", "true");
await fresh.context().close(); await fresh.context().close();
}); });
test("a Configuration group", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoAdminPage(page, "config");
const second = page.getByRole("tab").nth(1);
const name = (await second.textContent()) ?? "";
await second.click();
await expect(page).toHaveURL(/[?&]cfgtab=/);
const fresh = await bookmarked(browser, page);
await expect(fresh.getByRole("tab", { name, exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await fresh.context().close();
});
test("the Activity tab and its time window", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoAdminPage(page, "activity");
await tab(page, "Server log").click();
await page.getByRole("button", { name: "30 days", exact: true }).click();
await expect(page).toHaveURL(/[?&]aw=30d/);
const fresh = await bookmarked(browser, page);
await expect(tab(fresh, "Server log")).toHaveAttribute("aria-selected", "true");
await expect(fresh.getByRole("button", { name: "30 days", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await fresh.context().close();
});
test("a playlist and its sort", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoPage(page, "playlists");
await expect(column(page, "Title")).toBeVisible();
await column(page, "Title").getByRole("button").first().click();
await expect(page).toHaveURL(/[?&]plsort=/);
const sorted = await column(page, "Title").getAttribute("aria-sort");
const fresh = await bookmarked(browser, page);
await expect(column(fresh, "Title")).toHaveAttribute("aria-sort", sorted ?? "");
expect(new URL(fresh.url()).searchParams.get("pl")).toBe(
new URL(page.url()).searchParams.get("pl"),
);
await fresh.context().close();
});
test("the Channels manager's tab and name search", async ({ page, browser }) => { test("the Channels manager's tab and name search", async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
await gotoPage(page, "channels"); await gotoPage(page, "channels");
@@ -90,6 +143,38 @@ test.describe("a bookmark reopens the view it was taken of", () => {
await fresh.context().close(); await fresh.context().close();
}); });
// A file of the test's own, uploaded for it and deleted after: the account's library is left as
// it was found.
test("the Files sort, and the file open in the viewer", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
const NAME = "bookmark-note";
// One a run cut short left behind goes first.
const left = (await (await page.request.get("/api/files")).json()) as Record<string, unknown>[];
for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`)))
await page.request.delete(`/api/files/${String(f.id)}`);
const up = await page.request.post("/api/files/upload", {
headers: { "X-Upload-Filename": `${NAME}.txt` },
data: Buffer.from("a note the bookmark reopens\n"),
});
expect(up.ok(), await up.text()).toBeTruthy();
const { id } = (await up.json()) as { id: number };
try {
await gotoPage(page, "files");
await column(page, "Name").getByRole("button").first().click();
await expect(page).toHaveURL(/[?&]fsort=name/);
const sorted = await column(page, "Name").getAttribute("aria-sort");
await page.getByText(NAME, { exact: true }).first().click();
await expect(page).toHaveURL(new RegExp(`[?&]fview=${id}`));
const fresh = await bookmarked(browser, page);
await expect(fresh.getByRole("dialog", { name: NAME })).toBeVisible();
await fresh.keyboard.press("Escape");
await expect(column(fresh, "Name")).toHaveAttribute("aria-sort", sorted ?? "");
await fresh.context().close();
} finally {
await page.request.delete(`/api/files/${id}`);
}
});
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => { test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
@@ -114,9 +199,15 @@ test.describe("a bookmark reopens the view it was taken of", () => {
}); });
// The search itself never reaches YouTube here: the answer is the server's shape, served by the // 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, and the request it answers is what the address named. The instance's search settings are
// pinned too (the free source, switched on): a stack left on the API source reopens the search
// waiting for its press, by design, and one with search off offers none.
test("a live YouTube search, its term and its count", async ({ page, browser }) => { test("a live YouTube search, its term and its count", async ({ page, browser }) => {
const answer = async (p: Page, seen: string[]) => { const answer = async (p: Page, seen: string[]) => {
await p.route("**/api/me", async (route) => {
const me = (await (await route.fetch()).json()) as Record<string, unknown>;
return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } });
});
await p.route("**/api/search/youtube?**", (route) => { await p.route("**/api/search/youtube?**", (route) => {
seen.push(route.request().url()); seen.push(route.request().url());
return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } }); return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } });
@@ -86,8 +86,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState) // The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState)
// so a reload or a link opens it again; its own history entry, so Back steps player → search → feed. // so a reload or a link opens it again; its own history entry, so Back steps player → search → feed.
// Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was // Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was
// asked to search for (sent from the search box, or "Search YouTube again" pressed). Anything else // asked to search for (sent from the search box, or "Search YouTube again" pressed), on the entries
// — a reload, a link, a Back onto an older search — runs only when it costs no quota (Feed decides). // it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or
// a Forward onto a search — runs only when it costs no quota (Feed decides).
const [ytSearch, setYtSearch] = useState<string | null>(() => const [ytSearch, setYtSearch] = useState<string | null>(() =>
page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null, page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null,
); );
@@ -96,10 +97,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
); );
const [ytAsked, setYtAsked] = useState<string | null>(null); const [ytAsked, setYtAsked] = useState<string | null>(null);
const ytSearchRef = useLatest(ytSearch); const ytSearchRef = useLatest(ytSearch);
const ytCountRef = useLatest(ytCount);
// The search ends: off the screen, and no longer asked for.
const endYtSearch = useCallback(() => {
setYtSearch(null);
setYtAsked(null);
}, []);
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the // 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 // 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 // it, and a bookmarked ?chan link opens it. Reload of an in-app entry reads history.state._chan; a
// entry reads history.state._chan; a fresh bookmark has only the ?chan param. // fresh bookmark has only the ?chan param.
const [channelView, setChannelView] = useState<ChannelView | null>(() => const [channelView, setChannelView] = useState<ChannelView | null>(() =>
channelViewFrom(window.history.state, window.location.search), channelViewFrom(window.history.state, window.location.search),
); );
@@ -134,6 +141,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
return; return;
} }
setChannelView(null); // leave any open channel page when navigating via the rail setChannelView(null); // leave any open channel page when navigating via the rail
// A live search under that channel ends too: the page opens at its root, which the address
// below names — the Feed press over a channel opened from a search included.
endYtSearch();
setPageState(next); setPageState(next);
setAccountRaw(PAGE_KEY, next); setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
@@ -151,7 +161,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
notifyNav(); notifyNav();
then?.(); then?.();
}), }),
[pageRef, channelViewRef], [pageRef, channelViewRef, endYtSearch],
); );
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands // A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
@@ -179,11 +189,20 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document // No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the // below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
// entry's channel marks and the ?chan param. // entry's channel marks and the ?chan param.
// A live search under it (the channel opened from its results) is on screen again, and named
// again: the channel's address carried none of it.
setChannelView(null); setChannelView(null);
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {}; const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
window.history.replaceState(st, "", urlWithParams(closedChannelParams())); window.history.replaceState(
st,
"",
urlWithParams({
...closedChannelParams(),
...ytSearchParams(ytSearchRef.current, ytCountRef.current),
}),
);
} }
}, []); }, [ytSearchRef, ytCountRef]);
const enterYtSearch = useCallback((q: string) => { const enterYtSearch = useCallback((q: string) => {
setYtSearch(q); setYtSearch(q);
@@ -230,11 +249,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument). // 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(); if (window.history.state?._yt && backStaysInDocument()) window.history.back();
else { else {
setYtSearch(null); endYtSearch();
const { _yt: _q, ...st } = window.history.state ?? {}; const { _yt: _q, ...st } = window.history.state ?? {};
window.history.replaceState(st, "", urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT))); window.history.replaceState(st, "", urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT)));
} }
}, []); }, [endYtSearch]);
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted // Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
// DURING the render that changes the page, against the page we were last on: as an effect it ran // DURING the render that changes the page, against the page we were last on: as an effect it ran
@@ -244,7 +263,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const [pageSearchWasOn, setPageSearchWasOn] = useState(page); const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
if (pageSearchWasOn !== page) { if (pageSearchWasOn !== page) {
setPageSearchWasOn(page); setPageSearchWasOn(page);
if (page !== "feed") setYtSearch(null); if (page !== "feed") {
setYtSearch(null);
setYtAsked(null);
}
} }
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"): // The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
@@ -286,8 +308,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// page survives F5. // page survives F5.
const st = window.history.state || {}; const st = window.history.state || {};
// Mirror the resolved initial page to the address bar (the feed too) so a page reached from the // 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 // stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams keeps the
// ?chan / ?v / ?pl / filter params the loaders already read. // shell's keys (?chan, a player's) and the page's own; other pages' go (below).
// A retired page's link — or the remembered last page, when that is what opened — also opens its // A retired page's link — or the remembered last page, when that is what opened — also opens its
// part of the page that replaced it (an `at` tab). // part of the page that replaced it (an `at` tab).
const linked = new URLSearchParams(window.location.search).get("page"); const linked = new URLSearchParams(window.location.search).get("page");
@@ -325,9 +347,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
setPageState(p); setPageState(p);
setAccountRaw(PAGE_KEY, p); setAccountRaw(PAGE_KEY, p);
// The YouTube search is read from the address the browser restored, so Back/Forward opens or // The YouTube search is read from the address the browser restored, so Back/Forward opens or
// clears it to match the entry we landed on — not asked for: an API search waits for a press. // clears it to match the entry we landed on. One landed on without it has ended: a Forward back
// onto it is not asked for, and an API search waits for a press.
const here = new URLSearchParams(window.location.search); const here = new URLSearchParams(window.location.search);
setYtSearch(p === "feed" ? ytSearchFrom(here) : null); const landed = p === "feed" ? ytSearchFrom(here) : null;
setYtSearch(landed);
if (landed == null) setYtAsked(null);
setYtCountState(ytCountFrom(here)); setYtCountState(ytCountFrom(here));
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark // 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 // entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
@@ -188,6 +188,57 @@ describe("NavigationProvider · the YouTube search in the address", () => {
expect(search().get("ytn")).toBeNull(); expect(search().get("ytn")).toBeNull();
}); });
it("a search left by Back and reached again by Forward is not asked for", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
expect(result.current.ytSearchAsked).toBe(true);
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
expect(result.current.ytSearch).toBe("lofi");
expect(result.current.ytSearchAsked).toBe(false);
});
it("a search left in place is not asked for when it is opened again by a link", () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi");
const { result } = render();
act(() => result.current.askYtSearch());
stubNoEntryBelow();
act(() => result.current.exitYtSearch());
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
expect(result.current.ytSearchAsked).toBe(false);
});
it("the Feed press over a channel opened from the search opens the plain feed", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
act(() => result.current.openChannel("UC1"));
act(() => result.current.setPage("feed"));
expect(result.current.channelView).toBeNull();
expect(result.current.ytSearch).toBeNull();
expect(search().get("yt")).toBeNull();
});
it("a channel closed in place over the search names the search again", () => {
const { result } = render();
act(() => result.current.enterYtSearch("lofi"));
act(() => result.current.setYtCount(40));
act(() => result.current.openChannel("UC1"));
expect(search().get("yt")).toBeNull();
stubNoEntryBelow();
act(() => result.current.closeChannel());
expect(result.current.ytSearch).toBe("lofi");
expect([search().get("yt"), search().get("ytn")]).toEqual(["lofi", "40"]);
});
it("a page switch takes the search off the address", () => { it("a page switch takes the search off the address", () => {
const { result } = render(); const { result } = render();
act(() => result.current.enterYtSearch("lofi")); act(() => result.current.enterYtSearch("lofi"));
@@ -55,6 +55,10 @@ const ALL_URL_KEYS: readonly string[] = ALL.flatMap((m) => m.urlKeys);
export function moduleUrlKeys(): readonly string[] { export function moduleUrlKeys(): readonly string[] {
return ALL_URL_KEYS; return ALL_URL_KEYS;
} }
/** Every module's address-bar keys that carry typed text — what the client log keeps out (main.tsx). */
export function typedUrlKeys(): readonly string[] {
return ALL.flatMap((m) => m.typedUrlKeys ?? []);
}
export function foreignUrlKeys(page: Page): readonly string[] { export function foreignUrlKeys(page: Page): readonly string[] {
return ALL.filter((m) => m.id !== page).flatMap((m) => m.urlKeys); return ALL.filter((m) => m.id !== page).flatMap((m) => m.urlKeys);
} }
@@ -4,43 +4,58 @@ import { useQuery } from "@tanstack/react-query";
import { api, type Video } from "../../lib/api"; import { api, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState"; import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
import { dropFromAddress } from "../../lib/urlState"; import { clearParams } from "../../lib/urlState";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that /** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the * hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
* item is in it (`find`), so the player plays on through the list; else alone, its row fetched from * item is in it (`find`), so the player plays on through the list; else alone, its row fetched from
* the catalog. A row the catalog no longer has is said, not swallowed. */ * the catalog. A row the catalog no longer has is said, not swallowed.
*
* `scope` names the list the address put it over (a playlist, a live search): when the host moves to
* another before it opened, the address under it named something else, and the player is given up —
* opened later, it would start over a list nobody linked, unasked. */
export function useOpenLinkedPlayer( export function useOpenLinkedPlayer(
ready: boolean, ready: boolean,
find: (id: string) => Video | undefined, find: (id: string) => Video | undefined,
open: (video: Video, inList: boolean) => void, open: (video: Video, inList: boolean) => void,
scope: string | number | null = null,
): void { ): void {
const { t } = useTranslation(); const { t } = useTranslation();
const [linked] = useState(() => linkedPlayer()?.watch ?? null); const [linked] = useState(() => linkedPlayer()?.watch ?? null);
const [linkedScope] = useState(scope);
// Opened (or said to be gone) once; a ref, as nothing on screen reads it. // Opened (or said to be gone) once; a ref, as nothing on screen reads it.
const done = useRef(linked == null); const done = useRef(linked == null);
const listed = ready && linked != null ? find(linked) : undefined; const moved = scope !== linkedScope;
const listed = ready && !moved && linked != null ? find(linked) : undefined;
const row = useQuery({ const row = useQuery({
queryKey: qk.videoRow(linked ?? ""), queryKey: qk.videoRow(linked ?? ""),
queryFn: () => api.videoRow(linked!), queryFn: () => api.videoRow(linked!),
enabled: ready && linked != null && !listed, enabled: ready && !moved && linked != null && !listed,
retry: false, retry: false,
}); });
const openIt = useEffectEvent(open); const openIt = useEffectEvent(open);
const failed = useEffectEvent((err: unknown) => { const failed = useEffectEvent((err: unknown) => {
notifyActionError(err, t("player.linkedFailed")); notifyActionError(err, t("player.linkedFailed"));
dropFromAddress(PLAYER_KEYS); clearParams(...PLAYER_KEYS);
}); });
useEffect(() => { useEffect(() => {
if (done.current) return;
if (moved) {
done.current = true;
// The address goes on naming it under the new list (a reload would open it there) — unless a
// player opened since names its own.
if (linkedPlayer()?.watch === linked) clearParams(...PLAYER_KEYS);
return;
}
// Not before the host is ready: the row's cache entry is shared, so another opener's fetch fills // Not before the host is ready: the row's cache entry is shared, so another opener's fetch fills
// it here too (a disabled query still reads the cache) — the shell's player over a page that // it here too (a disabled query still reads the cache) — the shell's player over a page that
// plays its own would open a second one. // plays its own would open a second one.
if (done.current || !ready) return; if (!ready) return;
if (listed) openIt(listed, true); if (listed) openIt(listed, true);
else if (row.data) openIt(row.data, false); else if (row.data) openIt(row.data, false);
else if (row.isError) failed(row.error); else if (row.isError) failed(row.error);
else return; else return;
done.current = true; done.current = true;
}, [ready, listed, row.data, row.isError, row.error]); }, [linked, moved, ready, listed, row.data, row.isError, row.error]);
} }
+2 -2
View File
@@ -76,8 +76,8 @@
"cleared_one": "Cleared {{count}} result", "cleared_one": "Cleared {{count}} result",
"cleared_other": "Cleared {{count}} results", "cleared_other": "Cleared {{count}} results",
"showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.", "showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.",
"notRunTitle": "This search hasn't run again", "notRunTitle": "Not searched yet",
"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.", "notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload, a link or Back doesn't start one by itself.",
"runAgain": "Search YouTube again" "runAgain": "Search YouTube again"
} }
} }
+2 -2
View File
@@ -76,8 +76,8 @@
"cleared_one": "{{count}} találat törölve", "cleared_one": "{{count}} találat törölve",
"cleared_other": "{{count}} találat törölve", "cleared_other": "{{count}} találat törölve",
"showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább.", "showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább.",
"notRunTitle": "Ez a keresés nem futott újra", "notRunTitle": "Ez a keresés még nem futott le",
"notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltés vagy link nem indít el egyet magától.", "notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltéskor, linkről vagy visszalépéskor nem indul el magától.",
"runAgain": "Keresés újra a YouTube-on" "runAgain": "Keresés újra a YouTube-on"
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"linkedGone": "A címben szereplő fájl már nincs ebben a listában", "linkedGone": "A linkben szereplő fájl már nincs ebben a listában",
"navLabel": "Fájlok", "navLabel": "Fájlok",
"searchPlaceholder": "Keresés a fájlok között…", "searchPlaceholder": "Keresés a fájlok között…",
"count_one": "{{count}} elem", "count_one": "{{count}} elem",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"loading": "Betöltés…", "loading": "Betöltés…",
"linkedFailed": "A címben szereplő videó nem nyitható meg", "linkedFailed": "A linkben szereplő videó nem nyitható meg",
"backToOriginal": "Vissza az eredeti videóhoz", "backToOriginal": "Vissza az eredeti videóhoz",
"back": "Vissza", "back": "Vissza",
"previous": "Előző", "previous": "Előző",
+5
View File
@@ -10,6 +10,11 @@ import { LS, setTabRaw } from "./storage";
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
// The address-bar key the page mirrors its tab to. // The address-bar key the page mirrors its tab to.
export const USERS_TAB_URL_KEY = "utab"; export const USERS_TAB_URL_KEY = "utab";
/** The address-bar delta for the Users tab shown. */
export function usersTabParams(tab: string | null): Record<string, string | null> {
return { [USERS_TAB_URL_KEY]: tab };
}
const ADMIN_USERS_ACCESS_TAB = "access"; const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void { export function focusAccessRequestsTab(): void {
+5
View File
@@ -1,3 +1,8 @@
// The Configuration page's address-bar key: the config group its tab rail shows. Here, beside the other // The Configuration page's address-bar key: the config group its tab rail shows. Here, beside the other
// pages' keys, so the page's definition can declare it without importing the lazy page. // pages' keys, so the page's definition can declare it without importing the lazy page.
export const CONFIG_TAB_KEY = "cfgtab"; export const CONFIG_TAB_KEY = "cfgtab";
/** The address-bar delta for the config group shown (null: none named). */
export function configTabParams(tab: string | null): Record<string, string | null> {
return { [CONFIG_TAB_KEY]: tab };
}
+23 -1
View File
@@ -1,7 +1,7 @@
// The Files page's address-bar keys: the folder shown (`?folder=`, its encoding in // The Files page's address-bar keys: the folder shown (`?folder=`, its encoding in
// modules/files/filesContext), the file it turns to once (`?item=`), the name search (`?fq=`) and the // modules/files/filesContext), the file it turns to once (`?item=`), the name search (`?fq=`) and the
// sort (`?fsort=`, always written, lib/columnSort's `none` for the list's own order). Here, beside the // sort (`?fsort=`, always written, lib/columnSort's `none` for the list's own order). Here, beside the
// other pages' key lists, so the page's definition can declare them without importing its own folder. // other pages' codecs (lib/<id>UrlState), where the URL contract test reads them all.
export const FILES_FOLDER_KEY = "folder"; export const FILES_FOLDER_KEY = "folder";
export const FILES_ITEM_KEY = "item"; export const FILES_ITEM_KEY = "item";
export const FILES_SEARCH_KEY = "fq"; export const FILES_SEARCH_KEY = "fq";
@@ -21,6 +21,28 @@ export const FILES_KEYS = [
export type FilesOpenFile = { open: "view" | "play"; id: number }; export type FilesOpenFile = { open: "view" | "play"; id: number };
/** The address-bar delta for the list (the page's store): its search, folder and the file it turns to
* — each null when not named (`null`: the page is not what the address shows). */
export function filesListParams(
list: { search: string; folder: string | null; item: number | null } | null,
): Record<string, string | null> {
return {
[FILES_SEARCH_KEY]: list?.search || null,
[FILES_FOLDER_KEY]: list?.folder ?? null,
[FILES_ITEM_KEY]: list?.item != null ? String(list.item) : null,
};
}
/** The address-bar delta for the list's sort (lib/columnSort's encoding, `none` included). */
export function filesSortParams(sort: string | null): Record<string, string | null> {
return { [FILES_SORT_KEY]: sort };
}
/** What a file open over the list names in its own entry: the viewer's, or the player's. */
export function filesOpenParams(file: FilesOpenFile): Record<string, string | null> {
return { [file.open === "play" ? FILES_PLAY_KEY : FILES_VIEW_KEY]: String(file.id) };
}
/** The file an address opens over the list (a reload, a bookmark), or null. */ /** The file an address opens over the list (a reload, a bookmark), or null. */
export function openFileFrom(params: URLSearchParams): FilesOpenFile | null { export function openFileFrom(params: URLSearchParams): FilesOpenFile | null {
for (const [open, key] of [ for (const [open, key] of [
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
//
// A reload under two overlays (a dialog, or a maximised player, over the player) loads on the upper
// one's entry; only the player reopens, and takes that entry over. The lower one's entry is still
// under it, and nothing reopens there: closing the player steps back over it too, so one close
// lands on the page — not on a dead step that looks like it. Its own file: the entry is read as the
// module loads, so it is set up before the import.
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
describe("an overlay reopened on the entry a reload under two started from", () => {
it("closes onto the page under both", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
window.history.pushState({ sfPage: "feed", _ov: 1, _ovd: 1 }, "", "/?page=feed&watch=v1");
window.history.pushState({ sfPage: "feed", _ov: 2, _ovd: 2 }, "", "/?page=feed&watch=v1");
const depth = window.history.length;
const { useBackToClose } = await import("./history");
const holder: { unmount?: () => void } = {};
const r = renderHook(() => useBackToClose(() => holder.unmount?.(), { watch: "v1" }));
holder.unmount = r.unmount;
await act(async () => {
await Promise.resolve();
});
expect(window.history.length).toBe(depth);
await act(async () => {
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
r.unmount();
await landed;
await Promise.resolve();
});
expect(window.history.state?._ov).toBeUndefined();
expect(window.location.search).toBe("?page=feed");
});
it("a pushed overlay entry records how many it stands on", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
const { useBackToClose } = await import("./history");
const r = renderHook(() => useBackToClose(() => {}));
await act(async () => {
await Promise.resolve();
});
expect(window.history.state?._ovd).toBe(1);
r.unmount();
});
});
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
//
// The entry a reload under an open dialog started from carries the dialog's mark, and a dialog that
// does not reopen (About, the cheat sheet) leaves it there. A view pushed from that entry (a channel
// page, a search, a sub-view) must not inherit the mark: the first overlay over it took the view's
// entry over as its own, and closing it closed the view too. Its own file: the entry is read as the
// module loads, so it is set up before the import.
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
describe("a view pushed from the entry a reload under a dialog started from", () => {
it("is not taken over by the first overlay over it", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
window.history.pushState({ sfPage: "feed", _ov: 3 }, "", "/?page=feed");
const { rememberNavPosition, useBackToClose } = await import("./history");
// openChannel's push, as it spreads the entry it leaves.
const left = rememberNavPosition();
expect(left._ov).toBeUndefined();
window.history.pushState({ ...left, _chan: "UC1" }, "", "/?chan=UC1");
const depth = window.history.length;
const holder: { unmount?: () => void } = {};
const r = renderHook(() => useBackToClose(() => holder.unmount?.(), { watch: "v1" }));
holder.unmount = r.unmount;
await act(async () => {
await Promise.resolve();
});
expect(window.history.length).toBe(depth + 1);
await act(async () => {
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
r.unmount();
await landed;
await Promise.resolve();
});
expect(window.history.state?._chan).toBe("UC1");
expect(window.location.search).toBe("?chan=UC1");
});
});
+56 -14
View File
@@ -87,7 +87,10 @@ if (typeof window !== "undefined") {
/** Call right before a pushState that opens a new view over the current entry (a page, a sub-view, a /** Call right before a pushState that opens a new view over the current entry (a page, a sub-view, a
* channel, a tab). Returns the current state WITHOUT the stamp: a push that spreads the current state * channel, a tab). Returns the current state WITHOUT the stamp: a push that spreads the current state
* must spread this, so the new entry does not inherit a position it was never left from. */ * must spread this, so the new entry does not inherit a position it was never left from — nor an
* overlay's marks, which belong to the entry being left (an entry a reload under an open dialog
* started from carries them, and the first overlay over a view pushed from it took that view's entry
* over for its own). */
export function rememberNavPosition(): Record<string, unknown> { export function rememberNavPosition(): Record<string, unknown> {
// A restore still waiting for the last landing's lists belongs to the page being left: left // A restore still waiting for the last landing's lists belongs to the page being left: left
// running, it would focus "list N, item M" of whatever page this push opens. // running, it would focus "list N, item M" of whatever page this push opens.
@@ -95,7 +98,8 @@ export function rememberNavPosition(): Record<string, unknown> {
const { sfNav: _stale, ...st } = (window.history.state ?? {}) as Record<string, unknown>; const { sfNav: _stale, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
const pos = navPosition(); const pos = navPosition();
window.history.replaceState(pos ? { ...st, sfNav: pos } : st, ""); window.history.replaceState(pos ? { ...st, sfNav: pos } : st, "");
return st; const { _ov: _overlay, _ovd: _depth, ...view } = st;
return view;
} }
// --- Is the entry a back() lands on still ours? -------------------------------------------- // --- Is the entry a back() lands on still ours? --------------------------------------------
@@ -111,12 +115,12 @@ interface NavigationLike {
entries: () => readonly { sameDocument: boolean }[]; entries: () => readonly { sameDocument: boolean }[];
} }
/** Would one more history.back() still land in this document? */ /** Would `steps` more steps back (one, a history.back()) still land in this document? */
export function backStaysInDocument(): boolean { export function backStaysInDocument(steps = 1): boolean {
const nav = (window as { navigation?: NavigationLike }).navigation; const nav = (window as { navigation?: NavigationLike }).navigation;
const at = nav?.currentEntry?.index; const at = nav?.currentEntry?.index;
if (nav == null || at == null) return true; if (nav == null || at == null) return true;
return nav.entries()[at - pendingProgrammatic - 1]?.sameDocument === true; return nav.entries()[at - pendingSteps - steps]?.sameDocument === true;
} }
// --- Sub-views (value carried in history.state._sub) --------------------------------------- // --- Sub-views (value carried in history.state._sub) ---------------------------------------
@@ -292,10 +296,36 @@ const nextOverlayToken = () => ++overlayCounter;
let historyDepth = 0; let historyDepth = 0;
// The entry this document was loaded on, when it was an overlay's (a reload under an open player): // The entry this document was loaded on, when it was an overlay's (a reload under an open player):
// the first overlay to open takes it over rather than pushing another over it — pushed, Back would // the first overlay to open takes it over rather than pushing another over it — pushed, Back would
// close the player onto that same page again, one dead step per reload. Until the first push or Back. // close the player onto that same page again, one dead step per reload. Until the first push or Back;
// a view pushed from it does not carry its mark (rememberNavPosition). `_ovd` on an overlay's entry is how many overlay
// entries it stood on, its own included: a reload under two (a dialog, or a maximised player, over
// the player) leaves the lower one's entry under the one taken over, and closing the taken-over
// overlay steps back over those too (`deadBelow`) — one Back closes it, as it opened.
let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null; let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null;
// Programmatic history.back() calls still awaiting their popstate — each must be ignored. const reloadedDepth: unknown = window.history.state?._ovd;
let deadBelow = 0;
// Programmatic history steps back still awaiting their popstate — each must be ignored — and how
// many entries they step over in all (a step over dead entries is one traversal of several).
let pendingProgrammatic = 0; let pendingProgrammatic = 0;
let pendingSteps = 0;
const pendingStepCounts: number[] = [];
function stepBack(steps: number): void {
pendingProgrammatic++;
pendingSteps += steps;
pendingStepCounts.push(steps);
if (steps === 1) window.history.back();
else window.history.go(-steps);
}
function landedStepBack(): void {
pendingProgrammatic--;
pendingSteps -= pendingStepCounts.shift() ?? 1;
}
// The overlay at the bottom of the stack closing: back over its entry and any dead ones under it.
function stepsToClose(): number {
const steps = historyDepth === 0 ? 1 + deadBelow : 1;
if (historyDepth === 0) deadBelow = 0;
return steps;
}
let reconcileScheduled = false; let reconcileScheduled = false;
let listenerInstalled = false; let listenerInstalled = false;
// Re-checked after every reconcile — see `afterClosing`. // Re-checked after every reconcile — see `afterClosing`.
@@ -314,19 +344,27 @@ function reconcileHistory(): void {
const here = window.history.state?._ov; const here = window.history.state?._ov;
if (historyDepth === 1 && here != null && here === reloadedOverlayEntry) { if (historyDepth === 1 && here != null && here === reloadedOverlayEntry) {
reloadedOverlayEntry = null; reloadedOverlayEntry = null;
if (typeof reloadedDepth === "number" && reloadedDepth > 1) deadBelow = reloadedDepth - 1;
continue; continue;
} }
reloadedOverlayEntry = null; reloadedOverlayEntry = null;
window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, ""); window.history.pushState(
{ ...window.history.state, _ov: nextOverlayToken(), _ovd: historyDepth + deadBelow },
"",
);
} }
while (historyDepth > want) { while (historyDepth > want) {
historyDepth--; historyDepth--;
if (backStaysInDocument()) { const steps = stepsToClose();
pendingProgrammatic++; if (backStaysInDocument(steps)) stepBack(steps);
window.history.back(); else if (steps > 1 && backStaysInDocument()) stepBack(1);
} else { else {
// Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked. // Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked.
const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record<string, unknown>; const {
_ov: _drop,
_ovd: _depth,
...st
} = (window.history.state ?? {}) as Record<string, unknown>;
window.history.replaceState(st, ""); window.history.replaceState(st, "");
} }
} }
@@ -368,7 +406,7 @@ function ensureOverlayListener(): void {
window.addEventListener("popstate", () => { window.addEventListener("popstate", () => {
reloadedOverlayEntry = null; reloadedOverlayEntry = null;
if (pendingProgrammatic > 0) { if (pendingProgrammatic > 0) {
pendingProgrammatic--; // our own back(): consume and ignore landedStepBack(); // our own back(): consume and ignore
// The last one landed: an overlay that opened while it was in flight still owes its entry. // The last one landed: an overlay that opened while it was in flight still owes its entry.
if (pendingProgrammatic === 0 && historyDepth < overlayStack.length) scheduleReconcile(); if (pendingProgrammatic === 0 && historyDepth < overlayStack.length) scheduleReconcile();
else nameOverlaysHere(); else nameOverlaysHere();
@@ -376,7 +414,11 @@ function ensureOverlayListener(): void {
} }
// Genuine user Back: the browser already dropped one overlay entry, so close the topmost // Genuine user Back: the browser already dropped one overlay entry, so close the topmost
// overlay to match. Its unmount cleanup finds itself already off the stack and only re-syncs. // overlay to match. Its unmount cleanup finds itself already off the stack and only re-syncs.
// The last one closing over dead entries (see `deadBelow`) steps over them too.
if (historyDepth > 0) historyDepth--; if (historyDepth > 0) historyDepth--;
const dead = historyDepth === 0 ? deadBelow : 0;
if (historyDepth === 0) deadBelow = 0;
if (dead > 0 && backStaysInDocument(dead)) stepBack(dead);
const top = overlayStack.pop(); const top = overlayStack.pop();
if (top) top.cb(); if (top) top.cb();
scheduleReconcile(); scheduleReconcile();
+16 -2
View File
@@ -10,9 +10,12 @@ import { installClientLog } from "./logHooks";
const appCopy = (text: string | null) => const appCopy = (text: string | null) =>
["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null; ["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null;
// The pages' search boxes' keys, as main.tsx hands them over from the module definitions.
const TYPED = ["fq", "yt"];
beforeAll(() => { beforeAll(() => {
installClientLog(appCopy); installClientLog(appCopy, TYPED);
installClientLog(appCopy); // a hot reload's second call must not wrap History twice installClientLog(appCopy, TYPED); // a hot reload's second call must not wrap History twice
expect(events().map((e) => e.event)).toEqual(["app.start", "nav.view"]); expect(events().map((e) => e.event)).toEqual(["app.start", "nav.view"]);
clear(); clear();
}); });
@@ -56,6 +59,17 @@ describe("client log hooks", () => {
}); });
}); });
it("keeps a page's own search box out below TRACE too", () => {
setLevel("DEBUG");
window.history.pushState({ sfPage: "files" }, "", "/?folder=all&fq=private+words&yt=lofi");
expect(views()[0]!.attrs!.values).toEqual({ folder: "all" });
expect(JSON.stringify(views())).not.toContain("private");
setLevel("TRACE");
window.history.pushState({ sfPage: "files" }, "", "/?folder=all&fq=private+words");
expect(views()[1]!.attrs!.values).toEqual({ folder: "all", fq: "private words" });
});
it("records an overlay opening as a view of its own, and Back as a pop", () => { it("records an overlay opening as a view of its own, and Back as a pop", () => {
window.history.pushState({ sfPage: "feed", _ov: 1 }, ""); window.history.pushState({ sfPage: "feed", _ov: 1 }, "");
// What Back does: the browser restores the previous entry's state (the unwrapped prototype // What Back does: the browser restores the previous entry's state (the unwrapped prototype
+7 -2
View File
@@ -14,7 +14,8 @@ import { enabled, errorAttrs, log } from "./log";
import { pathTemplate } from "./http"; import { pathTemplate } from "./http";
// URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes"). // URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes").
// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG. // Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG. The
// shell's own here; each page's search boxes join them at install (ModuleDef.typedUrlKeys).
const TYPED_PARAMS = new Set(["q", "keywords", "search"]); const TYPED_PARAMS = new Set(["q", "keywords", "search"]);
type Via = "start" | "push" | "replace" | "pop"; type Via = "start" | "push" | "replace" | "pop";
@@ -168,9 +169,13 @@ let installed = false;
/** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice. /** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice.
* `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this * `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this
* module stays free of the i18n runtime. */ * module stays free of the i18n runtime. */
export function installClientLog(appCopy: AppCopy = () => null): void { export function installClientLog(
appCopy: AppCopy = () => null,
typedParams: readonly string[] = [],
): void {
if (installed) return; if (installed) return;
installed = true; installed = true;
for (const k of typedParams) TYPED_PARAMS.add(k);
log.info("app.start", "app loaded", { log.info("app.start", "app loaded", {
version: FRONTEND_VERSION, version: FRONTEND_VERSION,
+4
View File
@@ -50,6 +50,10 @@ export type ModuleDef = {
// along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to // along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to
// one module and none is a shell key (moduleUrl.contract.test). // one module and none is a shell key (moduleUrl.contract.test).
urlKeys: readonly string[]; urlKeys: readonly string[];
// Those of them that carry what the user TYPED (a search box's text): the client log keeps their
// values out below its most detailed level, as it does the shell's own (lib/logHooks). A new search
// box's key is declared here with it (moduleUrl.contract.test holds them to urlKeys).
typedUrlKeys?: readonly string[];
// Its page plays YouTube videos in the player and reopens it from an address that names one (a // Its page plays YouTube videos in the player and reopens it from an address that names one (a
// reload, a bookmark — lib/playerUrlState), with its own list as the queue. A page without it gets // reload, a bookmark — lib/playerUrlState), with its own list as the queue. A page without it gets
// the shell's player for such an address: the video alone. // the shell's player for such an address: the video alone.
+9
View File
@@ -20,6 +20,15 @@ export function isStoredSort(s: SortState): boolean {
return s?.key === PLAYLIST_STORED_SORT?.key && s?.dir === PLAYLIST_STORED_SORT?.dir; return s?.key === PLAYLIST_STORED_SORT?.key && s?.dir === PLAYLIST_STORED_SORT?.dir;
} }
/** The address-bar delta for the page's store: the playlist shown and the rail's search (each null
* when not named). */
export function playlistsParams(
selected: number | null,
search: string,
): Record<string, string | null> {
return { pl: selected != null ? String(selected) : null, plq: search || null };
}
/** The address-bar delta for an open playlist's view; `null` clears it (no playlist open). */ /** The address-bar delta for an open playlist's view; `null` clears it (no playlist open). */
export function playlistViewParamUpdates(v: PlaylistView | null): Record<string, string | null> { export function playlistViewParamUpdates(v: PlaylistView | null): Record<string, string | null> {
if (!v) return { plsort: null, plf: null, plgroup: null }; if (!v) return { plsort: null, plf: null, plgroup: null };
+5
View File
@@ -6,6 +6,11 @@ import { LS, setTabRaw } from "./storage";
// address-bar key the page mirrors its tab to is here too, beside the other pages' keys. // address-bar key the page mirrors its tab to is here too, beside the other pages' keys.
export const SETTINGS_TAB_KEY = "stab"; export const SETTINGS_TAB_KEY = "stab";
/** The address-bar delta for the Settings tab shown. */
export function settingsTabParams(tab: string | null): Record<string, string | null> {
return { [SETTINGS_TAB_KEY]: tab };
}
export type SettingsTabId = export type SettingsTabId =
| "appearance" | "appearance"
| "notifications" | "notifications"
+2 -2
View File
@@ -127,13 +127,13 @@ describe("a page opened at a path that starts with //", () => {
}); });
describe("filterParamUpdates", () => { describe("filterParamUpdates", () => {
it("maps non-default filters to values and defaults to null (delete)", () => { it("maps non-default filters to values and other defaults to null (delete)", () => {
const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" }; const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" };
const upd = filterParamUpdates(f); const upd = filterParamUpdates(f);
expect(upd.q).toBe("react"); expect(upd.q).toBe("react");
expect(upd.tags).toBe("3,7"); expect(upd.tags).toBe("3,7");
expect(upd.show).toBe("watched"); expect(upd.show).toBe("watched");
expect(upd.sort).toBeNull(); // default "newest" is not emitted → cleared expect(upd.sort).toBe("newest"); // the view and the order are always named, defaults too
expect(upd.scope).toBeNull(); expect(upd.scope).toBeNull();
}); });
+14 -14
View File
@@ -80,13 +80,19 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
} }
/** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted /** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted
* value, or null to delete it (so switching a filter back to its default clears the key). Pass `null` * value, or null to delete it (so switching a filter back to its default clears the key) — except the
* to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them. * view and the order, always written (defaults too): a feed address then always names its view, so a
* Feed the result into urlWithParams(). */ * bookmark of the default feed opens the default feed, not whatever view the account stored since
* (an address naming none falls back to it). Pass `null` to clear ALL filter keys — used when leaving
* the feed, where the localStorage copy still holds them. Feed the result into urlWithParams(). */
export function filterParamUpdates(f: FeedFilters | null): Record<string, string | null> { export function filterParamUpdates(f: FeedFilters | null): Record<string, string | null> {
const p = f ? filtersToParams(f) : new URLSearchParams(); const p = f ? filtersToParams(f) : new URLSearchParams();
const out: Record<string, string | null> = {}; const out: Record<string, string | null> = {};
for (const k of KEYS) out[k] = p.get(k); for (const k of KEYS) out[k] = p.get(k);
if (f) {
out.show = f.show || "unwatched";
out.sort = f.sort || "newest";
}
return out; return out;
} }
@@ -233,13 +239,6 @@ export function urlWithParams(updates: Record<string, string | null>): string {
return hrefHere(params); return hrefHere(params);
} }
/** Take `keys` off the current entry's address: what a link named and the page could not show (a file
* or a video that is gone, said once) must not stay to be said again on every reload, or shared. */
export function dropFromAddress(keys: readonly string[]): void {
const updates = Object.fromEntries(keys.map((k) => [k, null]));
window.history.replaceState(window.history.state, "", urlWithParams(updates));
}
/** The channel page's address, opened over the page on screen: the shell's own keys (the page, an /** The channel page's address, opened over the page on screen: the shell's own keys (the page, an
* entry link not yet read) and the channel — none of the page's keys, none of another channel's view * entry link not yet read) and the channel — none of the page's keys, none of another channel's view
* (the channel page writes its own, lib/channelPageUrlState), and no player (opening a channel closes * (the channel page writes its own, lib/channelPageUrlState), and no player (opening a channel closes
@@ -269,10 +268,11 @@ export function hrefHere(query: URLSearchParams, hash: string = window.location.
return url.pathname.startsWith("//") ? url.href : `${url.pathname}${url.search}${url.hash}`; return url.pathname.startsWith("//") ? url.href : `${url.pathname}${url.search}${url.hash}`;
} }
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify, /** Take `keys` off the current entry's address, WITHOUT touching the nav/filter params that live
* ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now * there: TRANSIENT params once read (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
* live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now * ?reset), and what a link named that the page could not show (a file or a video that is gone, said
* that the query is the source of truth for navigation. No-op when none of the keys are present. */ * once) — left, it would be said again on every reload, or shared. Preserves history.state. No-op when
* none of the keys are present. */
export function clearParams(...keys: string[]): void { export function clearParams(...keys: string[]): void {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
let changed = false; let changed = false;
+40 -2
View File
@@ -5,7 +5,7 @@
// so an inline object literal (new identity every render) only fires when a param actually changes; a // so an inline object literal (new identity every render) only fires when a param actually changes; a
// null value clears a key; and every other param on the URL is preserved. // null value clears a key; and every other param on the URL is preserved.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook } from "@testing-library/react"; import { act, cleanup, renderHook } from "@testing-library/react";
import { useUrlMirror } from "./useUrlMirror"; import { useUrlMirror } from "./useUrlMirror";
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/"); const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
@@ -15,7 +15,10 @@ beforeEach(() => {
setUrl("/"); setUrl("/");
spy = vi.spyOn(window.history, "replaceState"); spy = vi.spyOn(window.history, "replaceState");
}); });
afterEach(() => spy.mockRestore()); afterEach(() => {
cleanup();
spy.mockRestore();
});
describe("useUrlMirror", () => { describe("useUrlMirror", () => {
it("writes the param onto the current entry on mount", () => { it("writes the param onto the current entry on mount", () => {
@@ -49,6 +52,41 @@ describe("useUrlMirror", () => {
expect(window.location.search).toBe("?page=playlists&pl=5"); expect(window.location.search).toBe("?page=playlists&pl=5");
}); });
// An entry above the view's own (a dialog's, a drawer's, a sub-view's) took a change the entry
// under it never saw: Back restores that entry's older address, and a reload would bring the old
// view back over the one on screen.
it("names the view again on the entry Back lands on", async () => {
setUrl("?page=plex&pf=");
const { rerender } = renderHook((p: Record<string, string | null>) => useUrlMirror(p), {
initialProps: { pf: "" } as Record<string, string | null>,
});
window.history.pushState({ _ov: 1 }, "", window.location.href);
rerender({ pf: "genres=Drama" });
expect(window.location.search).toBe("?page=plex&pf=genres%3DDrama");
await act(async () => {
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
window.history.back();
await landed;
await new Promise((res) => setTimeout(res, 0));
});
expect(window.history.state).toBeNull();
expect(window.location.search).toBe("?page=plex&pf=genres%3DDrama");
});
it("writes nothing on a landing that already names the view", async () => {
setUrl("?pl=5");
renderHook(() => useUrlMirror({ pl: "5" }));
window.history.pushState({ _ov: 1 }, "", window.location.href);
spy.mockClear();
await act(async () => {
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
window.history.back();
await landed;
await new Promise((res) => setTimeout(res, 0));
});
expect(spy).not.toHaveBeenCalled();
});
it("leaves an unconsumed auth token in the fragment alone", () => { it("leaves an unconsumed auth token in the fragment alone", () => {
// This hook's mount effect is what ran before <App> (and therefore before the auth card) had even // This hook's mount effect is what ran before <App> (and therefore before the auth card) had even
// loaded, so whatever it writes to the address bar is what the card later reads. It must not eat // loaded, so whatever it writes to the address bar is what the card later reads. It must not eat
+33 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useEffectEvent } from "react"; import { useEffect, useEffectEvent, useState } from "react";
import { urlWithParams } from "./urlState"; import { urlWithParams } from "./urlState";
// Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new // Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new
@@ -6,17 +6,44 @@ import { urlWithParams } from "./urlState";
// value clears that key, and urlWithParams preserves every other param (so providers don't clobber each // value clears that key, and urlWithParams preserves every other param (so providers don't clobber each
// other). Extracted from the identical replaceState/urlWithParams blocks FeedFiltersProvider and // other). Extracted from the identical replaceState/urlWithParams blocks FeedFiltersProvider and
// PlaylistsProvider used to hand-roll, so the mirroring contract lives in one place. // PlaylistsProvider used to hand-roll, so the mirroring contract lives in one place.
//
// Also whenever Back/Forward makes another entry current: the browser restores that entry's address,
// which says what the view was when it was left — a change made since on an entry above it (a dialog's
// or a drawer's, a sub-view's) is not in it, and a reload would bring the old view back over the one
// on screen. (One landed on by an overlay's step back that another step will leave is written too,
// and harmlessly: the next landing writes the entry that stays.)
export function useUrlMirror(updates: Record<string, string | null>): void { export function useUrlMirror(updates: Record<string, string | null>): void {
// The caller typically passes an inline object literal (new identity every render); key on its // The caller typically passes an inline object literal (new identity every render); key on its
// serialized value so the effect only fires when a param actually changes, not every render. // serialized value so the effect only fires when a param actually changes, not every render.
const dep = JSON.stringify(updates); const dep = JSON.stringify(updates);
// The object itself is read through an effect event, so the effect's one reactive input is the // The object itself is read through an effect event, so the effect's reactive inputs are the
// serialized value — which is exactly what the dep list says: an effect event for the identity that // serialized value and the landing count — which is exactly what the dep list says: an effect event
// churns, a primitive for the trigger. // for the identity that churns, primitives for the triggers.
const mirror = useEffectEvent(() => { const mirror = useEffectEvent(() => {
window.history.replaceState(window.history.state, "", urlWithParams(updates)); const href = urlWithParams(updates);
// Every mirror runs on every landing: one with nothing to change writes nothing (a browser
// throttles a page that rewrites its address too often).
if (href !== urlWithParams({})) window.history.replaceState(window.history.state, "", href);
}); });
// Counted in state, on a task after the popstate dispatch: every listener of it has run by then —
// a store that reads its keys back from the landed address among them (Activity) — and so has the
// render each one caused (React renders a popstate's updates at once, between listeners). A write
// inside the dispatch put the store's old value over the address a later listener was still to
// read.
const [landings, setLandings] = useState(0);
useEffect(() => {
let timer = 0;
const onPop = () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => setLandings((n) => n + 1), 0);
};
window.addEventListener("popstate", onPop);
return () => {
window.removeEventListener("popstate", onPop);
window.clearTimeout(timer);
};
}, []);
useEffect(() => { useEffect(() => {
mirror(); mirror();
}, [dep]); }, [dep, landings]);
} }
+2 -1
View File
@@ -8,6 +8,7 @@ import { ConfirmProvider } from "./components/ui/ConfirmProvider";
import BootGate, { BootScreen } from "./shell/BootGate"; import BootGate, { BootScreen } from "./shell/BootGate";
import { NavigationProvider } from "./features/navigation/NavigationProvider"; import { NavigationProvider } from "./features/navigation/NavigationProvider";
import { ModuleStores } from "./features/navigation/ModuleStores"; import { ModuleStores } from "./features/navigation/ModuleStores";
import { typedUrlKeys } from "./features/navigation/moduleRegistry";
import { PrefsProvider } from "./features/prefs/PrefsProvider"; import { PrefsProvider } from "./features/prefs/PrefsProvider";
import { WizardProvider } from "./features/wizard/WizardProvider"; import { WizardProvider } from "./features/wizard/WizardProvider";
import "./i18n"; import "./i18n";
@@ -19,7 +20,7 @@ import { installStaleBuildReload } from "./lib/staleBuild";
import { appCopyOf } from "./i18n/appCopy"; import { appCopyOf } from "./i18n/appCopy";
// First, before any render: a crash or a navigation during boot belongs in the log too. // First, before any render: a crash or a navigation during boot belongs in the log too.
installClientLog(appCopyOf); installClientLog(appCopyOf, typedUrlKeys());
// A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk. // A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk.
installStaleBuildReload(); installStaleBuildReload();
+25 -1
View File
@@ -4,7 +4,7 @@
// the list searched (Channels, Files and Playlists kept it in memory only, Plex in this browser only). // the list searched (Channels, Files and Playlists kept it in memory only, Plex in this browser only).
// The real navigation and the real stores, read through their hooks; only the network is absent. // The real navigation and the real stores, read through their hooks; only the network is absent.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { cleanup, render, waitFor } from "@testing-library/react"; import { act, cleanup, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -14,6 +14,10 @@ vi.mock("react-i18next", async (importOriginal) => ({
})); }));
import { NavigationProvider } from "./features/navigation/NavigationProvider"; import { NavigationProvider } from "./features/navigation/NavigationProvider";
import {
useNavigationActions,
type NavigationActions,
} from "./features/navigation/navigationContext";
import { ModuleStores } from "./features/navigation/ModuleStores"; import { ModuleStores } from "./features/navigation/ModuleStores";
import { useChannels } from "./modules/channels/channelsContext"; import { useChannels } from "./modules/channels/channelsContext";
import { useFiles } from "./modules/files/filesContext"; import { useFiles } from "./modules/files/filesContext";
@@ -22,13 +26,16 @@ import { usePlex } from "./modules/plex/plexContext";
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "./lib/storage"; import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "./lib/storage";
const seen: { current: Record<string, string> } = { current: {} }; const seen: { current: Record<string, string> } = { current: {} };
const nav: { current: NavigationActions | null } = { current: null };
function Probe() { function Probe() {
const channels = useChannels(); const channels = useChannels();
const files = useFiles(); const files = useFiles();
const playlists = usePlaylists(); const playlists = usePlaylists();
const plex = usePlex(); const plex = usePlex();
const actions = useNavigationActions();
useEffect(() => { useEffect(() => {
nav.current = actions;
seen.current = { seen.current = {
channels: channels.search, channels: channels.search,
files: files.search, files: files.search,
@@ -93,3 +100,20 @@ describe("Plex's search", () => {
expect(search().get("pqf")).toBe("people"); expect(search().get("pqf")).toBe("people");
}); });
}); });
// The search is the page's while it is on screen: leaving the page ends it, so a trip back through
// the rail opens the page unsearched (and the address names none).
describe.each([
["files", "fq", "notes"],
["playlists", "plq", "jazz"],
])("%s's name search, once the page is left", (page, key, value) => {
it("is gone when the page is opened again", async () => {
mountAt(`/?page=${page}&${key}=${value}`, page);
await waitFor(() => expect(seen.current[page]).toBe(value));
act(() => nav.current!.setPage("feed"));
act(() => nav.current!.setPage(page as "files" | "playlists"));
await waitFor(() => expect(seen.current[page]).toBe(""));
await new Promise((r) => setTimeout(r, 350)); // past the address write's typing pause
expect(search().get(key)).toBeNull();
});
});
+110 -24
View File
@@ -3,26 +3,28 @@
// The URL is the view: each module declares the address-bar keys its view writes (`urlKeys`), and the // The URL is the view: each module declares the address-bar keys its view writes (`urlKeys`), and the
// shell clears them on a page switch and opens a page without another page's keys (NavigationProvider, // shell clears them on a page switch and opens a page without another page's keys (NavigationProvider,
// derived from these declarations). That only holds while the declarations are true, so this pins // derived from these declarations). That only holds while the declarations are true, so this pins
// them: each key belongs to one module and is no shell key, every codec writes only its module's keys, // them: each key belongs to one module and is no shell key, the keys a module declares are exactly
// and no key literal anywhere in the app reads or writes the address bar undeclared. // what its encoders write, a writer outside lib/ hands the address an encoder's result (or literal
// keys, checked here) — never a key of its own making — and every key literal read from the live
// query is declared.
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs"; import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path"; import { join, relative } from "node:path";
import { moduleDef, REGISTERED_PAGES } from "./features/navigation/moduleRegistry"; import { moduleDef, REGISTERED_PAGES, typedUrlKeys } from "./features/navigation/moduleRegistry";
import { DEFAULT_TAG_MODES, EMPTY_PLEX_FILTERS, type FeedFilters } from "./lib/api"; import { DEFAULT_TAG_MODES, EMPTY_PLEX_FILTERS, type FeedFilters } from "./lib/api";
import { filterParamUpdates, SHELL_KEYS, type Page } from "./lib/urlState"; import { filterParamUpdates, SHELL_KEYS, type Page } from "./lib/urlState";
import { channelsParamUpdates } from "./lib/channelsUrlState"; import { channelsParamUpdates } from "./lib/channelsUrlState";
import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState"; import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState";
import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState"; import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState";
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState"; import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState";
import { FILES_KEYS } from "./lib/filesUrlState"; import { filesListParams, filesOpenParams, filesSortParams } from "./lib/filesUrlState";
import { ytSearchParams } from "./lib/ytSearchUrlState"; import { ytSearchParams } from "./lib/ytSearchUrlState";
import { budgetParams } from "./lib/homeUrlState"; import { budgetParams } from "./lib/homeUrlState";
import { PLAYER_KEYS, playerParamUpdates } from "./lib/playerUrlState"; import { PLAYER_KEYS, playerParamUpdates } from "./lib/playerUrlState";
import { playlistViewParamUpdates } from "./lib/playlistsUrlState"; import { playlistsParams, playlistViewParamUpdates } from "./lib/playlistsUrlState";
import { SETTINGS_TAB_KEY } from "./lib/settingsTab"; import { settingsTabParams } from "./lib/settingsTab";
import { CONFIG_TAB_KEY } from "./lib/configTab"; import { configTabParams } from "./lib/configTab";
import { USERS_TAB_URL_KEY } from "./lib/adminUsersTab"; import { usersTabParams } from "./lib/adminUsersTab";
import { PLEX_SUB_URL } from "./modules/plex/plexSubUrl"; import { PLEX_SUB_URL } from "./modules/plex/plexSubUrl";
import { MODE_URL } from "./shell/welcomeMode"; import { MODE_URL } from "./shell/welcomeMode";
import { CHANNEL_PAGE_KEYS, channelPageParamUpdates } from "./lib/channelPageUrlState"; import { CHANNEL_PAGE_KEYS, channelPageParamUpdates } from "./lib/channelPageUrlState";
@@ -56,13 +58,17 @@ const WRITES: Partial<Record<Page, () => string[]>> = {
...Object.keys(plexSearchParamUpdates({ q: "x", field: "all" })), ...Object.keys(plexSearchParamUpdates({ q: "x", field: "all" })),
PLEX_SUB_URL.key, PLEX_SUB_URL.key,
], ],
files: () => [...FILES_KEYS], files: () => [
settings: () => [SETTINGS_TAB_KEY], ...Object.keys(filesListParams(null)),
config: () => [CONFIG_TAB_KEY], ...Object.keys(filesSortParams(null)),
users: () => [USERS_TAB_URL_KEY], ...Object.keys(filesOpenParams({ open: "view", id: 1 })),
...Object.keys(filesOpenParams({ open: "play", id: 1 })),
],
settings: () => Object.keys(settingsTabParams(null)),
config: () => Object.keys(configTabParams(null)),
users: () => Object.keys(usersTabParams(null)),
playlists: () => [ playlists: () => [
"pl", ...Object.keys(playlistsParams(null, "")),
"plq",
...Object.keys(playlistViewParamUpdates({ sort: null, filters: {}, groupBy: false })), ...Object.keys(playlistViewParamUpdates({ sort: null, filters: {}, groupBy: false })),
], ],
activity: () => [ activity: () => [
@@ -79,25 +85,57 @@ function sources(dir: string): string[] {
}); });
} }
// Key literals that reach the address bar's QUERY: the keys of an object literal handed to // The object literals handed to an address writer: `urlWithParams` / `useUrlMirror`, an overlay's
// `urlWithParams` / `useUrlMirror`, and a `.get("…")` on the live query. // own keys (ui/Modal's `url`, `useBackToClose`'s).
const WRITER_OBJECTS = [
/(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/g,
/\burl=\{\{([^}]*)\}/g,
/useBackToClose\([^,()]+,\s*\{([^}]*)\}/g,
];
// Key literals that reach the address bar's QUERY: the literal keys of a writer's object, and a
// `.get("…")` / `.has("…")` in a file that reads the live query (any params object there: the
// two-line form names its params before it reads them). The fragment (`hash`) is not the query.
function queryKeyLiterals(): { key: string; where: string }[] { function queryKeyLiterals(): { key: string; where: string }[] {
const out: { key: string; where: string }[] = []; const out: { key: string; where: string }[] = [];
for (const file of sources(SRC)) { for (const file of sources(SRC)) {
const text = readFileSync(file, "utf8"); const text = readFileSync(file, "utf8");
const where = relative(SRC, file).split("\\").join("/"); const where = relative(SRC, file).split("\\").join("/");
for (const m of text.matchAll(/(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/g)) { for (const re of WRITER_OBJECTS) {
for (const k of `{${m[1]!}`.matchAll(/[{,]\s*(\w+)\s*:/g)) out.push({ key: k[1]!, where }); for (const m of text.matchAll(re)) {
for (const k of `{${m[1]!}`.matchAll(/[{,]\s*(\w+)\s*:/g)) out.push({ key: k[1]!, where });
}
} }
for (const m of text.matchAll( if (!text.includes("location.search")) continue;
/new URLSearchParams\(window\.location\.search\)\.get\("(\w+)"\)/g, for (const m of text.matchAll(/(\b\w+|\))\.(?:get|has)\("(\w+)"\)/g)) {
)) { if (m[1] !== "hash") out.push({ key: m[2]!, where });
out.push({ key: m[1]!, where });
} }
} }
return out; return out;
} }
// A key a writer computes itself (`[KEY]: …`) is one no scan can read: outside lib/ (the codecs, and
// the history plumbing they feed) a writer hands the address an encoder's result instead, and the
// encoder is what WRITES calls.
function computedWriterKeys(inLib = false): string[] {
const out: string[] = [];
for (const file of sources(SRC)) {
const where = relative(SRC, file).split("\\").join("/");
if (where.startsWith("lib/") !== inLib) continue;
const text = readFileSync(file, "utf8");
for (const re of WRITER_OBJECTS) {
for (const m of text.matchAll(re)) {
if (/[{,]\s*\[/.test(`{${m[1]!}`)) out.push(`${where}: ${m[0]!.slice(0, 60)}`);
}
}
}
return out;
}
// A retired page's link keys: read once to open the page that replaced it, and cleared (lib/urlState
// RETIRED) — no module writes them any more.
const RETIRED_LINK_KEYS = ["lu", "lq", "lwin"];
describe("module URL keys", () => { describe("module URL keys", () => {
const owner = new Map<string, Page>(); const owner = new Map<string, Page>();
for (const page of REGISTERED_PAGES) { for (const page of REGISTERED_PAGES) {
@@ -154,14 +192,62 @@ describe("module URL keys", () => {
it("cover every key literal the app reads or writes in the address bar", () => { it("cover every key literal the app reads or writes in the address bar", () => {
const shell: readonly string[] = SHELL_KEYS; const shell: readonly string[] = SHELL_KEYS;
const undeclared = queryKeyLiterals().filter( const undeclared = queryKeyLiterals().filter(
({ key }) => !owner.has(key) && !shell.includes(key), ({ key }) => !owner.has(key) && !shell.includes(key) && !RETIRED_LINK_KEYS.includes(key),
); );
expect(undeclared).toEqual([]); expect(undeclared).toEqual([]);
}); });
it("the literal scan sees the call sites it is meant to (a regex that matched nothing would pass)", () => { it("the literal scan sees the call sites it is meant to (a regex that matched nothing would pass)", () => {
const seen = new Set(queryKeyLiterals().map(({ key }) => key)); const seen = new Set(queryKeyLiterals().map(({ key }) => key));
for (const key of ["pl", "page", "rjob", "link"]) expect(seen).toContain(key); // A writer's literal, a one-line read, a two-line read, a retired link's.
for (const key of ["page", "rjob", "link", "plq", "folder", "lq"]) expect(seen).toContain(key);
});
it("are written outside lib/ only through the codecs that WRITES calls", () => {
expect(computedWriterKeys()).toEqual([]);
});
it("the computed-key scan sees what it is meant to (lib's own writers compute theirs)", () => {
expect(computedWriterKeys(true).some((w) => w.startsWith("lib/history.ts"))).toBe(true);
});
// What a search box puts in the address is what the user typed: the client log keeps it out below
// its most detailed level (lib/logHooks), from these declarations.
it("declare their typed-text keys among their own", () => {
for (const page of REGISTERED_PAGES) {
const def = moduleDef(page);
expect(
(def.typedUrlKeys ?? []).filter((k) => !def.urlKeys.includes(k)),
page,
).toEqual([]);
}
expect([...typedUrlKeys()].sort()).toEqual(["aq", "cq", "fq", "plq", "pq", "q", "yt"]);
// …which the client log is handed as it installs.
expect(readFileSync(join(SRC, "main.tsx"), "utf8")).toContain(
"installClientLog(appCopyOf, typedUrlKeys());",
);
});
});
// The bookmark net (e2e/bookmark.spec.ts) opens every page that names its view, through the rail or
// by its address — Plex excepted, by name there: its view is the instance's own library.
describe("the bookmark net", () => {
const spec = readFileSync(join(SRC, "..", "e2e", "bookmark.spec.ts"), "utf8");
const opened = (page: Page) =>
spec.includes(`page=${page}`) ||
spec.includes(`gotoPage(page, "${page}")`) ||
spec.includes(`gotoAdminPage(page, "${page}")`);
it.each(REGISTERED_PAGES.filter((p) => moduleDef(p).urlKeys.length > 0 && p !== "plex"))(
"takes a round trip through %s",
(page) => {
expect(opened(page)).toBe(true);
},
);
it("names the page it leaves out", () => {
expect(opened("plex")).toBe(false);
expect(spec).toContain("Not Plex:");
}); });
}); });
@@ -297,7 +297,9 @@ describe("Activity", () => {
it("Back/Forward on the server log reads only the filters of the entry it lands on", async () => { it("Back/Forward on the server log reads only the filters of the entry it lands on", async () => {
// A browser runs the microtasks between two popstate listeners, so each listener's update // A browser runs the microtasks between two popstate listeners, so each listener's update
// renders on its own; a test dispatch does not. Each listener runs in its own act here. // renders on its own; a test dispatch does not. Each listener runs in its own act here — a
// synchronous one: a browser runs no TASK between them (an async act let a timer set by one
// listener fire before the next).
const listeners: EventListener[] = []; const listeners: EventListener[] = [];
const addOther = window.addEventListener.bind(window); const addOther = window.addEventListener.bind(window);
const removeOther = window.removeEventListener.bind(window); const removeOther = window.removeEventListener.bind(window);
@@ -316,7 +318,7 @@ describe("Activity", () => {
// Forward onto a pivot's entry: one run's lines over the log's whole retention. // Forward onto a pivot's entry: one run's lines over the log's whole retention.
window.history.pushState(null, "", "/?page=activity&at=logs&aw=90d&lrun=41"); window.history.pushState(null, "", "/?page=activity&at=logs&aw=90d&lrun=41");
for (const l of [...listeners]) { for (const l of [...listeners]) {
await act(async () => l(new PopStateEvent("popstate"))); act(() => l(new PopStateEvent("popstate")));
} }
await waitFor(() => await waitFor(() =>
expect(logsMock).toHaveBeenLastCalledWith(expect.objectContaining({ run_id: 41 }), null), expect(logsMock).toHaveBeenLastCalledWith(expect.objectContaining({ run_id: 41 }), null),
+1
View File
@@ -14,4 +14,5 @@ export const activityModule: ModuleDef = {
gate: (me) => me.role === "admin", gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./Activity")), Page: lazyWithPreload(() => import("./Activity")),
urlKeys: ACTIVITY_KEYS, urlKeys: ACTIVITY_KEYS,
typedUrlKeys: ["aq"],
}; };
+1
View File
@@ -15,4 +15,5 @@ export const channelsModule: ModuleDef = {
maxWidth: "96rem", maxWidth: "96rem",
Store: ChannelsProvider, Store: ChannelsProvider,
urlKeys: CHANNELS_KEYS, urlKeys: CHANNELS_KEYS,
typedUrlKeys: ["cq"],
}; };
+2 -2
View File
@@ -16,7 +16,7 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle";
import FilterSelect from "../../components/ui/FilterSelect"; import FilterSelect from "../../components/ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "../../components/ui/DraftSaveBar"; import { DraftSaveBar, type SaveState } from "../../components/ui/DraftSaveBar";
import { LS, useTabPersistedState } from "../../lib/storage"; import { LS, useTabPersistedState } from "../../lib/storage";
import { CONFIG_TAB_KEY } from "../../lib/configTab"; import { CONFIG_TAB_KEY, configTabParams } from "../../lib/configTab";
import { useUrlMirror } from "../../lib/useUrlMirror"; import { useUrlMirror } from "../../lib/useUrlMirror";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
@@ -192,7 +192,7 @@ export default function ConfigPanel() {
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0]; const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
// The address names the group on screen (always: see SettingsPanel); until the registry is in, the // The address names the group on screen (always: see SettingsPanel); until the registry is in, the
// one it was opened on. // one it was opened on.
useUrlMirror({ [CONFIG_TAB_KEY]: (data ? activeGroup : tab) || null }); useUrlMirror(configTabParams((data ? activeGroup : tab) || null));
if (q.isError && !data) { if (q.isError && !data) {
return ( return (
+12 -11
View File
@@ -503,6 +503,10 @@ export default function Feed({
const saved = savedOverrides[v.id]; const saved = savedOverrides[v.id];
return saved && !(live && saved.answered) ? { ...v, saved: saved.value } : v; return saved && !(live && saved.answered) ? { ...v, saved: saved.value } : v;
}); });
// The live search's cards, in the order picked for them, and the player's queue over them.
const ytShown = (rows: Video[]): Video[] =>
sortYtResults(withOverrides(rows, true), ytSort.key as SortKey, ytSort.dir, lang);
const ytQueue = (rows: Video[]): Video[] => ytShown(rows).filter((v) => v.status !== "hidden");
const merged: Video[] = withOverrides(loaded); const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show)); const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the // The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
@@ -511,16 +515,18 @@ export default function Feed({
// goes hidden still drops out ("visible affects"). // goes hidden still drops out ("visible affects").
const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden"); const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden");
// A reload or a bookmark of the player reopens it once this list is in, the list as its queue again // A reload or a bookmark of the player reopens it once this list is in, the list as its queue again
// (the feed's filters are in the address under it). Not over a live search: that is not in the // (the feed's filters are in the address under it) — over a live search too, its results the
// address, and a reload leaves it. // queue, once they are in (or the search waits for its press, and it plays alone). Scoped to the
// list it was linked over: leaving the search first gives it up.
useOpenLinkedPlayer( useOpenLinkedPlayer(
!ytActive && !query.isLoading, ytActive ? ytQuery.data !== undefined || ytQuery.isError || !ytRuns : !query.isLoading,
(id) => playerQueue.find((v) => v.id === id), (id) => (ytActive ? ytQueue(ytLoaded) : playerQueue).find((v) => v.id === id),
(video, inList) => { (video, inList) => {
openVideo(video); openVideo(video);
// Not in what this list has loaded (further down it, or no longer in it): it plays alone. // Not in what this list has loaded (further down it, or no longer in it): it plays alone.
if (!inList) setActiveVideo((a) => a && { ...a, alone: true }); if (!inList) setActiveVideo((a) => a && { ...a, alone: true });
}, },
ytActive ? `yt:${ytSearch}` : "feed",
); );
// A live search ingests new catalog videos, so the disabled feed queries still hold their stale // A live search ingests new catalog videos, so the disabled feed queries still hold their stale
@@ -534,12 +540,7 @@ export default function Feed({
// A separate data source rendered in the same cards/player. No watch-state view filter (the // A separate data source rendered in the same cards/player. No watch-state view filter (the
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota). // user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
if (ytActive) { if (ytActive) {
const ytItems: Video[] = sortYtResults( const ytItems: Video[] = ytShown(ytLoaded);
withOverrides(ytLoaded, true),
ytSort.key as SortKey,
ytSort.dir,
lang,
);
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden"); const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
const ytError = const ytError =
ytQuery.error instanceof HttpError ytQuery.error instanceof HttpError
@@ -704,7 +705,7 @@ export default function Feed({
<PlayerModal <PlayerModal
video={activeVideo.video} video={activeVideo.video}
startAt={activeVideo.startAt} startAt={activeVideo.startAt}
queue={ytPlayerQueue} queue={activeVideo.alone ? undefined : ytPlayerQueue}
onClose={closePlayer} onClose={closePlayer}
onState={onState} onState={onState}
onWrite={trackSessionWrite} onWrite={trackSessionWrite}
@@ -24,6 +24,14 @@ vi.mock("./VirtualFeed", () => ({
), ),
})); }));
vi.mock("../../features/player/PlayerModal", () => ({
default: ({ video, queue }: { video: { id: string }; queue?: { id: string }[] }) => (
<p>
playing {video.id} in {queue ? queue.map((v) => v.id).join(",") : "no list"}
</p>
),
}));
import Feed from "./Feed"; import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api"; import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
@@ -48,6 +56,7 @@ beforeAll(() => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks(); vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
}); });
const FILTERS: FeedFilters = { const FILTERS: FeedFilters = {
@@ -106,14 +115,14 @@ function mount({
search_source: source, search_source: source,
search_enabled: enabled, search_enabled: enabled,
}); });
const tree = (count: number) => ( const tree = (count: number, ytSearch: string | null = "lofi") => (
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<NavigationActionsContext.Provider value={nav}> <NavigationActionsContext.Provider value={nav}>
<NavigationStateContext.Provider <NavigationStateContext.Provider
value={{ value={{
page: "feed", page: "feed",
channelView: null, channelView: null,
ytSearch: "lofi", ytSearch,
ytCount: count, ytCount: count,
ytSearchAsked: asked, ytSearchAsked: asked,
}} }}
@@ -124,7 +133,7 @@ function mount({
view="cards" view="cards"
setView={() => {}} setView={() => {}}
canRead canRead
ytSearch="lofi" ytSearch={ytSearch}
onYtSearch={() => {}} onYtSearch={() => {}}
onExitYtSearch={() => {}} onExitYtSearch={() => {}}
/> />
@@ -133,7 +142,10 @@ function mount({
</QueryClientProvider> </QueryClientProvider>
); );
const view = render(tree(20)); const view = render(tree(20));
return { showing: (count: number) => view.rerender(tree(count)) }; return {
showing: (count: number) => view.rerender(tree(count)),
leave: () => view.rerender(tree(20, null)),
};
} }
const settle = () => new Promise((r) => setTimeout(r, 30)); const settle = () => new Promise((r) => setTimeout(r, 30));
@@ -216,3 +228,41 @@ describe("Feed — a live search the address reopened", () => {
expect(screen.queryByRole("button", { name: "feed.yt.runAgain" })).toBeNull(); expect(screen.queryByRole("button", { name: "feed.yt.runAgain" })).toBeNull();
}); });
}); });
// A player opened over the search names itself in the address too: a reload or a bookmark opens it
// again over the search it was opened from — not later, over a list nobody linked.
describe("Feed — a player the address names over a live search", () => {
it("reopens over the results, them as its queue", async () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b");
vi.spyOn(api, "searchYoutube").mockResolvedValue({
items: [hit("a"), hit("b")],
next_cursor: null,
source: "scrape",
} as never);
const row = vi.spyOn(api, "videoRow");
mount({ source: "scrape", asked: false });
expect(await screen.findByText("playing b in a,b")).toBeTruthy();
expect(row).not.toHaveBeenCalled();
});
it("plays alone over a search that waits for its press", async () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b");
vi.spyOn(api, "videoRow").mockResolvedValue(hit("b"));
mount({ source: "api", asked: false });
expect(await screen.findByText("playing b in no list")).toBeTruthy();
});
it("is given up when the search is left before it opened, and leaves the address", async () => {
window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b");
vi.spyOn(api, "searchYoutube").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "feed").mockResolvedValue({ items: [hit("b")], next_cursor: null } as never);
const row = vi.spyOn(api, "videoRow").mockResolvedValue(hit("b"));
const { leave } = mount({ source: "scrape", asked: false });
leave();
await settle();
await settle();
expect(screen.queryByText(/^playing/)).toBeNull();
expect(row).not.toHaveBeenCalled();
expect(new URLSearchParams(window.location.search).get("watch")).toBeNull();
});
});
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
import { type FeedFilters } from "../../lib/api"; import { type FeedFilters } from "../../lib/api";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../../lib/urlState"; import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../../lib/urlState";
import { useUrlMirror } from "../../lib/useUrlMirror"; import { useUrlMirror } from "../../lib/useUrlMirror";
import { useDebounced } from "../../lib/useDebounced";
import { ownKey, writeJSON } from "../../lib/storage"; import { ownKey, writeJSON } from "../../lib/storage";
import { logFilterChange } from "../../lib/filterLog"; import { logFilterChange } from "../../lib/filterLog";
import { useNavigation } from "../../features/navigation/navigationContext"; import { useNavigation } from "../../features/navigation/navigationContext";
@@ -56,8 +57,11 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
// keys are cleared from the URL (localStorage still holds them; they re-mirror on return to the // keys are cleared from the URL (localStorage still holds them; they re-mirror on return to the
// feed). The channel page keeps its own isolated filters, in keys of its own. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the // feed). The channel page keeps its own isolated filters, in keys of its own. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
// other providers own. // other providers own.
// The search text once the typing pauses, as the other pages' searches: one address-bar write per
// keystroke is churn a browser throttles.
const nav = useNavigation(); const nav = useNavigation();
useUrlMirror(filterParamUpdates(urlPage(nav) === "feed" ? filters : null)); const typedQ = useDebounced(filters.q ?? "", 300);
useUrlMirror(filterParamUpdates(urlPage(nav) === "feed" ? { ...filters, q: typedQ } : null));
// A share link's filters (captured at mount) win over the account's stored view and are persisted // A share link's filters (captured at mount) win over the account's stored view and are persisted
// to it once, so the view the link opened also survives a later plain reload. // to it once, so the view the link opened also survives a later plain reload.
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
//
// The feed's address names its view. The view and the order always (defaults too): an address naming
// none falls back to the view the account stored, so a bookmark of the default feed reopened whatever
// view was stored since. The search text once the typing pauses, as the other pages' searches.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { useEffect } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NavigationProvider } from "../../features/navigation/NavigationProvider";
import { FeedFiltersProvider } from "./FeedFiltersProvider";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { qk } from "../../lib/queryKeys";
import { claimLocalData, LS } from "../../lib/storage";
import type { FeedFilters } from "../../lib/api";
const seen: { filters: FeedFilters | null; set: ((f: FeedFilters) => void) | null } = {
filters: null,
set: null,
};
function Probe() {
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
useEffect(() => {
seen.filters = filters;
seen.set = setFilters;
});
return null;
}
function mount() {
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 5, preferences: {} });
render(
<QueryClientProvider client={qc}>
<NavigationProvider>
<FeedFiltersProvider>
<Probe />
</FeedFiltersProvider>
</NavigationProvider>
</QueryClientProvider>,
);
}
const search = () => new URLSearchParams(window.location.search);
beforeEach(() => {
localStorage.clear();
claimLocalData(5);
});
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
describe("FeedFiltersProvider · the address", () => {
it("names the default view and order", () => {
window.history.replaceState(null, "", "/?page=feed");
mount();
expect([search().get("show"), search().get("sort")]).toEqual(["unwatched", "newest"]);
});
it("a bookmark of the default feed opens it, not the view the account stored since", () => {
localStorage.setItem(LS.filters, JSON.stringify({ show: "all", includeShorts: true }));
window.history.replaceState(null, "", "/?page=feed&show=unwatched&sort=newest");
mount();
expect(seen.filters!.show).toBe("unwatched");
expect(seen.filters!.includeShorts).toBe(false);
});
it("names the search once the typing pauses", async () => {
window.history.replaceState(null, "", "/?page=feed");
mount();
act(() => seen.set!({ ...seen.filters!, q: "lo" }));
act(() => seen.set!({ ...seen.filters!, q: "lofi" }));
expect(search().get("q")).toBeNull();
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
expect(search().get("q")).toBe("lofi");
});
});
+1
View File
@@ -19,6 +19,7 @@ export const feedModule: ModuleDef = {
Store: FeedStore, Store: FeedStore,
// Its filters, and the live YouTube search shown in its place (lib/ytSearchUrlState). // Its filters, and the live YouTube search shown in its place (lib/ytSearchUrlState).
urlKeys: [...FEED_KEYS, ...YT_SEARCH_KEYS], urlKeys: [...FEED_KEYS, ...YT_SEARCH_KEYS],
typedUrlKeys: ["q", "yt"],
playsVideos: true, playsVideos: true,
}; };
+2 -2
View File
@@ -14,7 +14,7 @@ import { formatBytes } from "../../lib/format";
import { dispatch, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap"; import { dispatch, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
import { useLayer } from "../../lib/layerStack"; import { useLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { FILES_VIEW_KEY } from "../../lib/filesUrlState"; import { filesOpenParams } from "../../lib/filesUrlState";
import { jobTitle } from "../../features/downloads/downloadTitle"; import { jobTitle } from "../../features/downloads/downloadTitle";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { ContextMenu } from "../../components/ui/ContextMenu"; import { ContextMenu } from "../../components/ui/ContextMenu";
@@ -129,7 +129,7 @@ export default function FilePreview({
title={<span className="block truncate">{title}</span>} title={<span className="block truncate">{title}</span>}
onClose={onClose} onClose={onClose}
maxWidth="max-w-6xl" maxWidth="max-w-6xl"
url={{ [FILES_VIEW_KEY]: String(file.id) }} url={filesOpenParams({ open: "view", id: file.id })}
headerActions={ headerActions={
<> <>
<IconButton <IconButton
@@ -809,6 +809,26 @@ describe("Files · a file the address names", () => {
// Said once: the address no longer names it, so a reload or a copied link does not say it again. // Said once: the address no longer names it, so a reload or a copied link does not say it again.
expect(here().has("fview")).toBe(false); expect(here().has("fview")).toBe(false);
}); });
// A list the cap cut cannot tell a deleted file from one beyond its end: it says the file is not
// among the ones listed, and keeps the link.
it("past the cap with no search or sort to widen it, says it is not listed and keeps the link", async () => {
// The case before closed a viewer: its step back lands a moment later, on whatever is current.
await new Promise((r) => setTimeout(r, 30));
window.history.replaceState(null, "", "/?page=files&fsort=none&fview=9999");
mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1));
mocks.folders = [YT];
mount();
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "files.arrival.notListed" }),
),
);
expect(mocks.notify).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "files.linkedGone" }),
);
expect(here().get("fview")).toBe("9999");
}, 30_000); // a full page of FILES_LIST_CAP rows
}); });
describe("Files · the view", () => { describe("Files · the view", () => {
+8 -5
View File
@@ -25,11 +25,12 @@ import { LS, ownKey } from "../../lib/storage";
import { useCardPager } from "../../lib/useCardPager"; import { useCardPager } from "../../lib/useCardPager";
import { useDebounced } from "../../lib/useDebounced"; import { useDebounced } from "../../lib/useDebounced";
import { useUrlMirror } from "../../lib/useUrlMirror"; import { useUrlMirror } from "../../lib/useUrlMirror";
import { dropFromAddress } from "../../lib/urlState"; import { clearParams } from "../../lib/urlState";
import { import {
FILES_PLAY_KEY, FILES_PLAY_KEY,
FILES_SORT_KEY, FILES_SORT_KEY,
FILES_VIEW_KEY, FILES_VIEW_KEY,
filesSortParams,
openFileFrom, openFileFrom,
} from "../../lib/filesUrlState"; } from "../../lib/filesUrlState";
import { useLiveQuery } from "../../lib/useLiveQuery"; import { useLiveQuery } from "../../lib/useLiveQuery";
@@ -231,7 +232,7 @@ export default function Files() {
setLinkedSort(undefined); setLinkedSort(undefined);
setPrefSort(next); setPrefSort(next);
}; };
useUrlMirror({ [FILES_SORT_KEY]: encodeSortParamOrNone(sort) }); useUrlMirror(filesSortParams(encodeSortParamOrNone(sort)));
const params = listParams(shown); const params = listParams(shown);
// Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own. // Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own.
const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 }); const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 });
@@ -286,15 +287,17 @@ export default function Files() {
const [viewing, setViewing] = useState<number | null>(null); const [viewing, setViewing] = useState<number | null>(null);
// A reload or a bookmark reopens the file the address names — in the viewer or the player — once // A reload or a bookmark reopens the file the address names — in the viewer or the player — once
// the list it is in has come (its folder, search and sort are in the address too). Once; a file the // the list it is in has come (its folder, search and sort are in the address too). Once; a file the
// list no longer holds is said, and taken off the address. // list no longer holds is said, and taken off the address — unless the cap cut the list, which then
// cannot tell a deleted file from one beyond its end (as an arrival's notice says): the link stays.
const [linkedFile] = useState(() => openFileFrom(new URLSearchParams(window.location.search))); const [linkedFile] = useState(() => openFileFrom(new URLSearchParams(window.location.search)));
const linkedDone = useRef(linkedFile == null); const linkedDone = useRef(linkedFile == null);
const listIn = wide ? wideIn && wideQ.data !== undefined : filesQ.data !== undefined; const listIn = wide ? wideIn && wideQ.data !== undefined : filesQ.data !== undefined;
const linkedRow = linkedFile && listIn ? rows.find((f) => f.id === linkedFile.id) : undefined; const linkedRow = linkedFile && listIn ? rows.find((f) => f.id === linkedFile.id) : undefined;
const openLinked = useEffectEvent((file: LibraryFile | undefined) => { const openLinked = useEffectEvent((file: LibraryFile | undefined) => {
if (!file) { if (!file && capped && !wide) notify({ level: "info", message: t("files.arrival.notListed") });
else if (!file) {
notify({ level: "warning", message: t("files.linkedGone") }); notify({ level: "warning", message: t("files.linkedGone") });
dropFromAddress([FILES_VIEW_KEY, FILES_PLAY_KEY]); clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY);
} else if (linkedFile?.open === "play") play(file); } else if (linkedFile?.open === "play") play(file);
else setViewing(file.id); else setViewing(file.id);
}); });
+16 -9
View File
@@ -2,7 +2,12 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage"; import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage";
import { useUrlMirror } from "../../lib/useUrlMirror"; import { useUrlMirror } from "../../lib/useUrlMirror";
import { useDebounced } from "../../lib/useDebounced"; import { useDebounced } from "../../lib/useDebounced";
import { FILES_FOLDER_KEY, FILES_ITEM_KEY, FILES_SEARCH_KEY } from "../../lib/filesUrlState"; import {
FILES_FOLDER_KEY,
FILES_ITEM_KEY,
FILES_SEARCH_KEY,
filesListParams,
} from "../../lib/filesUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage"; import { urlPage } from "../../features/navigation/urlPage";
import { import {
@@ -24,8 +29,8 @@ import {
// read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in // read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in
// main.tsx around <App/>. // main.tsx around <App/>.
// //
// The search lives in the address alone (`?fq=`): a reload or a bookmark opens it searched, and a trip // The search is named in the address (`?fq=`), so a reload or a bookmark opens it searched, and ends
// back to the page through the rail opens it unsearched. The view is remembered per account and // when the page is left: a trip back to the page through the rail opens it unsearched. The view is remembered per account and
// mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder. A // mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder. A
// `?folder=` in the address wins over the remembered one. A folder that no longer exists is the // `?folder=` in the address wins over the remembered one. A folder that no longer exists is the
// page's to fall back from (it knows the folder list). // page's to fall back from (it knows the folder list).
@@ -89,19 +94,21 @@ export function FilesProvider({ children }: { children: ReactNode }) {
); );
// Only ON the Files page, and only until the page has used it. // Only ON the Files page, and only until the page has used it.
const focus = page === "files" && focusRaw && focusRaw.token !== focusApplied ? focusRaw : null; const focus = page === "files" && focusRaw && focusRaw.token !== focusApplied ? focusRaw : null;
// Leaving the page ends the arrival: back on it later, nothing is rung or turned to. // Leaving the page ends the arrival — back on it later, nothing is rung or turned to — and the
// search: the page opens unsearched again.
if (page !== "files" && arrivedRaw) setArrivedRaw(null); if (page !== "files" && arrivedRaw) setArrivedRaw(null);
if (page !== "files" && search) setSearchState("");
const arrived = page === "files" ? arrivedRaw : null; const arrived = page === "files" ? arrivedRaw : null;
const shown = urlPage(nav) === "files"; const shown = urlPage(nav) === "files";
// Written once the typing pauses (one address-bar write per keystroke is churn a browser throttles); // Written once the typing pauses (one address-bar write per keystroke is churn a browser throttles);
// an arrival's cleared box at once. // an arrival's cleared box at once.
const typed = useDebounced(search.trim(), 300, focusRaw?.token); const typed = useDebounced(search.trim(), 300, focusRaw?.token);
useUrlMirror({ useUrlMirror(
[FILES_SEARCH_KEY]: shown && typed ? typed : null, filesListParams(
[FILES_FOLDER_KEY]: shown ? viewParam(view) : null, shown ? { search: typed, folder: viewParam(view), item: focus ? focus.id : null } : null,
[FILES_ITEM_KEY]: shown && focus ? String(focus.id) : null, ),
}); );
const state = useMemo<FilesState>( const state = useMemo<FilesState>(
() => ({ search, view, focus, arrived }), () => ({ search, view, focus, arrived }),
+2 -2
View File
@@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import Modal from "../../components/ui/Modal"; import Modal from "../../components/ui/Modal";
import { api, type DownloadJob } from "../../lib/api"; import { api, type DownloadJob } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { FILES_PLAY_KEY } from "../../lib/filesUrlState"; import { filesOpenParams } from "../../lib/filesUrlState";
import { jobTitle } from "../../features/downloads/downloadTitle"; import { jobTitle } from "../../features/downloads/downloadTitle";
import { exposeVideoToBackgroundPlay } from "../../lib/browserBackgroundPlay"; import { exposeVideoToBackgroundPlay } from "../../lib/browserBackgroundPlay";
import { useMediaLogRef } from "../../lib/mediaLog"; import { useMediaLogRef } from "../../lib/mediaLog";
@@ -97,7 +97,7 @@ export default function LibraryPlayer({
title={title} title={title}
onClose={onClose} onClose={onClose}
maxWidth="max-w-4xl" maxWidth="max-w-4xl"
url={{ [FILES_PLAY_KEY]: String(job.id) }} url={filesOpenParams({ open: "play", id: job.id })}
> >
{isAudio ? ( {isAudio ? (
<div className="flex flex-col items-center gap-4 py-2"> <div className="flex flex-col items-center gap-4 py-2">
+1
View File
@@ -18,4 +18,5 @@ export const filesModule: ModuleDef = {
maxWidth: "80rem", maxWidth: "80rem",
Store: FilesProvider, Store: FilesProvider,
urlKeys: FILES_KEYS, urlKeys: FILES_KEYS,
typedUrlKeys: ["fq"],
}; };
@@ -211,6 +211,32 @@ describe("Playlists — a player the address names", () => {
expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]); expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]);
expect(h.player!.onRemoveFromList).toBeUndefined(); expect(h.player!.onRemoveFromList).toBeUndefined();
}); });
it("plays alone over a playlist emptied since", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z");
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0));
mount({ 1: [] });
await waitFor(() => expect(h.player?.video.id).toBe("Z"));
expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]);
});
it("is given up when another playlist is picked before it opened, and leaves the address", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z");
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0));
// The linked playlist's read never answers: the opener is still waiting when the pick lands.
const { rerender } = mount({ 1: [vid("A", 0)], 2: [vid("B", 0)] });
vi.mocked(api.playlist).mockImplementation(async (id: number) =>
id === 1 ? new Promise<never>(() => {}) : (list(id, [vid("B", 0)]) as never),
);
qc.removeQueries({ queryKey: qk.playlist(1) });
h.selectedId = 2;
rerender();
await screen.findByText("Video B");
await new Promise((r) => setTimeout(r, 30));
expect(h.player).toBeNull();
expect(row).not.toHaveBeenCalled();
expect(new URLSearchParams(window.location.search).get("watch")).toBeNull();
});
}); });
describe("Playlists — the row the player is launched with", () => { describe("Playlists — the row the player is launched with", () => {
+9 -2
View File
@@ -583,14 +583,21 @@ function PlaylistDetail() {
}); });
// A reload or a bookmark of the player reopens it once this playlist is in, its view as the queue // A reload or a bookmark of the player reopens it once this playlist is in, its view as the queue
// again (the playlist and its view are in the address under it). A video the view does not show // again (the playlist and its view are in the address under it). A video the view does not show
// (filtered out, gone from the list) plays alone, and nothing removes it from a list. // (filtered out, gone from the list, the list emptied or out of reach) plays alone, and nothing
// removes it from a list. In: the order seeded from it (an empty one has none to wait for), or its
// read refused. Scoped to the playlist it was linked over: another one picked first gives it up.
const linkedListIn =
(detail != null && (detail.items.length === 0 || items.length > 0)) ||
(selectedId != null && detailQuery.isError) ||
(selectedId == null && listQuery.data?.length === 0);
useOpenLinkedPlayer( useOpenLinkedPlayer(
detail != null && items.length > 0, linkedListIn,
(id) => shown.find((v) => v.id === id), (id) => shown.find((v) => v.id === id),
(video, inList) => { (video, inList) => {
if (inList) openPlayer(video.id); if (inList) openPlayer(video.id);
else setPlaying({ video, queue: [video], index: 0, listId: selectedId, editable: false }); else setPlaying({ video, queue: [video], index: 0, listId: selectedId, editable: false });
}, },
selectedId,
); );
async function moveItemTo(video: Video, target: Playlist) { async function moveItemTo(video: Video, target: Playlist) {
@@ -5,6 +5,7 @@ import { useDebounced } from "../../lib/useDebounced";
import { useNavigation } from "../../features/navigation/navigationContext"; import { useNavigation } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage"; import { urlPage } from "../../features/navigation/urlPage";
import { readSelectedId } from "./playlistsInit"; import { readSelectedId } from "./playlistsInit";
import { playlistsParams } from "../../lib/playlistsUrlState";
import { import {
PlaylistsActionsContext, PlaylistsActionsContext,
PlaylistsStateContext, PlaylistsStateContext,
@@ -18,8 +19,8 @@ import {
// FeedFiltersProvider / NavigationProvider (split State + Actions contexts, one-line hooks; composed // FeedFiltersProvider / NavigationProvider (split State + Actions contexts, one-line hooks; composed
// in main.tsx around <App/>). // in main.tsx around <App/>).
// //
// The `search` term lives in the address alone (`?plq=`, a reload or a bookmark keeps it, a trip back // The `search` term is named in the address (`?plq=`, a reload or a bookmark keeps it) and ends when
// through the rail starts unsearched); the selected playlist is per-account persisted (LS.playlist) so // the page is left (a trip back through the rail starts unsearched); the selected playlist is per-account persisted (LS.playlist) so
// a reload returns you to it, and named in the address too. Neither is server-synced, and an account switch does a full reload, so init-from-storage is all the // a reload returns you to it, and named in the address too. Neither is server-synced, and an account switch does a full reload, so init-from-storage is all the
// hydration these need — no per-account load effect (unlike the server-backed feed filters). // hydration these need — no per-account load effect (unlike the server-backed feed filters).
// //
@@ -43,11 +44,10 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
// The search once the typing pauses: one address-bar write per keystroke is churn a browser throttles. // The search once the typing pauses: one address-bar write per keystroke is churn a browser throttles.
const nav = useNavigation(); const nav = useNavigation();
const shown = urlPage(nav) === "playlists"; const shown = urlPage(nav) === "playlists";
// Leaving the page ends the search: adjusted during the render that leaves it.
if (nav.page !== "playlists" && search) setSearchState("");
const typed = useDebounced(search.trim(), 300); const typed = useDebounced(search.trim(), 300);
useUrlMirror({ useUrlMirror(shown ? playlistsParams(selectedId, typed) : playlistsParams(null, ""));
pl: shown && selectedId != null ? String(selectedId) : null,
plq: shown && typed ? typed : null,
});
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]); const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
const actions = useMemo<PlaylistsActions>( const actions = useMemo<PlaylistsActions>(
+1
View File
@@ -19,5 +19,6 @@ export const playlistsModule: ModuleDef = {
Store: PlaylistsProvider, Store: PlaylistsProvider,
// The playlist shown (PlaylistsProvider, playlistsInit). // The playlist shown (PlaylistsProvider, playlistsInit).
urlKeys: PLAYLISTS_KEYS, urlKeys: PLAYLISTS_KEYS,
typedUrlKeys: ["plq"],
playsVideos: true, playsVideos: true,
}; };
+1
View File
@@ -19,4 +19,5 @@ export const plexModule: ModuleDef = {
maxWidth: "100rem", maxWidth: "100rem",
Store: PlexStore, Store: PlexStore,
urlKeys: [...PLEX_KEYS, ...PLEX_SEARCH_KEYS, PLEX_SUB_KEY], urlKeys: [...PLEX_KEYS, ...PLEX_SEARCH_KEYS, PLEX_SUB_KEY],
typedUrlKeys: ["pq"],
}; };
@@ -26,7 +26,7 @@ import { api, HttpError, type Me } from "../../lib/api";
import { displayName, roleKey } from "../../lib/api/account"; import { displayName, roleKey } from "../../lib/api/account";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { LS, useTabPersistedState } from "../../lib/storage"; import { LS, useTabPersistedState } from "../../lib/storage";
import { SETTINGS_TAB_KEY } from "../../lib/settingsTab"; import { SETTINGS_TAB_KEY, settingsTabParams } from "../../lib/settingsTab";
import { useUrlMirror } from "../../lib/useUrlMirror"; import { useUrlMirror } from "../../lib/useUrlMirror";
import Avatar from "../../components/ui/Avatar"; import Avatar from "../../components/ui/Avatar";
import { notify, type NotifSettings } from "../../lib/notifications"; import { notify, type NotifSettings } from "../../lib/notifications";
@@ -80,7 +80,7 @@ export default function SettingsPanel() {
: "appearance"; : "appearance";
// The address names the tab on screen, so a bookmark opens it (always written: without it, the page // The address names the tab on screen, so a bookmark opens it (always written: without it, the page
// opens on the tab this browser tab last showed). // opens on the tab this browser tab last showed).
useUrlMirror({ [SETTINGS_TAB_KEY]: tab }); useUrlMirror(settingsTabParams(tab));
return ( return (
<div className="p-4 max-w-3xl w-full mx-auto"> <div className="p-4 max-w-3xl w-full mx-auto">
+2 -2
View File
@@ -27,7 +27,7 @@ import { displayName } from "../../lib/api/account";
import { notify } from "../../lib/notifications"; import { notify } from "../../lib/notifications";
import { formatDateTime, relativeTime } from "../../lib/format"; import { formatDateTime, relativeTime } from "../../lib/format";
import { invalidateUserAccess } from "../../lib/adminUsers"; import { invalidateUserAccess } from "../../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY } from "../../lib/adminUsersTab"; import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY, usersTabParams } from "../../lib/adminUsersTab";
import { Badge } from "../../components/ui/Badge"; import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
@@ -89,7 +89,7 @@ export default function AdminUsers() {
]; ];
const active = tabs.some((x) => x.id === tab) ? tab : "roles"; const active = tabs.some((x) => x.id === tab) ? tab : "roles";
// The address names the tab on screen (always: see SettingsPanel). // The address names the tab on screen (always: see SettingsPanel).
useUrlMirror({ [USERS_TAB_URL_KEY]: active }); useUrlMirror(usersTabParams(active));
return ( return (
<> <>
{/* The tab bar is fixed chrome; the tab content scrolls under it. */} {/* The tab bar is fixed chrome; the tab content scrolls under it. */}