release: 0.113.0 (feature/e46-url-is-the-view)
This commit is contained in:
@@ -7,6 +7,20 @@ edit the release notes and re-run the generator.
|
||||
|
||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
## 0.113.0 — 2026-10-28
|
||||
|
||||
### New
|
||||
|
||||
- The address names what you see: tabs, list views, sorts and searches (the live YouTube search too), the YouTube player and the file viewer. A reload, a bookmark or a shared link opens the same view.
|
||||
- A link to a video reopens the player over its page once; Back closes it. A video the page no longer lists plays on its own and says so.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Back and Forward step through the views as you saw them, across dialogs, players and reloads: a reload under an open player no longer leaves a Back step that does nothing.
|
||||
- Signed out on a Plex page, the sign-in card could show a button with no text.
|
||||
- The channel page's address names the channel and its own view, not the page under it.
|
||||
- A reload during a Plex binge reopens the episode playing now, not the one it started from.
|
||||
|
||||
## 0.112.1 — 2026-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -115,6 +115,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||
| FE view state | the module's URL codec: its keys in `urlKeys` (`module.ts`), read and written by `lib/<id>UrlState.ts` through `useUrlMirror` (contract-held, `e2e/bookmark.spec.ts`) | `useState` / sessionStorage alone: a reload, a bookmark or a shared link loses the view |
|
||||
| FE a page's header content | the page renders it into the floating header itself (`PageHeader`, `ui/PageShell`) | a `page === …` branch in the shell's header, or a bridge carrying a page's state up to it |
|
||||
| FE module store | its definition's `Store` (`module.ts`), mounted at the root by `ModuleStores`; it reads no other module's store (contract-held) | a provider hand-nested in `main.tsx` |
|
||||
| FE saved views | the module's own store from `features/filters` (`createSavedViewsStore` + `useSavedViewsStore`) | one store switched by the active page — every visit elsewhere reset it |
|
||||
|
||||
@@ -249,6 +249,21 @@ def set_video_progress(
|
||||
return {"ok": True, "video_id": video_id, "position_seconds": position}
|
||||
|
||||
|
||||
@router.get("/videos/{video_id}/row")
|
||||
def get_video_row(
|
||||
video_id: str = Path(max_length=32),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""One catalog video as the feed lists it (its channel, this user's watch state and resume point,
|
||||
saved): what a player opened from an address starts from. The catalog only, so it costs nothing;
|
||||
every video a player can open (a list's row, a related one) is in it."""
|
||||
rows = queries.feed_rows_by_ids(db, user, [video_id])
|
||||
if not rows:
|
||||
raise not_found("video")
|
||||
return rows[0]
|
||||
|
||||
|
||||
@router.get("/videos/{video_id}")
|
||||
def get_video_detail(
|
||||
video_id: str,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""A catalog video as the feed lists it, the row a player opened from an address starts from. DB lane."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.reader.models import VideoState
|
||||
|
||||
|
||||
def _video(db, vid="v1"):
|
||||
db.add(Channel(id="UCo", title="Owner", handle="@owner"))
|
||||
db.flush()
|
||||
db.add(
|
||||
Video(
|
||||
id=vid,
|
||||
title="A talk",
|
||||
channel_id="UCo",
|
||||
published_at=datetime.now(timezone.utc),
|
||||
duration_seconds=3600,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_a_video_comes_as_the_feed_row_with_my_own_state(db, make_user, as_user):
|
||||
me, other = make_user(), make_user()
|
||||
_video(db)
|
||||
db.add(VideoState(user_id=me.id, video_id="v1", status="new", position_seconds=600))
|
||||
db.add(VideoState(user_id=other.id, video_id="v1", status="watched", position_seconds=90))
|
||||
db.commit()
|
||||
r = as_user(me).get("/api/videos/v1/row")
|
||||
assert r.status_code == 200, r.text
|
||||
row = r.json()
|
||||
assert (row["id"], row["title"], row["channel_title"]) == ("v1", "A talk", "Owner")
|
||||
assert (row["status"], row["position_seconds"]) == ("new", 600)
|
||||
assert row["watch_url"] == "https://www.youtube.com/watch?v=v1"
|
||||
|
||||
|
||||
def test_a_video_never_opened_has_no_state(db, make_user, as_user):
|
||||
_video(db)
|
||||
row = as_user(make_user()).get("/api/videos/v1/row").json()
|
||||
assert (row["status"], row["position_seconds"], row["saved"]) == ("new", 0, False)
|
||||
|
||||
|
||||
def test_a_video_outside_the_catalog_is_not_found(db, make_user, as_user):
|
||||
r = as_user(make_user()).get("/api/videos/nope/row")
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"]["code"] == "not_found"
|
||||
+16
-2
@@ -45,7 +45,18 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
|
||||
page renders into the floating header itself through `PageHeader`; the shell knows no page.
|
||||
2. `module.ts`: its `ModuleDef` (`lib/moduleDef.ts`): id, label keys, icon, an optional `gate`, the page as a
|
||||
`lazyWithPreload(() => import(…))`, and optionally a rail and a `Store`. Of its own folder it imports statically
|
||||
only its store (a `…Provider` / `…Store` file); the page and the rail load lazily.
|
||||
only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. `urlKeys` names every
|
||||
address-bar key its view writes (the URL is the view: a bookmark reopens it), its list kept in `lib/` beside
|
||||
its codec; the shell clears them on a page switch and opens a page without another page's keys.
|
||||
Every setting the page shows — a tab, a sort, a filter, a search, the item open in it — goes there: a codec
|
||||
in `lib/<id>UrlState.ts` (read the params into the view, write the view as updates, a default left out —
|
||||
or always written, when an address naming none falls back to a view remembered elsewhere), written with
|
||||
`useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through `useBackToClose`'s),
|
||||
always as the codec's result: a writer outside `lib/` computes no key of its own. Read back first on mount
|
||||
(a link wins over anything remembered). A key that carries typed text — a search box's, a column filter
|
||||
map with a text filter — is also in `typedUrlKeys` (the client log keeps what was typed out). Add its round
|
||||
trip to `e2e/bookmark.spec.ts`, tagged with the page (`{ tag: "@<id>" }`). Only what an address cannot promise to
|
||||
reopen stays out, said in its codec (Home's draw, `lib/homeUrlState`).
|
||||
3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's
|
||||
hooks). Another folder reaches the module through this file or not at all.
|
||||
4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in
|
||||
@@ -58,7 +69,10 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
|
||||
returns to `ui/FilterRail`; tsc names each table still missing the new id.
|
||||
|
||||
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), the `local/layers` lint rule (imports only go
|
||||
`moduleStores.contract.test.tsx` (no store reads another module), `moduleUrl.contract.test.ts` (each URL key
|
||||
belongs to one module and matches what its codec writes; a writer outside `lib/` is handed a literal or a `lib/`
|
||||
function's result, never a key of its own; no key literal written or read from the live query undeclared; every key
|
||||
sorted as typed or not; every page with keys in a tagged bookmark round trip), the `local/layers` lint rule (imports only go
|
||||
down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the
|
||||
i18n parity test.
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import {
|
||||
clickVisibleChannelLink,
|
||||
expectContentType,
|
||||
gotoPage,
|
||||
openApp,
|
||||
openFirstVideo,
|
||||
toggleContentType,
|
||||
} from "./helpers";
|
||||
|
||||
// The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW
|
||||
// browser context, so nothing this tab remembered (its history entries, sessionStorage, the view
|
||||
// storage it wrote) can stand in for what the address says. One round trip per page that names its
|
||||
// view, tagged with the page (moduleUrl.contract.test holds the tags to the module definitions),
|
||||
// plus the overlays the shell opens over any page (the channel page, the player) and the live
|
||||
// search. Not Plex: what it shows is the instance's own library, which a test stack may not have.
|
||||
|
||||
const STORAGE_STATE = "e2e/.auth/state.json";
|
||||
|
||||
/** The page's address, opened as a bookmark in a fresh context of the same account. */
|
||||
async function bookmarked(browser: Browser, page: Page, prepare?: (p: Page) => Promise<void>) {
|
||||
const url = page.url();
|
||||
const ctx = await browser.newContext({ storageState: STORAGE_STATE, locale: "en-US" });
|
||||
const fresh = await ctx.newPage();
|
||||
await prepare?.(fresh);
|
||||
await openApp(fresh, url);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
const tab = (p: Page, name: string) => p.getByRole("tab", { name, exact: true });
|
||||
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("the feed's filters", { tag: "@feed" }, async ({ page, browser }) => {
|
||||
await openApp(page, "/?page=feed");
|
||||
await toggleContentType(page, "includeLive");
|
||||
await expect(page).toHaveURL(/[?&]live=/);
|
||||
const fresh = await bookmarked(browser, page);
|
||||
await expectContentType(fresh, "includeLive", true);
|
||||
await fresh.context().close();
|
||||
});
|
||||
|
||||
test("Home's time budget", { tag: "@home" }, async ({ page, browser }) => {
|
||||
await openApp(page, "/?page=home");
|
||||
await page.getByRole("button", { name: "An evening", exact: true }).click();
|
||||
await expect(page).toHaveURL(/[?&]hb=long/);
|
||||
const fresh = await bookmarked(browser, page);
|
||||
await expect(fresh.getByRole("button", { name: "An evening", exact: true })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await fresh.context().close();
|
||||
});
|
||||
|
||||
test("a Settings tab", { tag: "@settings" }, async ({ page, browser }) => {
|
||||
await openApp(page, "/?page=feed");
|
||||
await gotoPage(page, "settings");
|
||||
await tab(page, "Notifications").click();
|
||||
const fresh = await bookmarked(browser, page);
|
||||
await expect(tab(fresh, "Notifications")).toHaveAttribute("aria-selected", "true");
|
||||
await fresh.context().close();
|
||||
});
|
||||
|
||||
test("an admin page's tab", { tag: "@users" }, async ({ page, browser }) => {
|
||||
await openApp(page, "/?page=feed");
|
||||
await gotoAdminPage(page, "users");
|
||||
await tab(page, "Access requests").click();
|
||||
const fresh = await bookmarked(browser, page);
|
||||
await expect(tab(fresh, "Access requests")).toHaveAttribute("aria-selected", "true");
|
||||
await fresh.context().close();
|
||||
});
|
||||
|
||||
test("a Configuration group", { tag: "@config" }, 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", { tag: "@activity" }, 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", { tag: "@playlists" }, 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",
|
||||
{ tag: "@channels" },
|
||||
async ({ page, browser }) => {
|
||||
await openApp(page, "/?page=feed");
|
||||
await gotoPage(page, "channels");
|
||||
await tab(page, "Hidden").click();
|
||||
await page.getByPlaceholder("Search channels…").fill("zz");
|
||||
await expect(page).toHaveURL(/zz/); // written once the typing settles
|
||||
const fresh = await bookmarked(browser, page);
|
||||
await expect(tab(fresh, "Hidden")).toHaveAttribute("aria-selected", "true");
|
||||
await expect(fresh.getByPlaceholder("Search channels…")).toHaveValue("zz");
|
||||
await fresh.context().close();
|
||||
},
|
||||
);
|
||||
|
||||
test("the Files search", { tag: "@files" }, async ({ page, browser }) => {
|
||||
await openApp(page, "/?page=feed");
|
||||
await gotoPage(page, "files");
|
||||
await page.getByPlaceholder("Search files…").fill("zz");
|
||||
await expect(page).toHaveURL(/zz/);
|
||||
const fresh = await bookmarked(browser, page);
|
||||
await expect(fresh.getByPlaceholder("Search files…")).toHaveValue("zz");
|
||||
await fresh.context().close();
|
||||
});
|
||||
|
||||
// A file of the test's own, uploaded for it and deleted after, and the account's sort put back:
|
||||
// the account is left as it was found.
|
||||
test(
|
||||
"the Files sort, and the file open in the viewer",
|
||||
{ tag: "@files" },
|
||||
async ({ page, browser }) => {
|
||||
await openApp(page, "/?page=feed");
|
||||
const NAME = "bookmark-note";
|
||||
// One a run cut short left behind goes first.
|
||||
const left = (await (await page.request.get("/api/files")).json()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`)))
|
||||
await page.request.delete(`/api/files/${String(f.id)}`);
|
||||
const up = await page.request.post("/api/files/upload", {
|
||||
headers: { "X-Upload-Filename": `${NAME}.txt` },
|
||||
data: Buffer.from("a note the bookmark reopens\n"),
|
||||
});
|
||||
expect(up.ok(), await up.text()).toBeTruthy();
|
||||
const { id } = (await up.json()) as { id: number };
|
||||
// The account's own sort, put back after: a header press cycles it (asc, desc, none), so the
|
||||
// case starts from none, as the address names it, whatever an earlier run left.
|
||||
const me = (await (await page.request.get("/api/me")).json()) as {
|
||||
preferences?: Record<string, unknown>;
|
||||
};
|
||||
const ownSort = me.preferences?.filesSort ?? null;
|
||||
try {
|
||||
await openApp(page, "/?page=files&fsort=none");
|
||||
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}`);
|
||||
await page.request.put("/api/me/preferences", { data: { filesSort: ownSort } });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
|
||||
await openApp(page, "/?page=feed");
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
expect(await clickVisibleChannelLink(page)).toBe(true);
|
||||
const title = await page.getByTestId("channel-title").textContent();
|
||||
await tab(page, "About").click();
|
||||
const fresh = await bookmarked(browser, page);
|
||||
await expect(fresh.getByTestId("channel-title")).toHaveText(title ?? "");
|
||||
await expect(tab(fresh, "About")).toHaveAttribute("aria-selected", "true");
|
||||
await fresh.context().close();
|
||||
});
|
||||
|
||||
test("the player and the video it plays", async ({ page, browser }) => {
|
||||
await openApp(page, "/?page=feed");
|
||||
await openFirstVideo(page);
|
||||
await expect(page).toHaveURL(/[?&]watch=/);
|
||||
const watch = new URL(page.url()).searchParams.get("watch");
|
||||
const fresh = await bookmarked(browser, page);
|
||||
await expect(fresh.getByTestId("player-modal")).toBeVisible();
|
||||
expect(new URL(fresh.url()).searchParams.get("watch")).toBe(watch);
|
||||
await fresh.context().close();
|
||||
});
|
||||
|
||||
// The search itself never reaches YouTube here: the answer is the server's shape, served by the
|
||||
// test, and the request it answers is what the address named. 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",
|
||||
{ tag: "@feed" },
|
||||
async ({ page, browser }) => {
|
||||
const answer = async (p: Page, seen: string[]) => {
|
||||
await p.route("**/api/me", async (route) => {
|
||||
const me = (await (await route.fetch()).json()) as Record<string, unknown>;
|
||||
return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } });
|
||||
});
|
||||
await p.route("**/api/search/youtube?**", (route) => {
|
||||
seen.push(route.request().url());
|
||||
return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } });
|
||||
});
|
||||
};
|
||||
await answer(page, []);
|
||||
await openApp(page, "/?page=feed");
|
||||
await page.getByPlaceholder("Search your subscriptions…").fill("lofi");
|
||||
await page.getByRole("button", { name: "Search YouTube", exact: true }).click();
|
||||
await expect(page.getByText("YouTube results for “lofi”")).toBeVisible();
|
||||
await expect(page).toHaveURL(/[?&]yt=lofi/); // named as it opens, not only once the count is set
|
||||
await page.getByRole("button", { name: "40", exact: true }).click();
|
||||
await expect(page).toHaveURL(/[?&]ytn=40/);
|
||||
|
||||
const seen: string[] = [];
|
||||
const fresh = await bookmarked(browser, page, (p) => answer(p, seen));
|
||||
await expect(fresh.getByText("YouTube results for “lofi”")).toBeVisible();
|
||||
await expect(fresh.getByRole("button", { name: "40", exact: true })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect.poll(() => seen.length).toBeGreaterThan(0);
|
||||
const asked = new URL(seen[0]!).searchParams;
|
||||
expect([asked.get("q"), asked.get("limit")]).toEqual(["lofi", "40"]);
|
||||
await fresh.context().close();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -898,7 +898,8 @@ export default tseslint.config(
|
||||
plugins: { playwright },
|
||||
rules: {
|
||||
"playwright/no-focused-test": "error",
|
||||
"playwright/no-skipped-test": "error",
|
||||
// fixme too: a test Playwright never runs, which the bookmark net would still count.
|
||||
"playwright/no-skipped-test": ["error", { disallowFixme: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -65,6 +65,7 @@ export default function Modal({
|
||||
placement = "center",
|
||||
initialFocus,
|
||||
headerActions,
|
||||
url,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
initialFocus: ModalFocus;
|
||||
@@ -81,10 +82,14 @@ export default function Modal({
|
||||
placement?: "center" | "drawer" | "drawer-start" | "top";
|
||||
/** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */
|
||||
headerActions?: ReactNode;
|
||||
/** What the dialog names in the address while it is open, when it is part of the view (a viewer, a
|
||||
* player): written into its own history entry, so a reload or a bookmark reopens it and Back lands
|
||||
* on the address without it (lib/history). */
|
||||
url?: Record<string, string | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||
useBackToClose(onClose);
|
||||
useBackToClose(onClose, url);
|
||||
// ONE container element for the dialog's whole life, re-parented between <body> and the fullscreen
|
||||
// element — and attached before the children commit, so an `autoFocus` child
|
||||
// still gets the focus. Both rules live in the hook; `ui/AnchoredPopover` shares it.
|
||||
|
||||
@@ -10,8 +10,9 @@ import { MEDIA_CHOSEN } from "./mediaChrome";
|
||||
// Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px:
|
||||
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
|
||||
// it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with
|
||||
// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings, Stats):
|
||||
// a reload keeps the user where they were, and a fresh tab opens on the first sub-tab. Not every
|
||||
// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings) and names it in
|
||||
// the address: a reload keeps the user where they were, a link opens its tab, and a fresh tab opens on
|
||||
// the first sub-tab. Not every
|
||||
// caller: the channel manager's view is account-wide (ChannelsProvider), and Downloads, a channel page
|
||||
// and the player settings start on their first tab each time.
|
||||
//
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
[
|
||||
{
|
||||
"version": "0.113.0",
|
||||
"date": "2026-10-28",
|
||||
"new": [
|
||||
"The address names what you see: tabs, list views, sorts and searches (the live YouTube search too), the YouTube player and the file viewer. A reload, a bookmark or a shared link opens the same view.",
|
||||
"A link to a video reopens the player over its page once; Back closes it. A video the page no longer lists plays on its own and says so."
|
||||
],
|
||||
"fixed": [
|
||||
"Back and Forward step through the views as you saw them, across dialogs, players and reloads: a reload under an open player no longer leaves a Back step that does nothing.",
|
||||
"Signed out on a Plex page, the sign-in card could show a button with no text.",
|
||||
"The channel page's address names the channel and its own view, not the page under it.",
|
||||
"A reload during a Plex binge reopens the episode playing now, not the one it started from."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.112.1",
|
||||
"date": "2026-10-27",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
[
|
||||
{
|
||||
"version": "0.113.0",
|
||||
"date": "2026-10-28",
|
||||
"new": [
|
||||
"A cím megnevezi, amit látsz: a füleket, a listanézeteket, a rendezést és a kereséseket (az élő YouTube-keresést is), a YouTube-lejátszót és a fájlnézőt. Újratöltés, könyvjelző vagy megosztott link ugyanazt a nézetet nyitja meg.",
|
||||
"Egy videóra mutató link egyszer újranyitja a lejátszót az oldala fölött, a Vissza bezárja. Ha az oldal már nem listázza a videót, a lejátszó csak azt az egyet játssza le, és ezt jelzi is."
|
||||
],
|
||||
"fixed": [
|
||||
"A Vissza és az Előre úgy lép végig a nézeteken, ahogy láttad őket, párbeszédablakokon, lejátszókon és újratöltésen át: nyitott lejátszó alatti újratöltés után nem marad semmit sem csináló Vissza-lépés.",
|
||||
"Plex-oldalon kijelentkezve a bejelentkező kártyán felirat nélküli gomb jelenhetett meg.",
|
||||
"A csatornaoldal címe a csatornát és annak saját nézetét nevezi meg, nem az alatta lévő oldalt.",
|
||||
"Plexben sorozatnézés közben az újratöltés az épp futó epizódot nyitja meg, nem azt, amelyikkel kezdted."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.112.1",
|
||||
"date": "2026-10-27",
|
||||
|
||||
@@ -27,6 +27,8 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
|
||||
import { NavigationProvider } from "./NavigationProvider";
|
||||
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||
import { urlPage } from "./urlPage";
|
||||
import { channelUrl } from "../../lib/urlState";
|
||||
import { useBackToClose } from "../../lib/history";
|
||||
import { PageHeading } from "../../components/ui/Headings";
|
||||
|
||||
@@ -167,6 +169,19 @@ describe("NavigationProvider · setPage's same-page guard", () => {
|
||||
expect(window.location.search).toBe("?page=plex&keep=1");
|
||||
});
|
||||
|
||||
it("leaves the keys of the modules it does not list by hand behind too: Files and the feed's", () => {
|
||||
// Each module declares its keys (`urlKeys`), and the switch clears the union of them: a key
|
||||
// missing from a hand-kept list rode along onto the next page's entry.
|
||||
window.history.replaceState(
|
||||
{ sfPage: "files" },
|
||||
"",
|
||||
"/?page=files&folder=all&item=5&q=music&sort=oldest&keep=1",
|
||||
);
|
||||
const { result } = render();
|
||||
act(() => result.current.setPage("plex"));
|
||||
expect(window.location.search).toBe("?page=plex&keep=1");
|
||||
});
|
||||
|
||||
it("pushes nothing when it is already that page", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setPage("plex"));
|
||||
@@ -195,6 +210,93 @@ describe("NavigationProvider · setPage's same-page guard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · a link carrying another page's keys", () => {
|
||||
it("opens with only its own page's keys: what the page does not show, its address does not say", () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/?page=activity&at=logs&folder=all&ctab=hidden&q=music&pscope=movie&pl=3&keep=1",
|
||||
);
|
||||
render();
|
||||
expect(window.location.search).toBe("?page=activity&at=logs&keep=1");
|
||||
});
|
||||
|
||||
it("keeps the channel page open over any page", () => {
|
||||
window.history.replaceState({}, "", "/?page=stats&chan=UC1&folder=all");
|
||||
const { result } = render();
|
||||
expect(window.location.search).toBe("?page=stats&chan=UC1");
|
||||
expect(result.current.channelView?.id).toBe("UC1");
|
||||
});
|
||||
|
||||
it("opens a channel link with only the channel page's keys, even over the page they belong to", () => {
|
||||
window.history.replaceState({}, "", "/?q=music&sort=oldest&chan=UC1&chtab=about&chtypes=ns");
|
||||
render();
|
||||
expect(window.location.search).toBe("?chan=UC1&chtab=about&chtypes=ns&page=feed");
|
||||
});
|
||||
});
|
||||
|
||||
// The channel page has an address of its own (the owner, 2026-10-05): it used to take the feed's
|
||||
// filters along (`?q=music&sort=oldest&chan=…`), naming a sort the channel did not show.
|
||||
describe("NavigationProvider · the channel page's address", () => {
|
||||
it("names only the channel; the page under it keeps its own address for Back", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&sort=oldest");
|
||||
const { result } = render();
|
||||
act(() => result.current.openChannel("UC1", "One"));
|
||||
expect(window.location.search).toBe("?page=feed&chan=UC1");
|
||||
act(() => window.history.back());
|
||||
await vi.waitFor(() => expect(result.current.channelView).toBeNull());
|
||||
expect(window.location.search).toBe("?q=music&sort=oldest&page=feed");
|
||||
});
|
||||
|
||||
it("says no page owns the address while the channel stands over it", () => {
|
||||
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
|
||||
const { result } = render();
|
||||
expect(result.current.page).toBe("plex");
|
||||
expect(urlPage(result.current)).toBe("plex");
|
||||
act(() => result.current.openChannel("UC1", "One"));
|
||||
expect(urlPage(result.current)).toBeNull();
|
||||
});
|
||||
|
||||
it("another channel opened from it starts fresh", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.openChannel("UC1", "One"));
|
||||
window.history.replaceState(window.history.state, "", "/?chan=UC1&chtab=about&chsort=oldest");
|
||||
act(() => result.current.openChannel("UC2", "Two"));
|
||||
expect(window.location.search).toBe("?chan=UC2");
|
||||
});
|
||||
|
||||
it("a page switch takes the channel page's keys with it", () => {
|
||||
window.history.replaceState({}, "", "/?chan=UC1&chtab=about&chsort=oldest&chtypes=n");
|
||||
const { result } = render();
|
||||
act(() => result.current.setPage("stats"));
|
||||
expect(window.location.search).toBe("?page=stats");
|
||||
});
|
||||
|
||||
it("closing it in place takes its keys with it", () => {
|
||||
window.history.replaceState({}, "", "/?chan=UC1&chtab=about&chtypes=n");
|
||||
const { result } = render();
|
||||
stubNavigation([false]);
|
||||
act(() => result.current.closeChannel());
|
||||
expect(window.location.search).toBe("?page=feed");
|
||||
});
|
||||
});
|
||||
|
||||
// The YouTube player opens over any page and names itself in its own entry (lib/playerUrlState).
|
||||
describe("NavigationProvider · the player's address", () => {
|
||||
it("a page switch takes a player the address still names with it", () => {
|
||||
window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related");
|
||||
const { result } = render();
|
||||
act(() => result.current.setPage("stats"));
|
||||
expect(window.location.search).toBe("?page=stats");
|
||||
});
|
||||
|
||||
it("a channel link names no player: opening the channel closes it", () => {
|
||||
window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related");
|
||||
render();
|
||||
expect(channelUrl("UC1")).toBe("/?page=feed&chan=UC1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · Back", () => {
|
||||
// A tab that had a since-removed module open, reloaded onto the new build: its older history
|
||||
// entries still name that page, and Back lands on one of them.
|
||||
@@ -208,7 +310,7 @@ describe("NavigationProvider · Back", () => {
|
||||
expect(result.current.page).toBe("feed");
|
||||
// …and the entry says so too, so the address bar and a later Feed click agree with the screen.
|
||||
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
||||
expect(window.location.search).toBe("?keep=1");
|
||||
expect(window.location.search).toBe("?page=feed&keep=1");
|
||||
});
|
||||
|
||||
it("lands where a retired page's content went, and the entry names that page", () => {
|
||||
@@ -325,7 +427,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
|
||||
window.history.replaceState(null, "", "/?page=scheduler&keep=1");
|
||||
const { result } = render();
|
||||
expect(result.current.page).toBe("feed");
|
||||
expect(window.location.search).toBe("?keep=1");
|
||||
expect(window.location.search).toBe("?page=feed&keep=1");
|
||||
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
||||
});
|
||||
|
||||
@@ -361,7 +463,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
|
||||
qc.setQueryData(qk.me(), member);
|
||||
});
|
||||
await settle();
|
||||
expect(window.location.search).toBe("");
|
||||
expect(window.location.search).toBe("?page=feed");
|
||||
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
||||
expect(getAccountRaw(PAGE_KEY)).toBe("feed");
|
||||
act(() => {
|
||||
@@ -381,7 +483,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
|
||||
});
|
||||
await settle();
|
||||
expect(result.current.channelView).toBeNull();
|
||||
expect(window.location.search).toBe("");
|
||||
expect(window.location.search).toBe("?page=feed");
|
||||
expect(window.history.state?._chan).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload under a dialog that does not reopen (About) loads on the dialog's entry, its overlay mark
|
||||
// still on it. A view rewritten in place there (the search refined) is another view: the next overlay
|
||||
// pushes its own entry over it rather than taking this one over — taken over, closing it stepped back
|
||||
// onto the entry below, and the screen went back to the search before the refine. Its own file: the
|
||||
// entry is read as the history module loads, so it is set up before the import.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
describe("a view rewritten in place on the entry a reload under a dialog started from", () => {
|
||||
it("is where an overlay over it closes to", async () => {
|
||||
window.history.replaceState({ sfPage: "feed", _yt: "dogs" }, "", "/?page=feed&yt=dogs");
|
||||
window.history.pushState(
|
||||
{ sfPage: "feed", _yt: "dogs", _ov: 1, _ovd: 1 },
|
||||
"",
|
||||
"/?page=feed&yt=dogs",
|
||||
);
|
||||
const { NavigationProvider } = await import("./NavigationProvider");
|
||||
const { useNavigation, useNavigationActions } = await import("./navigationContext");
|
||||
const { useBackToClose } = await import("../../lib/history");
|
||||
const { qk } = await import("../../lib/queryKeys");
|
||||
const { claimLocalData } = await import("../../lib/storage");
|
||||
const qc = new QueryClient();
|
||||
qc.setQueryData(qk.me(), { id: 1, role: "admin" });
|
||||
claimLocalData(1);
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<NavigationProvider>{children}</NavigationProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const nav = renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
|
||||
act(() => nav.result.current.enterYtSearch("cats"));
|
||||
|
||||
const depth = window.history.length;
|
||||
const overlay = renderHook(() => useBackToClose(() => {}));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(window.history.length).toBe(depth + 1);
|
||||
|
||||
await act(async () => {
|
||||
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||
overlay.unmount();
|
||||
await landed;
|
||||
});
|
||||
expect(new URLSearchParams(window.location.search).get("yt")).toBe("cats");
|
||||
expect(nav.result.current.ytSearch).toBe("cats");
|
||||
}, 30_000); // the provider's whole import graph loads inside the case (see the header)
|
||||
});
|
||||
@@ -2,8 +2,13 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
|
||||
import { useLatest } from "../../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState";
|
||||
import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
|
||||
import {
|
||||
channelUrl,
|
||||
resolvePage,
|
||||
retiredParams,
|
||||
urlWithParams,
|
||||
type Page,
|
||||
} from "../../lib/urlState";
|
||||
import { useDocumentObjectTitle } from "../../lib/documentTitle";
|
||||
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
|
||||
import {
|
||||
@@ -12,8 +17,17 @@ import {
|
||||
nextNavId,
|
||||
notifyNav,
|
||||
rememberNavPosition,
|
||||
replaceView,
|
||||
} from "../../lib/history";
|
||||
import { pageTitleKey, reachablePage } from "./moduleRegistry";
|
||||
import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry";
|
||||
import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState";
|
||||
import { PLAYER_KEYS } from "../../lib/playerUrlState";
|
||||
import {
|
||||
DEFAULT_YT_COUNT,
|
||||
ytCountFrom,
|
||||
ytSearchFrom,
|
||||
ytSearchParams,
|
||||
} from "../../lib/ytSearchUrlState";
|
||||
import {
|
||||
channelViewFrom,
|
||||
loadInitialPage,
|
||||
@@ -37,24 +51,35 @@ import {
|
||||
//
|
||||
// The contexts, their value types and the hooks live in navigationContext.ts.
|
||||
|
||||
/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */
|
||||
/** The address bar of `page`'s root: the page — the feed too: a bare address is the landing (Home, or
|
||||
* the remembered page), so a feed left unnamed reloaded or bookmarked as another page — no channel or
|
||||
* player over it, and every module's own keys cleared — a page switch resets to that module's root,
|
||||
* and its store mirrors what it holds back in. */
|
||||
function pageRootParams(page: Page): Record<string, string | null> {
|
||||
return {
|
||||
page: page === "feed" ? null : page,
|
||||
chan: null,
|
||||
v: null,
|
||||
pl: null,
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
ctab: null,
|
||||
csort: null,
|
||||
cf: null,
|
||||
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
|
||||
page,
|
||||
...closedChannelParams(),
|
||||
...nulls(PLAYER_KEYS),
|
||||
...nulls(moduleUrlKeys()),
|
||||
};
|
||||
}
|
||||
|
||||
/** The address of the page under a live search, the search ended: no search, player or channel. */
|
||||
const searchEndParams = (): Record<string, string | null> => ({
|
||||
...closedChannelParams(),
|
||||
...nulls(PLAYER_KEYS),
|
||||
...ytSearchParams(null, DEFAULT_YT_COUNT),
|
||||
});
|
||||
|
||||
const nulls = (keys: readonly string[]) => Object.fromEntries(keys.map((k) => [k, null]));
|
||||
|
||||
/** No channel page: neither `chan` nor its view's keys, which the page itself writes (ChannelPage) —
|
||||
* a closed channel leaves none behind. (An opened one gets its address from `channelUrl`.) */
|
||||
const closedChannelParams = (): Record<string, string | null> => ({
|
||||
chan: null,
|
||||
...nulls(CHANNEL_PAGE_KEYS),
|
||||
});
|
||||
|
||||
export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
|
||||
@@ -66,18 +91,30 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// and the address bar (stamped from it below) agree.
|
||||
const me = useMe();
|
||||
const page = reachablePage(chosenPage, me);
|
||||
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
|
||||
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
|
||||
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
|
||||
// Back steps player → search → feed instead of the search vanishing without a history entry.
|
||||
const [ytSearch, setYtSearch] = useState<string | null>(() => {
|
||||
const st = window.history.state;
|
||||
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
|
||||
});
|
||||
// The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState)
|
||||
// so a reload or a link opens it again; its own history entry, so Back steps player → search → feed.
|
||||
// Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was
|
||||
// asked to search for (sent from the search box, or "Search YouTube" pressed), on the entries
|
||||
// it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or
|
||||
// a Forward onto a search — runs only when it costs no quota (Feed decides).
|
||||
const [ytSearch, setYtSearch] = useState<string | null>(() =>
|
||||
page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null,
|
||||
);
|
||||
const [ytCount, setYtCountState] = useState(() =>
|
||||
ytCountFrom(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
const [ytAsked, setYtAsked] = useState<string | null>(null);
|
||||
const ytSearchRef = useLatest(ytSearch);
|
||||
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
|
||||
// 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
|
||||
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
|
||||
// it, and a bookmarked ?chan link opens it. Reload of an in-app entry reads history.state._chan; a
|
||||
// fresh bookmark has only the ?chan param.
|
||||
const [channelView, setChannelView] = useState<ChannelView | null>(() =>
|
||||
channelViewFrom(window.history.state, window.location.search),
|
||||
);
|
||||
@@ -107,31 +144,36 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
closeOverlaysThenNavigate(() => {
|
||||
// A channel page overlays the content column, so a nav click must close it even when the
|
||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||
if (next === pageRef.current && !channelViewRef.current) {
|
||||
// So must a live search on it, though that press ends only the search: the page's view stays.
|
||||
const samePage = next === pageRef.current && !channelViewRef.current;
|
||||
if (samePage && ytSearchRef.current == null) {
|
||||
then?.();
|
||||
return;
|
||||
}
|
||||
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);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
|
||||
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
|
||||
// root, and the address bar reflects the page (feed is implicit) while clearing the
|
||||
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
|
||||
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left
|
||||
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to
|
||||
// their page. The item it was opened from is still focused: the state setters above have not
|
||||
// re-rendered anything yet.
|
||||
// root, and the address bar reflects the page (the feed too) while clearing `chan` and
|
||||
// every module's own keys (pageRootParams) — a page switch resets to that module's root, and
|
||||
// each module's store mirrors its state back in on its page. The item it was opened from is
|
||||
// still focused: the state setters above have not re-rendered anything yet.
|
||||
// The press on a search over its own page ends only the search: the page's view is the one
|
||||
// on screen and stays named — its store, unchanged, writes nothing back in.
|
||||
rememberNavPosition();
|
||||
window.history.pushState(
|
||||
{ sfPage: next, sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams(pageRootParams(next)),
|
||||
urlWithParams(samePage ? searchEndParams() : pageRootParams(next)),
|
||||
);
|
||||
notifyNav();
|
||||
then?.();
|
||||
}),
|
||||
[pageRef, channelViewRef],
|
||||
[pageRef, channelViewRef, ytSearchRef, endYtSearch],
|
||||
);
|
||||
|
||||
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
|
||||
@@ -139,10 +181,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// inside one.
|
||||
const openChannel = useCallback((id: string, name?: string) => {
|
||||
closeOverlaysThenNavigate(() => {
|
||||
const url = urlWithParams({ chan: id });
|
||||
// The channel's address names the channel alone: the page under it keeps its own keys on its
|
||||
// own entry, where Back finds them, and writes none while the channel stands over it (urlPage).
|
||||
const url = channelUrl(id);
|
||||
if (window.history.state?._chan) {
|
||||
const st = window.history.state;
|
||||
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||
replaceView({ ...st, _chan: id, _chanName: name ?? null }, url);
|
||||
} else {
|
||||
const st = rememberNavPosition();
|
||||
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||
@@ -157,36 +201,67 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// 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
|
||||
// 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);
|
||||
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
|
||||
window.history.replaceState(st, "", urlWithParams({ chan: null }));
|
||||
replaceView(
|
||||
st,
|
||||
urlWithParams({
|
||||
...closedChannelParams(),
|
||||
...ytSearchParams(ytSearchRef.current, ytCountRef.current),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
}, [ytSearchRef, ytCountRef]);
|
||||
|
||||
const enterYtSearch = useCallback((q: string) => {
|
||||
setYtSearch(q);
|
||||
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
|
||||
// once the results resolve, so a reload only restores a confirmed scrape-sourced search.
|
||||
const { _ytScrape: _drop, ...st } = window.history.state || {};
|
||||
if (st._yt)
|
||||
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
|
||||
else {
|
||||
// New sub-view entry.
|
||||
const { _ytScrape: _dropToo, ...left } = rememberNavPosition();
|
||||
window.history.pushState({ ...left, sfPage: "feed", _yt: q }, "");
|
||||
setYtAsked(q);
|
||||
const st = window.history.state ?? {};
|
||||
const here = new URLSearchParams(window.location.search);
|
||||
if (st._yt || ytSearchFrom(here) != null) {
|
||||
// Refine the search on screen, in place, at the count it shows (one a link opened too: it
|
||||
// is the first entry of this document, so its new `_yt` mark has nothing of ours below it).
|
||||
replaceView({ ...st, _yt: q }, urlWithParams(ytSearchParams(q, ytCountFrom(here))));
|
||||
} else {
|
||||
// New sub-view entry, at the default count — ours (`_ytOwn`): leaving it steps back off it.
|
||||
setYtCountState(DEFAULT_YT_COUNT);
|
||||
const left = rememberNavPosition();
|
||||
window.history.pushState(
|
||||
{ ...left, sfPage: "feed", _yt: q, _ytOwn: 1 },
|
||||
"",
|
||||
urlWithParams(ytSearchParams(q, DEFAULT_YT_COUNT)),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The press that runs the search on screen, where it did not run by itself.
|
||||
const askYtSearch = useCallback(() => setYtAsked(ytSearchRef.current), [ytSearchRef]);
|
||||
|
||||
const setYtCount = useCallback(
|
||||
(n: number) => {
|
||||
setYtCountState(n);
|
||||
replaceView(
|
||||
window.history.state ?? {},
|
||||
urlWithParams(ytSearchParams(ytSearchRef.current, n)),
|
||||
);
|
||||
},
|
||||
[ytSearchRef],
|
||||
);
|
||||
|
||||
const exitYtSearch = useCallback(() => {
|
||||
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
|
||||
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
|
||||
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
|
||||
// one — a search a link or a reload opened is marked (`_yt`) but was pushed by nobody: below it is
|
||||
// another site, or nothing — or there is no entry of this document below it (a full tab, see
|
||||
// backStaysInDocument).
|
||||
if (window.history.state?._ytOwn && backStaysInDocument()) window.history.back();
|
||||
else {
|
||||
setYtSearch(null);
|
||||
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {};
|
||||
window.history.replaceState(st, "");
|
||||
endYtSearch();
|
||||
const { _yt: _q, _ytOwn: _own, ...st } = window.history.state ?? {};
|
||||
replaceView(st, urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT)));
|
||||
}
|
||||
}, []);
|
||||
}, [endYtSearch]);
|
||||
|
||||
// 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
|
||||
@@ -196,7 +271,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
|
||||
if (pageSearchWasOn !== page) {
|
||||
setPageSearchWasOn(page);
|
||||
if (page !== "feed") setYtSearch(null);
|
||||
if (page !== "feed") endYtSearch();
|
||||
}
|
||||
|
||||
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
|
||||
@@ -233,25 +308,33 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
|
||||
// the popstate handler only needs to read history.state.
|
||||
useEffect(() => {
|
||||
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
|
||||
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
|
||||
// are always kept so a channel page survives F5.
|
||||
// The entry of a search the address names carries its `_yt` mark, whichever way it opened (sent
|
||||
// from the box, a reload, a link): a channel opened from its results reads the search under it
|
||||
// from that mark. A mark left without one (an older build's entry) goes. _chan/_chanName are
|
||||
// always kept so a channel page survives F5.
|
||||
const st = window.history.state || {};
|
||||
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
|
||||
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
|
||||
// ?chan / ?v / ?pl / filter params the loaders already read.
|
||||
// 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 keeps the
|
||||
// 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
|
||||
// part of the page that replaced it (an `at` tab).
|
||||
const linked = new URLSearchParams(window.location.search).get("page");
|
||||
const remembered = getAccountRaw(PAGE_KEY);
|
||||
// A link can carry another page's keys (hand-edited, or written by an older build): the page
|
||||
// opens without them, so its address names only what it shows — and a channel page open over it
|
||||
// shows none of the page's own (an older build's channel links carried the feed's filters).
|
||||
const shown = channelViewFrom(st, window.location.search);
|
||||
const url = urlWithParams({
|
||||
...nulls(shown ? moduleUrlKeys() : foreignUrlKeys(mountPage)),
|
||||
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
|
||||
page: mountPage === "feed" ? null : mountPage,
|
||||
page: mountPage,
|
||||
});
|
||||
if (st._yt && st._ytScrape) {
|
||||
window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
|
||||
const named =
|
||||
mountPage === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null;
|
||||
if (named != null) {
|
||||
window.history.replaceState({ ...st, _yt: named, sfPage: mountPage }, "", url);
|
||||
} else {
|
||||
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
||||
const { _yt: _staleYt, _ytScrape: _staleScrape, _ytOwn: _staleOwn, ...rest } = st;
|
||||
window.history.replaceState({ ...rest, sfPage: mountPage }, "", url);
|
||||
}
|
||||
function onPop(e: PopStateEvent) {
|
||||
@@ -267,13 +350,22 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, sfPage: p },
|
||||
"",
|
||||
urlWithParams({ ...retiredParams(sf), page: p === "feed" ? null : p }),
|
||||
urlWithParams({ ...retiredParams(sf), page: p }),
|
||||
);
|
||||
setPageState(p);
|
||||
setAccountRaw(PAGE_KEY, p);
|
||||
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
|
||||
// restores or clears them to match the entry we landed on.
|
||||
setYtSearch((e.state?._yt as string) ?? null);
|
||||
// The YouTube search is read from the address the browser restored, so Back/Forward opens or
|
||||
// clears it to match the entry we landed on. One landed on without it has ended: a Forward back
|
||||
// onto it is not asked for, and an API search waits for a press.
|
||||
// A channel page opened from its results stands over it: its entry carries the search's mark
|
||||
// (its address does not), and the search stays under it — closing a player over that channel
|
||||
// lands there, and is no leaving of the search.
|
||||
const here = new URLSearchParams(window.location.search);
|
||||
const under = e.state?._chan && typeof e.state._yt === "string" ? e.state._yt : null;
|
||||
const landed = p === "feed" ? (ytSearchFrom(here) ?? under) : null;
|
||||
setYtSearch(landed);
|
||||
if (landed == null) setYtAsked(null);
|
||||
setYtCountState(ytCountFrom(here));
|
||||
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
|
||||
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
|
||||
// popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
|
||||
@@ -285,12 +377,26 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
}, [mountPage]);
|
||||
|
||||
const state = useMemo<NavigationState>(
|
||||
() => ({ page, channelView, ytSearch }),
|
||||
[page, channelView, ytSearch],
|
||||
() => ({
|
||||
page,
|
||||
channelView,
|
||||
ytSearch,
|
||||
ytCount,
|
||||
ytSearchAsked: ytSearch != null && ytAsked === ytSearch,
|
||||
}),
|
||||
[page, channelView, ytSearch, ytCount, ytAsked],
|
||||
);
|
||||
const actions = useMemo<NavigationActions>(
|
||||
() => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }),
|
||||
[setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch],
|
||||
() => ({
|
||||
setPage,
|
||||
openChannel,
|
||||
closeChannel,
|
||||
enterYtSearch,
|
||||
exitYtSearch,
|
||||
askYtSearch,
|
||||
setYtCount,
|
||||
}),
|
||||
[setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch, askYtSearch, setYtCount],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The live YouTube search in the address: sent from the search box it gets its own entry naming the
|
||||
// term (and its count), a reload or a link opens it again, Back/Forward read it from the address the
|
||||
// browser restored — and only a search asked for in this document counts as asked (an API search
|
||||
// reopened any other way waits for a press, Feed's part).
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import { NavigationProvider } from "./NavigationProvider";
|
||||
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { claimLocalData } from "../../lib/storage";
|
||||
|
||||
let qc = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<NavigationProvider>{children}</NavigationProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const render = () =>
|
||||
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
|
||||
const search = () => new URLSearchParams(window.location.search);
|
||||
|
||||
/** No entry of this document under the current one (a bookmark opened in a new tab). */
|
||||
function stubNoEntryBelow() {
|
||||
Object.defineProperty(window, "navigation", {
|
||||
configurable: true,
|
||||
value: {
|
||||
currentEntry: { index: 1 },
|
||||
entries: () => [{ sameDocument: false }, { sameDocument: true }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient();
|
||||
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true });
|
||||
localStorage.clear();
|
||||
claimLocalData(1);
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi");
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
Reflect.deleteProperty(window, "navigation");
|
||||
});
|
||||
|
||||
describe("NavigationProvider · the YouTube search in the address", () => {
|
||||
it("names a search sent from the box on an entry of its own, over the feed's", () => {
|
||||
const { result } = render();
|
||||
const depth = window.history.length;
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
expect(window.history.length).toBe(depth + 1);
|
||||
expect(search().get("yt")).toBe("lofi");
|
||||
expect(search().get("q")).toBe("lofi"); // the feed's own keys stay
|
||||
expect(search().get("ytn")).toBeNull(); // the default count is left out
|
||||
expect(result.current.ytSearch).toBe("lofi");
|
||||
expect(result.current.ytSearchAsked).toBe(true);
|
||||
});
|
||||
|
||||
it("refines the search on screen in place, at the count it shows", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
act(() => result.current.setYtCount(60));
|
||||
expect(search().get("ytn")).toBe("60");
|
||||
const depth = window.history.length;
|
||||
act(() => result.current.enterYtSearch("lofi beats"));
|
||||
expect(window.history.length).toBe(depth);
|
||||
expect(search().get("yt")).toBe("lofi beats");
|
||||
expect(search().get("ytn")).toBe("60");
|
||||
expect(result.current.ytCount).toBe(60);
|
||||
});
|
||||
|
||||
it("a new search starts at the default count", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
act(() => result.current.setYtCount(40));
|
||||
stubNoEntryBelow(); // left in place, so the next search is a new entry
|
||||
vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
act(() => result.current.exitYtSearch());
|
||||
act(() => result.current.enterYtSearch("jazz"));
|
||||
expect(result.current.ytCount).toBe(20);
|
||||
expect(search().get("ytn")).toBeNull();
|
||||
});
|
||||
|
||||
it("writes the count, and clears it back at the default", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
act(() => result.current.setYtCount(100));
|
||||
expect(search().get("ytn")).toBe("100");
|
||||
act(() => result.current.setYtCount(20));
|
||||
expect(search().get("ytn")).toBeNull();
|
||||
});
|
||||
|
||||
it("reopens the search a reload or a link names, not as asked", () => {
|
||||
window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40");
|
||||
const { result } = render();
|
||||
expect(result.current.ytSearch).toBe("lofi");
|
||||
expect(result.current.ytCount).toBe(40);
|
||||
expect(result.current.ytSearchAsked).toBe(false);
|
||||
expect(search().get("yt")).toBe("lofi"); // the mount's own stamp keeps it
|
||||
act(() => result.current.askYtSearch());
|
||||
expect(result.current.ytSearchAsked).toBe(true);
|
||||
});
|
||||
|
||||
it("refines a search a link opened in place too, at the count it shows", () => {
|
||||
window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40");
|
||||
const { result } = render();
|
||||
const depth = window.history.length;
|
||||
act(() => result.current.enterYtSearch("jazz"));
|
||||
expect(window.history.length).toBe(depth);
|
||||
expect([search().get("yt"), search().get("ytn")]).toEqual(["jazz", "40"]);
|
||||
});
|
||||
|
||||
it("Back onto an entry whose address alone names the search reopens it", () => {
|
||||
const { result } = render();
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||
});
|
||||
expect(result.current.ytSearch).toBe("lofi");
|
||||
});
|
||||
|
||||
it("keeps the search mark of an entry the reload started from", () => {
|
||||
window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi");
|
||||
render();
|
||||
expect(window.history.state?._yt).toBe("lofi");
|
||||
});
|
||||
|
||||
it("marks the entry of a search the address opened (a reload, a link)", () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
|
||||
render();
|
||||
expect(window.history.state?._yt).toBe("lofi");
|
||||
});
|
||||
|
||||
it("drops an older build's search mark the address does not name", () => {
|
||||
window.history.replaceState(
|
||||
{ sfPage: "feed", _yt: "lofi", _ytScrape: true },
|
||||
"",
|
||||
"/?page=feed",
|
||||
);
|
||||
const { result } = render();
|
||||
expect(result.current.ytSearch).toBeNull();
|
||||
expect(window.history.state?._yt).toBeUndefined();
|
||||
expect(window.history.state?._ytScrape).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names no search under another page", () => {
|
||||
window.history.replaceState(null, "", "/?page=plex&yt=lofi");
|
||||
const { result } = render();
|
||||
expect(result.current.page).toBe("plex");
|
||||
expect(result.current.ytSearch).toBeNull();
|
||||
expect(search().get("yt")).toBeNull();
|
||||
});
|
||||
|
||||
it("Back/Forward read the search from the address the browser restored, not as asked", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
window.history.replaceState({ sfPage: "feed", _yt: "jazz" }, "", "/?page=feed&yt=jazz&ytn=60");
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||
});
|
||||
expect(result.current.ytSearch).toBe("jazz");
|
||||
expect(result.current.ytCount).toBe(60);
|
||||
expect(result.current.ytSearchAsked).toBe(false);
|
||||
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi");
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||
});
|
||||
expect(result.current.ytSearch).toBeNull();
|
||||
});
|
||||
|
||||
it("leaving a search with no entry of this document below clears it from the address", () => {
|
||||
window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40");
|
||||
const { result } = render();
|
||||
stubNoEntryBelow();
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
act(() => result.current.exitYtSearch());
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(result.current.ytSearch).toBeNull();
|
||||
expect(search().get("yt")).toBeNull();
|
||||
expect(search().get("ytn")).toBeNull();
|
||||
});
|
||||
|
||||
it("a 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("the Feed press on the search itself opens the plain feed, as over a channel", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
const depth = window.history.length;
|
||||
act(() => result.current.setPage("feed"));
|
||||
expect(result.current.ytSearch).toBeNull();
|
||||
expect(search().get("yt")).toBeNull();
|
||||
expect(window.history.length).toBe(depth + 1); // its own entry: Back returns to the search
|
||||
});
|
||||
|
||||
it("the Feed press on the search keeps the feed's own view in the address", () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&show=all&sort=oldest");
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
act(() => result.current.setPage("feed"));
|
||||
expect(search().get("yt")).toBeNull();
|
||||
expect([search().get("show"), search().get("sort")]).toEqual(["all", "oldest"]);
|
||||
});
|
||||
|
||||
it("a search sent from the box is left by a step back off its own entry", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
act(() => result.current.exitYtSearch());
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a search a link opened is left in place, not by a step back off the document", () => {
|
||||
window.history.replaceState(null, "", "/?page=feed&yt=lofi");
|
||||
const { result } = render();
|
||||
const back = vi.spyOn(window.history, "back");
|
||||
act(() => result.current.exitYtSearch());
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(result.current.ytSearch).toBeNull();
|
||||
expect(search().get("yt")).toBeNull();
|
||||
});
|
||||
|
||||
it("a landing on a channel opened from the search (an overlay over it closing) keeps the search", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
act(() => result.current.openChannel("UC1"));
|
||||
// A player over the channel closed: the step back lands on the channel's own entry.
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||
});
|
||||
expect(result.current.ytSearch).toBe("lofi");
|
||||
expect(result.current.ytSearchAsked).toBe(true);
|
||||
// Back off the channel: the search it stood over is still the one asked for.
|
||||
window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi");
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||
});
|
||||
expect(result.current.ytSearchAsked).toBe(true);
|
||||
});
|
||||
|
||||
it("a landing on a channel opened from a search the address opened keeps the search", () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
|
||||
const { result } = render();
|
||||
act(() => result.current.openChannel("UC1"));
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||
});
|
||||
expect(result.current.ytSearch).toBe("lofi");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.enterYtSearch("lofi"));
|
||||
act(() => result.current.setPage("home"));
|
||||
expect(result.current.ytSearch).toBeNull();
|
||||
expect(search().get("yt")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel page opens over any page, and its address names the channel alone (the owner, 2026-10-05:
|
||||
// it used to carry the feed's filters, naming a sort the channel did not show). The page under it is
|
||||
// still mounted — its store lives at the root — and a change in it while the channel stands over it
|
||||
// (a background refetch, the header search) must not write its keys onto the channel's address. Closing
|
||||
// the channel brings them back. The real provider and the real stores; only the network is absent.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
import { NavigationProvider } from "./NavigationProvider";
|
||||
import { ModuleStores } from "./ModuleStores";
|
||||
import { useNavigationActions, type NavigationActions } from "./navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../../modules/feed";
|
||||
import { usePlexActions } from "../../modules/plex";
|
||||
import { useChannelsActions } from "../../modules/channels";
|
||||
import { useFilesActions } from "../../modules/files";
|
||||
import { usePlaylistsActions } from "../../modules/playlists";
|
||||
import { claimLocalData } from "../../lib/storage";
|
||||
|
||||
type Probe = {
|
||||
nav: NavigationActions;
|
||||
change: Record<string, () => void>;
|
||||
};
|
||||
const probe: { current: Probe | null } = { current: null };
|
||||
|
||||
function Probe() {
|
||||
const nav = useNavigationActions();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
const plex = usePlexActions();
|
||||
const channels = useChannelsActions();
|
||||
const files = useFilesActions();
|
||||
const playlists = usePlaylistsActions();
|
||||
// Handed out after each render (an effect, not the render itself, may write outside it).
|
||||
useEffect(() => {
|
||||
probe.current = {
|
||||
nav,
|
||||
change: {
|
||||
feed: () => setFilters({ ...filters, sort: "oldest" }),
|
||||
plex: () => plex.setScope("movie"),
|
||||
channels: () => channels.setView("hidden"),
|
||||
files: () => files.setView({ kind: "folder", id: 3 }),
|
||||
playlists: () => playlists.setSelectedId(5),
|
||||
"plex search": () => plex.setQ("poirot"),
|
||||
"channels search": () => channels.setSearch("tom"),
|
||||
"files search": () => files.setSearch("notes"),
|
||||
"playlists search": () => playlists.setSearch("jazz"),
|
||||
},
|
||||
};
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// What each change above writes, once its page is what the address shows: [page, key, value]. A
|
||||
// search is written once the typing pauses.
|
||||
const WRITTEN: Record<string, [string, string, string]> = {
|
||||
feed: ["feed", "sort", "oldest"],
|
||||
plex: ["plex", "pscope", "movie"],
|
||||
channels: ["channels", "ctab", "hidden"],
|
||||
files: ["files", "folder", "3"],
|
||||
playlists: ["playlists", "pl", "5"],
|
||||
"plex search": ["plex", "pq", "poirot"],
|
||||
"channels search": ["channels", "cq", "tom"],
|
||||
"files search": ["files", "fq", "notes"],
|
||||
"playlists search": ["playlists", "plq", "jazz"],
|
||||
};
|
||||
const PAUSE = () => new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(3);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
const search = () => new URLSearchParams(window.location.search);
|
||||
|
||||
describe.each(Object.keys(WRITTEN))("a channel page over %s", (change) => {
|
||||
it("keeps the page's keys off its address, and they come back when it closes", async () => {
|
||||
const [page, key, value] = WRITTEN[change]!;
|
||||
// The page named even for the feed: a bare address opens on Home (the account's default).
|
||||
window.history.replaceState({ sfPage: page }, "", `/?page=${page}`);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<NavigationProvider>
|
||||
<ModuleStores>
|
||||
<Probe />
|
||||
</ModuleStores>
|
||||
</NavigationProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
act(() => probe.current!.nav.openChannel("UC1", "One"));
|
||||
act(() => probe.current!.change[change]!());
|
||||
await act(PAUSE);
|
||||
expect(search().get(key)).toBeNull();
|
||||
expect(search().get("chan")).toBe("UC1");
|
||||
act(() => probe.current!.nav.closeChannel());
|
||||
await waitFor(() => expect(search().get("chan")).toBeNull());
|
||||
await waitFor(() => expect(search().get(key)).toBe(value));
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,21 @@ export const REGISTERED_PAGES: readonly Page[] = ALL.map((m) => m.id);
|
||||
|
||||
const BY_ID = Object.fromEntries(ALL.map((m) => [m.id, m])) as Record<Page, ModuleDef>;
|
||||
|
||||
// Every module's address-bar keys, and those of every module but `page`: what a page switch clears and
|
||||
// what a page opens without (NavigationProvider). Derived from the definitions, so a new module's keys
|
||||
// are cleared the day it declares them.
|
||||
const ALL_URL_KEYS: readonly string[] = ALL.flatMap((m) => m.urlKeys);
|
||||
export function moduleUrlKeys(): readonly string[] {
|
||||
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[] {
|
||||
return ALL.filter((m) => m.id !== page).flatMap((m) => m.urlKeys);
|
||||
}
|
||||
|
||||
// Look up a module's definition by page id (every Page is covered, incl. settings).
|
||||
export function moduleDef(id: Page): ModuleDef {
|
||||
return BY_ID[id];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { Page } from "../../lib/urlState";
|
||||
import { DEFAULT_YT_COUNT } from "../../lib/ytSearchUrlState";
|
||||
import type { ChannelView } from "./navigationInit";
|
||||
|
||||
// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only
|
||||
@@ -15,8 +16,13 @@ export interface NavigationState {
|
||||
page: Page;
|
||||
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
|
||||
channelView: ChannelView | null;
|
||||
/** Live YouTube search term (null = normal feed). A feed sub-view; rides in history.state._yt. */
|
||||
/** Live YouTube search term (null = normal feed). A feed sub-view, named in the address (`yt`). */
|
||||
ytSearch: string | null;
|
||||
/** How many results the search gathers (`ytn`). */
|
||||
ytCount: number;
|
||||
/** The search on screen was asked for in this document (sent from the search box, or run again by
|
||||
* a press), not only reopened by a reload, a link or Back. An API search runs only when asked. */
|
||||
ytSearchAsked: boolean;
|
||||
}
|
||||
|
||||
export interface NavigationActions {
|
||||
@@ -33,12 +39,17 @@ export interface NavigationActions {
|
||||
closeChannel: () => void;
|
||||
enterYtSearch: (q: string) => void;
|
||||
exitYtSearch: () => void;
|
||||
/** Run the search on screen: the "Search YouTube" press. */
|
||||
askYtSearch: () => void;
|
||||
setYtCount: (n: number) => void;
|
||||
}
|
||||
|
||||
export const NavigationStateContext = createContext<NavigationState>({
|
||||
page: "feed",
|
||||
channelView: null,
|
||||
ytSearch: null,
|
||||
ytCount: DEFAULT_YT_COUNT,
|
||||
ytSearchAsked: false,
|
||||
});
|
||||
export const NavigationActionsContext = createContext<NavigationActions>({
|
||||
setPage: () => {},
|
||||
@@ -46,6 +57,8 @@ export const NavigationActionsContext = createContext<NavigationActions>({
|
||||
closeChannel: () => {},
|
||||
enterYtSearch: () => {},
|
||||
exitYtSearch: () => {},
|
||||
askYtSearch: () => {},
|
||||
setYtCount: () => {},
|
||||
});
|
||||
|
||||
export function useNavigation(): NavigationState {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Page } from "../../lib/urlState";
|
||||
import type { NavigationState } from "./navigationContext";
|
||||
|
||||
/** The page whose own keys the address bar carries: none while the channel page stands over it, so
|
||||
* every module store writes its keys (`useUrlMirror`) only while its page is what the screen shows.
|
||||
* A plain function beside the context, not a hook in it: the stores' tests mock that module. */
|
||||
export function urlPage(nav: Pick<NavigationState, "page" | "channelView">): Page | null {
|
||||
return nav.channelView ? null : nav.page;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type Video, type VideoStatus } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useStableCallback } from "../../lib/useLatest";
|
||||
import { writeVideoState } from "../video/videoWrites";
|
||||
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
|
||||
/** The shell's player, for an address that names one over a page that hosts none of its own (the
|
||||
* channel manager, where the watched-videos dialog played it; any page a link names): the video
|
||||
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
|
||||
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [own] = useState(!hosted);
|
||||
const [video, setVideo] = useState<Video | null>(null);
|
||||
useOpenLinkedPlayer(
|
||||
own,
|
||||
() => undefined,
|
||||
(v) => setVideo(v),
|
||||
);
|
||||
const onState = useStableCallback((id: string, status: VideoStatus) => {
|
||||
const write = writeVideoState(qc, id, status);
|
||||
write
|
||||
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
||||
return write;
|
||||
});
|
||||
const close = useStableCallback(() => setVideo(null));
|
||||
if (!video) return null;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal video={video} startAt={null} onClose={close} onState={onState} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -111,6 +111,7 @@ function renderModal(ui: ReactNode) {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
started.length = 0;
|
||||
loaded.length = 0;
|
||||
seeks.length = 0;
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
|
||||
import { useBackToClose } from "../../lib/history";
|
||||
import { linkedPlayer, playerParamUpdates, type PlayerPanelTab } from "../../lib/playerUrlState";
|
||||
import ChannelNameLink from "../video/ChannelNameLink";
|
||||
import Toaster from "../../components/ui/Toaster";
|
||||
import { useOverlayOrder } from "../../components/ui/overlayOrder";
|
||||
@@ -222,8 +223,6 @@ export default function PlayerModal({
|
||||
const { openChannel } = useNavigationActions();
|
||||
const qc = useQueryClient();
|
||||
const { ref: descFadeRef, style: descFadeStyle } = useScrollFade();
|
||||
// Browser/mouse Back closes the player instead of leaving the page.
|
||||
useBackToClose(onClose);
|
||||
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
|
||||
// opened with, for its whole session. The live feed refetches and reorders/drops items under
|
||||
// us — e.g. auto-marking each video watched near its end removes it from a filtered feed — which
|
||||
@@ -277,6 +276,25 @@ export default function PlayerModal({
|
||||
setRelatedOf(active.id);
|
||||
setRelated(null);
|
||||
}
|
||||
// An address that names the item this player opens on (a reload, a bookmark) names its view too: the
|
||||
// related video playing in place over it and the panel's tab come back with it. Read once.
|
||||
const [linked] = useState(() => {
|
||||
const here = linkedPlayer();
|
||||
return here && here.watch === video.id ? here : null;
|
||||
});
|
||||
const linkedRelated = useQuery({
|
||||
queryKey: qk.videoRow(linked?.related ?? ""),
|
||||
queryFn: () => api.videoRow(linked!.related!),
|
||||
enabled: !!linked?.related,
|
||||
retry: false,
|
||||
});
|
||||
const [relatedAdopted, setRelatedAdopted] = useState(false);
|
||||
if (!relatedAdopted && linkedRelated.data) {
|
||||
setRelatedAdopted(true);
|
||||
// Only over the item it was named with: a step of the queue before it arrived left that one.
|
||||
if (active.id === linked?.watch && !related)
|
||||
setRelated({ video: linkedRelated.data, startAt: null });
|
||||
}
|
||||
const subject = related?.video ?? active;
|
||||
const subjectResume = related
|
||||
? (related.startAt ?? related.video.position_seconds ?? 0)
|
||||
@@ -753,7 +771,7 @@ export default function PlayerModal({
|
||||
// take the side panel's place of the description, a tab beside it; stacked, a drawer over the video
|
||||
// opened from a button beside the ⓘ. The list is the OPENED video's: one played from it leaves the
|
||||
// list in place, to try the next one (the owner's pick).
|
||||
const [panelTab, setPanelTab] = useState<"description" | "related">("description");
|
||||
const [panelTab, setPanelTab] = useState<PlayerPanelTab>(linked?.panel ?? "description");
|
||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||
// The drawer is the stacked layout's: going wide while it is open, the list moves into the panel's
|
||||
// tab instead. Left open, it reopened unasked when the window narrowed again.
|
||||
@@ -762,6 +780,17 @@ export default function PlayerModal({
|
||||
setPanelTab("related");
|
||||
}
|
||||
const panelTabs = useTabPanel();
|
||||
// Browser/mouse Back closes the player instead of leaving the page. Its entry's address names what
|
||||
// it plays (the queue's item, a related one over it) and the wide panel's tab, so a reload or a
|
||||
// bookmark reopens it as it is; the page's own entry under it never carries them.
|
||||
useBackToClose(
|
||||
onClose,
|
||||
playerParamUpdates({
|
||||
watch: active.id,
|
||||
related: related?.video.id ?? null,
|
||||
panel: wide ? panelTab : "description",
|
||||
}),
|
||||
);
|
||||
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
|
||||
// be refused.
|
||||
const relatedOn = useMe().related_enabled;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The URL is the view: the YouTube player names what it plays in its own history entry — the item of
|
||||
// the queue, a related video played in place over it, the wide panel's tab — so a reload or a bookmark
|
||||
// reopens it as it was, and Back lands on the page's address without it. Its own file: the overlay
|
||||
// history is module state, and these read the address the closed players of other tests step back over.
|
||||
//
|
||||
// `window.YT` is a fake installed before the player's API loader first runs (see PlayerModal.dom.test).
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../video/AddToPlaylist", () => ({ default: () => null }));
|
||||
vi.mock("../downloads/DownloadButton", () => ({ default: () => null }));
|
||||
|
||||
import PlayerModal from "./PlayerModal";
|
||||
import { api, type Me, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
const started: { videoId: string; start: number | undefined }[] = [];
|
||||
let playerEvents: { onReady?: () => void } = {};
|
||||
const fakePlayer = new Proxy(
|
||||
{ getCurrentTime: () => 0, getDuration: () => 3000, getVideoData: () => null },
|
||||
{ get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined) },
|
||||
);
|
||||
window.YT = {
|
||||
Player: function (
|
||||
_el: unknown,
|
||||
opts: { videoId: string; playerVars: { start?: number }; events?: typeof playerEvents },
|
||||
) {
|
||||
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
|
||||
playerEvents = opts.events ?? {};
|
||||
return fakePlayer;
|
||||
} as never,
|
||||
};
|
||||
const lastStart = () => started[started.length - 1];
|
||||
|
||||
const vid = (id: string, position_seconds = 0): Video =>
|
||||
({
|
||||
id,
|
||||
title: `Video ${id}`,
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status: "new",
|
||||
position_seconds,
|
||||
duration_seconds: 3000,
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
}) as unknown as Video;
|
||||
const related = (id: string, position_seconds = 0) => ({
|
||||
...vid(id, position_seconds),
|
||||
followed: false,
|
||||
});
|
||||
|
||||
// The wide layout (the side panel with its tabs): jsdom has no matchMedia, so one is put in.
|
||||
function wideWindow() {
|
||||
window.matchMedia = ((q: string) => ({
|
||||
matches: q === "(min-width: 1280px)",
|
||||
media: q,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
function renderModal(video: Video, queue: Video[]) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { related_enabled: true } as Me);
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlayerModal
|
||||
video={video}
|
||||
queue={queue}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// A closed player steps back over its entry a task later (jsdom's back(), like a browser's).
|
||||
async function closeAndLand(unmount: () => void) {
|
||||
const landed = new Promise((r) => window.addEventListener("popstate", r, { once: true }));
|
||||
unmount();
|
||||
await act(() => landed);
|
||||
}
|
||||
|
||||
const here = () => new URLSearchParams(window.location.search);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
vi.spyOn(api, "related").mockResolvedValue({
|
||||
items: [related("rel1"), related("rel2", 120)],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (window as { matchMedia?: unknown }).matchMedia;
|
||||
started.length = 0;
|
||||
});
|
||||
|
||||
describe("the player's address", () => {
|
||||
it("names the item playing now in its own entry, a step of the queue too", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?q=talks");
|
||||
const r = renderModal(vid("A"), [vid("A"), vid("B")]);
|
||||
await waitFor(() => expect(here().get("watch")).toBe("A"));
|
||||
expect(here().get("q")).toBe("talks");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
||||
await waitFor(() => expect(here().get("watch")).toBe("B"));
|
||||
await closeAndLand(r.unmount);
|
||||
expect(window.location.search).toBe("?q=talks");
|
||||
});
|
||||
|
||||
it("wide: names the related video playing over it and the panel's tab, and Back drops it", async () => {
|
||||
wideWindow();
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/");
|
||||
const r = renderModal(vid("A", 40), [vid("A", 40)]);
|
||||
act(() => playerEvents.onReady?.());
|
||||
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
|
||||
await waitFor(() => expect(here().get("wtab")).toBe("related"));
|
||||
const rows = await screen.findAllByRole("link", { name: "Video rel2" });
|
||||
fireEvent.click(rows.find((a) => a.hasAttribute("data-nav-open"))!);
|
||||
await waitFor(() => expect(here().get("wrel")).toBe("rel2"));
|
||||
expect(here().get("watch")).toBe("A");
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
|
||||
await waitFor(() => expect(here().has("wrel")).toBe(false));
|
||||
expect(here().get("watch")).toBe("A");
|
||||
await closeAndLand(r.unmount);
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
|
||||
it("wide: a link's related video and panel tab come back over the item it names", async () => {
|
||||
wideWindow();
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=A&wrel=rel2&wtab=related");
|
||||
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("rel2", 120));
|
||||
const r = renderModal(vid("A", 40), [vid("A", 40)]);
|
||||
await waitFor(() => expect(lastStart()).toEqual({ videoId: "rel2", start: 120 }));
|
||||
expect(row).toHaveBeenCalledWith("rel2");
|
||||
const tab = screen.getByRole("tab", { name: "player.related.title" });
|
||||
expect(tab.getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByRole("button", { name: "player.back" })).toBeTruthy();
|
||||
expect(here().get("wrel")).toBe("rel2");
|
||||
await closeAndLand(r.unmount);
|
||||
// The entry the link opened at named the player; once the player is gone, so is its name.
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
|
||||
it("a link's related video that arrives after the queue stepped on stays off the next item", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=A&wrel=rel2");
|
||||
let arrive: (v: Video) => void = () => {};
|
||||
vi.spyOn(api, "videoRow").mockReturnValue(new Promise<Video>((r) => (arrive = r)));
|
||||
const r = renderModal(vid("A"), [vid("A"), vid("B")]);
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
||||
await waitFor(() => expect(here().get("watch")).toBe("B"));
|
||||
await act(async () => {
|
||||
arrive(vid("rel2", 120));
|
||||
await new Promise((r) => setTimeout(r, 50)); // the query hands its answer on a tick later
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "Video B" })).toBeTruthy();
|
||||
expect(here().has("wrel")).toBe(false);
|
||||
await closeAndLand(r.unmount);
|
||||
});
|
||||
|
||||
it("wide: a link naming another item brings none of its view", async () => {
|
||||
wideWindow();
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=Z&wrel=rel2&wtab=related");
|
||||
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("rel2", 120));
|
||||
const r = renderModal(vid("A", 40), [vid("A", 40)]);
|
||||
await waitFor(() => expect(here().get("watch")).toBe("A"));
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
expect(here().has("wrel")).toBe(false);
|
||||
expect(here().has("wtab")).toBe(false);
|
||||
expect(lastStart()?.videoId).toBe("A");
|
||||
await closeAndLand(r.unmount);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload or a bookmark of the YouTube player reopens it: the page that hosts it opens it once its list
|
||||
// is in — from the list, so it plays on through it, or alone from the catalog — and a page that hosts
|
||||
// none gets the shell's, the video alone. A video the catalog no longer has is said, not swallowed.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../lib/actionErrors", () => ({ notifyActionError: vi.fn() }));
|
||||
// The player itself is not what is under test: it shows what it was opened on.
|
||||
vi.mock("./PlayerModal", () => ({
|
||||
default: ({ video, queue }: { video: Video; queue?: Video[] }) => (
|
||||
<p>
|
||||
playing {video.id} {queue ? "in a list" : "alone"}
|
||||
</p>
|
||||
),
|
||||
}));
|
||||
|
||||
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||
import { LinkedPlayer } from "./LinkedPlayer";
|
||||
import { api, type Video } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(notifyActionError).mockClear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
describe("useOpenLinkedPlayer", () => {
|
||||
it("opens the item from the host's list once the list is in, and only once", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=B");
|
||||
const row = vi.spyOn(api, "videoRow");
|
||||
const open = vi.fn();
|
||||
const list = [vid("A"), vid("B")];
|
||||
const { rerender } = renderHook(
|
||||
({ ready, rows }) => useOpenLinkedPlayer(ready, (id) => rows.find((v) => v.id === id), open),
|
||||
{ wrapper, initialProps: { ready: false, rows: list } },
|
||||
);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
rerender({ ready: true, rows: list });
|
||||
await waitFor(() => expect(open).toHaveBeenCalledWith(list[1], true));
|
||||
// The list refetched (new rows for the same videos): the player it opened is not opened again.
|
||||
rerender({ ready: true, rows: [vid("A"), vid("B")] });
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens an item the list does not hold alone, its row from the catalog", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=Z");
|
||||
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
|
||||
const open = vi.fn();
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||
await waitFor(() => expect(open).toHaveBeenCalledWith(vid("Z"), false));
|
||||
});
|
||||
|
||||
it("says so when the catalog no longer has it", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=Z");
|
||||
const gone = new Error("not found");
|
||||
vi.spyOn(api, "videoRow").mockRejectedValue(gone);
|
||||
const open = vi.fn();
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||
await waitFor(() =>
|
||||
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
|
||||
);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
// Said once: the address no longer names it, so a reload or a copied link does not say it again.
|
||||
expect(new URLSearchParams(window.location.search).has("watch")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a player opened while the row was on its way alone, and its address", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=Z");
|
||||
let answer: (v: Video) => void = () => {};
|
||||
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r)));
|
||||
const open = vi.fn();
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||
// A card clicked meanwhile: its player names itself in the address.
|
||||
window.history.replaceState(null, "", "/?watch=Y");
|
||||
answer(vid("Z"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
|
||||
});
|
||||
|
||||
it("does not clear the address of a player opened since when its catalog read fails", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=Z");
|
||||
let refuse: (e: Error) => void = () => {};
|
||||
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((_, r) => (refuse = r)));
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, vi.fn()), { wrapper });
|
||||
window.history.replaceState(null, "", "/?watch=Y");
|
||||
refuse(new Error("not found"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
|
||||
});
|
||||
|
||||
it("given up over a moved list, keeps the address of a player opened since", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=Z");
|
||||
const { rerender } = renderHook(
|
||||
({ scope }) => useOpenLinkedPlayer(false, () => undefined, vi.fn(), scope),
|
||||
{ wrapper, initialProps: { scope: 1 } },
|
||||
);
|
||||
window.history.replaceState(null, "", "/?watch=Y");
|
||||
rerender({ scope: 2 });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
|
||||
});
|
||||
|
||||
it("stops looking once it opened: the video leaving the list asks the catalog for nothing", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=B");
|
||||
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("B"));
|
||||
const find = vi.fn((id: string) => (id === "B" ? vid("B") : undefined));
|
||||
const { rerender } = renderHook(
|
||||
({ rows }) => useOpenLinkedPlayer(true, rows ? find : () => undefined, vi.fn()),
|
||||
{ wrapper, initialProps: { rows: true } },
|
||||
);
|
||||
await waitFor(() => expect(find).toHaveBeenCalled());
|
||||
find.mockClear();
|
||||
rerender({ rows: false });
|
||||
rerender({ rows: true });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(find).not.toHaveBeenCalled();
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens nothing, and asks for nothing, for an address that names no player", async () => {
|
||||
window.history.replaceState(null, "", "/?q=talks");
|
||||
const row = vi.spyOn(api, "videoRow");
|
||||
const open = vi.fn();
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the shell's LinkedPlayer", () => {
|
||||
it("plays the video alone over a page that hosts no player", async () => {
|
||||
window.history.replaceState(null, "", "/?page=channels&watch=Z");
|
||||
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
|
||||
render(<LinkedPlayer hosted={false} />, { wrapper });
|
||||
expect(await screen.findByText("playing Z alone")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves it to a page that hosts its own, even once that page has fetched the row", async () => {
|
||||
// The page plays it alone, from the catalog row it asked for: the same cache entry this one reads.
|
||||
window.history.replaceState(null, "", "/?page=home&watch=Z");
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.videoRow("Z"), vid("Z"));
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<LinkedPlayer hosted />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByText(/playing/)).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves it to a page that hosts its own", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=Z");
|
||||
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
|
||||
render(<LinkedPlayer hosted />, { wrapper });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/playing/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
|
||||
import { clearParams } from "../../lib/urlState";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
|
||||
/** 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
|
||||
* 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.
|
||||
*
|
||||
* `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. So it is when a player opened
|
||||
* meanwhile (a card clicked while the row was on its way): the address names that one now. */
|
||||
export function useOpenLinkedPlayer(
|
||||
ready: boolean,
|
||||
find: (id: string) => Video | undefined,
|
||||
open: (video: Video, inList: boolean) => void,
|
||||
scope: string | number | null = null,
|
||||
): void {
|
||||
const { t } = useTranslation();
|
||||
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
|
||||
const [linkedScope] = useState(scope);
|
||||
// Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must
|
||||
// not open it twice); the state stops the lookup and the row fetch on screen after it.
|
||||
const done = useRef(linked == null);
|
||||
const [settled, setSettled] = useState(linked == null);
|
||||
const moved = scope !== linkedScope;
|
||||
const waiting = ready && !moved && !settled && linked != null;
|
||||
const listed = waiting ? find(linked) : undefined;
|
||||
const row = useQuery({
|
||||
queryKey: qk.videoRow(linked ?? ""),
|
||||
queryFn: () => api.videoRow(linked!),
|
||||
enabled: waiting && !listed,
|
||||
retry: false,
|
||||
});
|
||||
const openIt = useEffectEvent(open);
|
||||
const failed = useEffectEvent((err: unknown) => {
|
||||
notifyActionError(err, t("player.linkedFailed"));
|
||||
clearParams(...PLAYER_KEYS);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (done.current) return;
|
||||
const settle = () => {
|
||||
done.current = true;
|
||||
// State, not derived: a one-way decision taken as the list or the row arrives. Derived from
|
||||
// `listed`, the lookup and the row fetch would come back whenever the video left the list.
|
||||
setSettled(true);
|
||||
};
|
||||
// A player opened since names its own: this one is not wanted any more.
|
||||
if (linkedPlayer()?.watch !== linked) return settle();
|
||||
if (moved) {
|
||||
// The address goes on naming it under the new list (a reload would open it there).
|
||||
clearParams(...PLAYER_KEYS);
|
||||
return settle();
|
||||
}
|
||||
// Not before the host is ready: the row's cache entry is shared, so another opener's fetch fills
|
||||
// it here too (a disabled query still reads the cache) — the shell's player over a page that
|
||||
// plays its own would open a second one.
|
||||
if (!ready) return;
|
||||
if (listed) openIt(listed, true);
|
||||
else if (row.data) openIt(row.data, false);
|
||||
else if (row.isError) failed(row.error);
|
||||
else return;
|
||||
settle();
|
||||
}, [linked, moved, ready, listed, row.data, row.isError, row.error]);
|
||||
}
|
||||
@@ -48,8 +48,9 @@ describe("ChannelNameLink", () => {
|
||||
it("is an anchor whose href opens this channel on the current page", () => {
|
||||
const { link } = renderLink();
|
||||
expect(link.tagName).toBe("A");
|
||||
// The same URL openChannel pushes: the current page's query plus ?chan=<id>.
|
||||
expect(link.getAttribute("href")).toBe(`/?page=feed&show=unwatched&chan=${CH}`);
|
||||
// The same URL openChannel pushes: the current page plus ?chan=<id> — not the page's own keys,
|
||||
// which named a view the channel does not show (`show=unwatched` here).
|
||||
expect(link.getAttribute("href")).toBe(`/?page=feed&chan=${CH}`);
|
||||
});
|
||||
|
||||
it("takes the in-app path on a plain left click, and suppresses the browser navigation", () => {
|
||||
@@ -73,11 +74,16 @@ describe("ChannelNameLink", () => {
|
||||
expect(onOpenInApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not lose the rest of the address bar", () => {
|
||||
window.history.replaceState({}, "", "/?page=channels&q=abc");
|
||||
// The address names only what the screen shows: the channel over the page, not the page's filters
|
||||
// (a link's new tab once opened the channel under the feed's search and sort).
|
||||
it("keeps the page and the shell's keys, and leaves the page's and another channel's behind", () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/?page=channels&q=abc&ctab=hidden&chtab=about&m=signup#x=1",
|
||||
);
|
||||
const { link } = renderLink();
|
||||
expect(link.getAttribute("href")).toContain("page=channels");
|
||||
expect(link.getAttribute("href")).toContain("q=abc");
|
||||
expect(link.getAttribute("href")).toBe(`/?page=channels&chan=${CH}&m=signup#x=1`);
|
||||
});
|
||||
|
||||
it("gives a channel with no title yet an accessible name", () => {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { forwardRef, type AnchorHTMLAttributes } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isPlainLeftClick } from "../../lib/nav";
|
||||
import { urlWithParams } from "../../lib/urlState";
|
||||
import { channelUrl } from "../../lib/urlState";
|
||||
|
||||
// A channel name that is a REAL link. The player showed it as a `<button>`, so the browser's own
|
||||
// gestures did nothing on it: middle-click and Ctrl/Cmd-click opened no tab, on a thing that looks
|
||||
// exactly like a link (the YouTube icon right next to it IS an anchor and does open one). The video
|
||||
// card's channel name had the same `<button>`, so it renders through here too.
|
||||
//
|
||||
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
|
||||
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
|
||||
// The href is the same URL `openChannel` pushes (`channelUrl`: the channel over the current page,
|
||||
// without the page's own keys) — so a new tab lands where the in-app navigation would have. A plain left click is still handled in-app, because
|
||||
// the SPA path does more than navigate (the player has to close first and reclaim its own
|
||||
// Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
|
||||
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
|
||||
@@ -33,7 +33,7 @@ const ChannelNameLink = forwardRef<HTMLAnchorElement, Props>(function ChannelNam
|
||||
<a
|
||||
{...rest}
|
||||
ref={ref}
|
||||
href={urlWithParams({ chan: channelId })}
|
||||
href={channelUrl(channelId)}
|
||||
title={tooltip}
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
|
||||
@@ -75,6 +75,9 @@
|
||||
"clearNow": "Clear now",
|
||||
"cleared_one": "Cleared {{count}} result",
|
||||
"cleared_other": "Cleared {{count}} results",
|
||||
"showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size."
|
||||
"showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.",
|
||||
"notRunTitle": "Not searched yet",
|
||||
"notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload, a link, Back or Forward doesn't start one by itself.",
|
||||
"runAgain": "Search YouTube"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"linkedGone": "The file in the address is no longer in this list",
|
||||
"linkedNotListed": "The file in the link isn't among the files listed here — search for it by name",
|
||||
"navLabel": "Files",
|
||||
"searchPlaceholder": "Search files…",
|
||||
"count_one": "{{count}} item",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"loading": "Loading…",
|
||||
"linkedFailed": "The video in the address could not be opened",
|
||||
"backToOriginal": "Back to the original video",
|
||||
"back": "Back",
|
||||
"previous": "Previous",
|
||||
|
||||
@@ -75,6 +75,9 @@
|
||||
"clearNow": "Törlés most",
|
||||
"cleared_one": "{{count}} találat törölve",
|
||||
"cleared_other": "{{count}} találat törölve",
|
||||
"showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább."
|
||||
"showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább.",
|
||||
"notRunTitle": "Ez a keresés még nem futott le",
|
||||
"notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltéskor, linkről, vissza- vagy előrelépéskor nem indul el magától.",
|
||||
"runAgain": "Keresés a YouTube-on"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"linkedGone": "A linkben szereplő fájl már nincs ebben a listában",
|
||||
"linkedNotListed": "A linkben szereplő fájl nincs a listázottak között — keress rá a nevére",
|
||||
"navLabel": "Fájlok",
|
||||
"searchPlaceholder": "Keresés a fájlok között…",
|
||||
"count_one": "{{count}} elem",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"loading": "Betöltés…",
|
||||
"linkedFailed": "A linkben szereplő videó nem nyitható meg",
|
||||
"backToOriginal": "Vissza az eredeti videóhoz",
|
||||
"back": "Vissza",
|
||||
"previous": "Előző",
|
||||
|
||||
@@ -2,12 +2,19 @@ import { LS, setTabRaw } from "./storage";
|
||||
|
||||
// The admin Users page's persisted-tab key + the Access-requests tab id, plus a helper to
|
||||
// pre-select that tab before navigating there (a notification's "Review" link uses it —
|
||||
// useTabPersistedState reads the key on mount, and the page mounts fresh on navigation).
|
||||
// useTabPersistedState reads the key on mount, and the page on screen hears it).
|
||||
//
|
||||
// Kept out of AdminUsers.tsx so callers (App, NotificationsPanel) can pre-select the tab
|
||||
// WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull
|
||||
// it back into the main bundle and defeat the code-split.
|
||||
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
|
||||
// The address-bar key the page mirrors its tab to.
|
||||
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";
|
||||
|
||||
export function focusAccessRequestsTab(): void {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { setupApi } from "./api/setup";
|
||||
export type * from "./api/setup";
|
||||
import { feedApi } from "./api/feed";
|
||||
export type * from "./api/feed";
|
||||
export { TAKEOUT_JOB } from "./api/feed";
|
||||
export { FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, TAKEOUT_JOB } from "./api/feed";
|
||||
import { discoverApi } from "./api/discover";
|
||||
export type * from "./api/discover";
|
||||
export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover";
|
||||
|
||||
@@ -81,6 +81,11 @@ export interface FeedResponse {
|
||||
// or optional (dead surface). Re-add if a consumer ever needs it.
|
||||
}
|
||||
|
||||
/** The feed's view and order with nothing chosen: what a bookmark of the plain feed names, and what
|
||||
* the active-filter chips read "nothing applied" against. */
|
||||
export const FEED_DEFAULT_SHOW = "unwatched";
|
||||
export const FEED_DEFAULT_SORT = "newest";
|
||||
|
||||
export interface FeedFilters {
|
||||
tags: number[];
|
||||
/** Per-category tag match (language / topic / other each independently any/all). */
|
||||
@@ -314,6 +319,9 @@ export const feedApi = {
|
||||
duration_seconds: Math.floor(durationSeconds),
|
||||
}),
|
||||
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
|
||||
/** One catalog video as the feed lists it (this user's state with it): what a player opened from an
|
||||
* address starts from. */
|
||||
videoRow: (id: string): Promise<Video> => req(`/api/videos/${encodeURIComponent(id)}/row`),
|
||||
// One file of a Google Takeout export, its bytes as they are; the server tells the two apart.
|
||||
takeoutUpload: (file: File): Promise<TakeoutUpload> => uploadReq("/api/takeout", file),
|
||||
takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// The channel page's address: what a view writes, and what an address opens.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
channelPageParamUpdates as encode,
|
||||
channelPageViewFrom as decode,
|
||||
type ChannelPageView,
|
||||
} from "./channelPageUrlState";
|
||||
|
||||
const FRESH: ChannelPageView = {
|
||||
tab: "videos",
|
||||
sort: "newest",
|
||||
types: { includeNormal: true, includeShorts: false, includeLive: false },
|
||||
};
|
||||
const params = (u: Record<string, string | null>) =>
|
||||
new URLSearchParams(Object.entries(u).filter((e): e is [string, string] => e[1] != null));
|
||||
|
||||
describe("channel page address", () => {
|
||||
it("names a fresh page by its content types alone", () => {
|
||||
expect(encode(FRESH)).toEqual({ chtab: null, chsort: null, chtypes: "n" });
|
||||
});
|
||||
|
||||
it("names the tab, the sort and every content type it shows", () => {
|
||||
expect(
|
||||
encode({
|
||||
tab: "about",
|
||||
sort: "oldest",
|
||||
types: { includeNormal: false, includeShorts: true, includeLive: true },
|
||||
}),
|
||||
).toEqual({ chtab: "about", chsort: "oldest", chtypes: "sl" });
|
||||
});
|
||||
|
||||
it("names an empty content choice, which no letters would", () => {
|
||||
const none = { includeNormal: false, includeShorts: false, includeLive: false };
|
||||
expect(encode({ ...FRESH, types: none }).chtypes).toBe("none");
|
||||
expect(decode(params({ chtypes: "none" }), FRESH).types).toEqual(none);
|
||||
});
|
||||
|
||||
it("clears every key once the page is closed", () => {
|
||||
expect(encode(null)).toEqual({ chtab: null, chsort: null, chtypes: null });
|
||||
});
|
||||
|
||||
it.each<ChannelPageView>([
|
||||
FRESH,
|
||||
{
|
||||
tab: "about",
|
||||
sort: "views",
|
||||
types: { includeNormal: true, includeShorts: true, includeLive: false },
|
||||
},
|
||||
{
|
||||
tab: "videos",
|
||||
sort: "oldest",
|
||||
types: { includeNormal: false, includeShorts: false, includeLive: true },
|
||||
},
|
||||
])("reopens the view it names: %o", (view) => {
|
||||
expect(decode(params(encode(view)), FRESH)).toEqual(view);
|
||||
});
|
||||
|
||||
it("opens a bare channel link as a fresh page, with the feed's content types", () => {
|
||||
const feedTypes = { includeNormal: true, includeShorts: true, includeLive: false };
|
||||
expect(decode(new URLSearchParams("chan=UC1"), { ...FRESH, types: feedTypes })).toEqual({
|
||||
...FRESH,
|
||||
types: feedTypes,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a tab it does not know", () => {
|
||||
expect(decode(params({ chtab: "bogus" }), FRESH).tab).toBe("videos");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// The channel page's address-bar keys. The page opens over any page (`?chan=`, the shell's), so its own
|
||||
// keys are the shell's too: no module owns them, a page switch clears them with `chan`, and the page
|
||||
// under it writes none of ITS keys while the channel stands over it — the address names only what the
|
||||
// screen shows. The watch state is not here: it is an account preference that follows every channel
|
||||
// (`channelShow`).
|
||||
export const CHANNEL_PAGE_KEYS = ["chtab", "chsort", "chtypes"] as const;
|
||||
|
||||
export type ChannelTab = "videos" | "about";
|
||||
type ChannelContentTypes = {
|
||||
includeNormal: boolean;
|
||||
includeShorts: boolean;
|
||||
includeLive: boolean;
|
||||
};
|
||||
export type ChannelPageView = { tab: ChannelTab; sort: string; types: ChannelContentTypes };
|
||||
|
||||
const DEFAULT_SORT = "newest";
|
||||
// The content types as letters: n(ormal) s(horts) l(ive); "none" for an empty choice.
|
||||
const TYPE_LETTERS: [keyof ChannelContentTypes, string][] = [
|
||||
["includeNormal", "n"],
|
||||
["includeShorts", "s"],
|
||||
["includeLive", "l"],
|
||||
];
|
||||
|
||||
/** The address-bar delta for the channel page's view; `null` clears every key (the page closed). The
|
||||
* content types are always written: a page opened fresh takes them from the feed, so a link without
|
||||
* them would reopen with whatever the feed of the browser opening it holds. */
|
||||
export function channelPageParamUpdates(v: ChannelPageView | null): Record<string, string | null> {
|
||||
if (v == null) return { chtab: null, chsort: null, chtypes: null };
|
||||
const letters = TYPE_LETTERS.filter(([k]) => v.types[k])
|
||||
.map(([, l]) => l)
|
||||
.join("");
|
||||
return {
|
||||
chtab: v.tab === "about" ? "about" : null,
|
||||
chsort: v.sort && v.sort !== DEFAULT_SORT ? v.sort : null,
|
||||
chtypes: letters || "none",
|
||||
};
|
||||
}
|
||||
|
||||
/** The view a channel page's address names; what it leaves out comes from `fresh` (the defaults of a
|
||||
* page opened without a link: the videos tab, newest first, the feed's content types). */
|
||||
export function channelPageViewFrom(
|
||||
params: URLSearchParams,
|
||||
fresh: ChannelPageView,
|
||||
): ChannelPageView {
|
||||
const raw = params.get("chtypes");
|
||||
const types =
|
||||
raw == null
|
||||
? fresh.types
|
||||
: (Object.fromEntries(
|
||||
TYPE_LETTERS.map(([k, l]) => [k, raw !== "none" && raw.includes(l)]),
|
||||
) as ChannelContentTypes);
|
||||
return {
|
||||
tab: params.get("chtab") === "about" ? "about" : fresh.tab,
|
||||
sort: params.get("chsort") || fresh.sort,
|
||||
types,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Channels-manager content state (active tab + column sort + the column-filter map) round-trips
|
||||
// through namespaced URL keys (ctab/csort/cf) so a manager view is shareable, the plexUrlState
|
||||
// The Channels-manager content state (active tab + column sort + the column-filter map + the name
|
||||
// search) round-trips through namespaced URL keys (ctab/csort/cf/cq) so a manager view is shareable, the plexUrlState
|
||||
// analogue. `cf` carries the mixed scalar/array FilterMap as a JSON blob.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -20,7 +20,7 @@ const RICH_FILTERS: FilterMap = {
|
||||
tags: ["12", "34"],
|
||||
};
|
||||
|
||||
const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {} };
|
||||
const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {}, search: "" };
|
||||
|
||||
// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
|
||||
function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
|
||||
@@ -32,29 +32,49 @@ function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
|
||||
}
|
||||
|
||||
describe("channelsParamUpdates", () => {
|
||||
it("emits nothing for the all-default state", () => {
|
||||
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null });
|
||||
it("writes no search for a blank one", () => {
|
||||
expect(channelsParamUpdates({ ...BASE, search: " " }).cq).toBeNull();
|
||||
});
|
||||
|
||||
it("writes the default view in full: this browser remembers one a link must not inherit", () => {
|
||||
expect(channelsParamUpdates(BASE)).toEqual({
|
||||
ctab: "subscribed",
|
||||
csort: "none",
|
||||
cf: "",
|
||||
cq: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("a link to the default view opens it over another remembered one", () => {
|
||||
const params = applyUpdates(channelsParamUpdates(BASE));
|
||||
const back = channelsParamsToState(params, {
|
||||
view: "hidden",
|
||||
sort: { key: "subscribers", dir: "desc" },
|
||||
filters: { sync: "fully" },
|
||||
search: "",
|
||||
});
|
||||
expect(back).toEqual(BASE);
|
||||
});
|
||||
|
||||
it("clears every key when passed null (off-manager page)", () => {
|
||||
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null });
|
||||
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null, cq: null });
|
||||
});
|
||||
|
||||
it("emits only non-default keys", () => {
|
||||
it("writes the view picked", () => {
|
||||
const u = channelsParamUpdates({
|
||||
view: "hidden",
|
||||
sort: { key: "subscribers", dir: "desc" },
|
||||
filters: { sync: "fully" },
|
||||
search: "tom",
|
||||
});
|
||||
expect(u.ctab).toBe("hidden");
|
||||
expect(u.csort).toBe("subscribers:desc");
|
||||
expect(u.cf).toBe(JSON.stringify({ sync: "fully" }));
|
||||
expect(u.cq).toBe("tom");
|
||||
});
|
||||
|
||||
it("drops empty filter entries so a cleared funnel emits no cf", () => {
|
||||
expect(
|
||||
channelsParamUpdates({ view: "subscribed", sort: null, filters: { tags: [], sync: "" } }).cf,
|
||||
).toBeNull();
|
||||
it("drops empty filter entries so a cleared funnel writes an empty cf", () => {
|
||||
expect(channelsParamUpdates({ ...BASE, filters: { tags: [], sync: "" } }).cf).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,12 +84,14 @@ describe("round-trip", () => {
|
||||
view: "blocked",
|
||||
sort: { key: "priority", dir: "asc" },
|
||||
filters: RICH_FILTERS,
|
||||
search: "jazz",
|
||||
};
|
||||
const params = applyUpdates(channelsParamUpdates(state));
|
||||
const back = channelsParamsToState(params, BASE);
|
||||
expect(back.view).toBe("blocked");
|
||||
expect(back.sort).toEqual({ key: "priority", dir: "asc" });
|
||||
expect(back.filters).toEqual(RICH_FILTERS);
|
||||
expect(back.search).toBe("jazz");
|
||||
// the array shape survives the JSON round-trip, not collapsed to a scalar
|
||||
expect(Array.isArray(back.filters.tags)).toBe(true);
|
||||
});
|
||||
@@ -80,6 +102,7 @@ describe("round-trip", () => {
|
||||
view: "discovery",
|
||||
sort: null,
|
||||
filters: { sync: "needsFull" },
|
||||
search: "",
|
||||
});
|
||||
expect(back.view).toBe("discovery"); // from base, no ctab
|
||||
expect(back.filters).toEqual({ sync: "needsFull" }); // from base, no cf
|
||||
@@ -88,7 +111,7 @@ describe("round-trip", () => {
|
||||
|
||||
it("ignores an out-of-vocabulary tab / bad sort / corrupt cf and falls back to base", () => {
|
||||
const params = new URLSearchParams("ctab=bogus&csort=key:sideways&cf=notjson");
|
||||
const back = channelsParamsToState(params, { view: "subscribed", sort: null, filters: {} });
|
||||
const back = channelsParamsToState(params, BASE);
|
||||
expect(back.view).toBe("subscribed"); // bogus tab → base
|
||||
expect(back.sort).toBeNull(); // bad dir → parseSortState null → base (null)
|
||||
expect(back.filters).toEqual({}); // corrupt cf → decodeFilters heals to empty
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
// from the address bar so a manager view is bookmarkable/shareable — the analogue of the feed's
|
||||
// urlState and Plex's plexUrlState. Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're
|
||||
// channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the
|
||||
// feed's URL-stripping can't delete them out from under the manager. Only non-default values are
|
||||
// emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex
|
||||
// rail search) and stays OUT of the shared contract.
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
// feed's URL-stripping can't delete them out from under the manager. The tab, sort and filters are
|
||||
// always written, their defaults too: a page opened without them takes this browser's remembered view,
|
||||
// which may not be the one bookmarked. The name search (`cq`) is part of the view like the rest (a
|
||||
// bookmark of a searched list opens searched), but nothing remembers it, so only a term is written.
|
||||
import { decodeSortParamOrNone, encodeSortParamOrNone, type SortState } from "./columnSort";
|
||||
import { type FilterMap } from "./tableColumns";
|
||||
import { decodeFilterMap, encodeFilterMap } from "./filterMapParam";
|
||||
|
||||
// The content state this mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the
|
||||
// raw provider SortState (healed against live columns only at the point of use), so the user's real
|
||||
@@ -15,55 +17,29 @@ export interface ChannelsUrlState {
|
||||
view: string;
|
||||
sort: SortState;
|
||||
filters: FilterMap;
|
||||
search: string;
|
||||
}
|
||||
|
||||
const KEYS = ["ctab", "csort", "cf"] as const;
|
||||
export const CHANNELS_KEYS = ["ctab", "csort", "cf", "cq"] as const;
|
||||
const KEYS = CHANNELS_KEYS;
|
||||
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
|
||||
|
||||
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default
|
||||
// sort here (fallback null), so any sort emits — no default short-circuit needed.
|
||||
// The FilterMap mixes scalar column filters (priority=select, name=text, the sync chip) with array ones
|
||||
// (tags=multi), so it's carried as a compact JSON blob rather than a nested query string — JSON round-
|
||||
// trips both shapes exactly (and future multi columns for free), and the values are opaque enum/id
|
||||
// tokens where a readable querystring buys little. Empty entries are dropped so a cleared funnel emits
|
||||
// no `cf`.
|
||||
function encodeFilters(map: FilterMap): string | null {
|
||||
const clean: FilterMap = {};
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
if (Array.isArray(v) ? v.length : v) clean[k] = v;
|
||||
}
|
||||
return Object.keys(clean).length ? JSON.stringify(clean) : null;
|
||||
}
|
||||
// A URL blob is untrusted input: accept only a plain object of string / string[] entries, anything else
|
||||
// heals to empty (same discipline as useFilterState's storage hydrate).
|
||||
function decodeFilters(raw: string): FilterMap {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
||||
const out: FilterMap = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
if (typeof val === "string") out[k] = val;
|
||||
else if (Array.isArray(val) && val.every((x) => typeof x === "string"))
|
||||
out[k] = val as string[];
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`, `none` unsorted) via the shared codec in columnSort. The
|
||||
// filter map rides as one JSON value (lib/filterMapParam), an empty one as `cf=`.
|
||||
|
||||
/** The address-bar delta for the manager content state: each namespaced key mapped to its emitted
|
||||
* value, or null to delete it (so a control back at its default clears the key). Pass `null` to clear
|
||||
* value, or null to delete it (only the search's, when cleared). Pass `null` to clear
|
||||
* ALL manager keys — used when the app is off the Channels page (localStorage still holds the state; it
|
||||
* re-mirrors on return). Feed the result into urlWithParams(). */
|
||||
export function channelsParamUpdates(
|
||||
state: ChannelsUrlState | null,
|
||||
): Record<string, string | null> {
|
||||
if (!state) return { ctab: null, csort: null, cf: null };
|
||||
if (!state) return { ctab: null, csort: null, cf: null, cq: null };
|
||||
return {
|
||||
ctab: state.view && state.view !== "subscribed" ? state.view : null,
|
||||
csort: encodeSortParam(state.sort),
|
||||
cf: encodeFilters(state.filters),
|
||||
ctab: state.view || "subscribed",
|
||||
csort: encodeSortParamOrNone(state.sort),
|
||||
cf: encodeFilterMap(state.filters) ?? "",
|
||||
cq: state.search.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,10 +55,11 @@ export function channelsParamsToState(
|
||||
base: ChannelsUrlState,
|
||||
): ChannelsUrlState {
|
||||
const tab = params.get("ctab");
|
||||
const csort = params.get("csort");
|
||||
const linkedSort = decodeSortParamOrNone(params.get("csort"));
|
||||
return {
|
||||
view: tab && VIEWS.includes(tab) ? tab : base.view,
|
||||
sort: csort != null ? (decodeSortParam(csort) ?? base.sort) : base.sort,
|
||||
filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters,
|
||||
sort: linkedSort === undefined ? base.sort : linkedSort,
|
||||
filters: params.has("cf") ? decodeFilterMap(params.get("cf") ?? "") : base.filters,
|
||||
search: params.get("cq") ?? base.search,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +53,22 @@ export function decodeSortParam(raw: string): SortState {
|
||||
return parseSortState({ key, dir });
|
||||
}
|
||||
|
||||
// The list's own order (no column sort) as a word of its own, for a page whose sort is remembered (an
|
||||
// account pref, this browser's view): there the sort is always written, because a page opened without
|
||||
// one takes the remembered sort, which may not be the one bookmarked.
|
||||
const UNSORTED = "none";
|
||||
|
||||
export function encodeSortParamOrNone(s: SortState): string {
|
||||
return encodeSortParam(s) ?? UNSORTED;
|
||||
}
|
||||
|
||||
/** The sort an address names; `undefined` when it names none or a malformed one (the remembered one
|
||||
* applies). */
|
||||
export function decodeSortParamOrNone(raw: string | null): SortState | undefined {
|
||||
if (raw == null) return undefined;
|
||||
return raw === UNSORTED ? null : (decodeSortParam(raw) ?? undefined);
|
||||
}
|
||||
|
||||
/** The header-click cycle: unsorted → ascending → descending → unsorted. */
|
||||
export function cycleSort(current: SortState, key: string): SortState {
|
||||
if (!current || current.key !== key) return { key, dir: "asc" };
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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.
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// The file an address names open on the Files page: the player's key wins over the viewer's, and only a
|
||||
// real row id opens anything — a fractional, zero, negative or garbled one would look up a row that
|
||||
// cannot exist and say the file is gone.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openFileFrom } from "./filesUrlState";
|
||||
|
||||
const from = (q: string) => openFileFrom(new URLSearchParams(q));
|
||||
|
||||
describe("openFileFrom", () => {
|
||||
it("reads the viewer's and the player's file", () => {
|
||||
expect(from("fview=3")).toEqual({ open: "view", id: 3 });
|
||||
expect(from("fplay=4")).toEqual({ open: "play", id: 4 });
|
||||
expect(from("fview=3&fplay=4")).toEqual({ open: "play", id: 4 });
|
||||
});
|
||||
|
||||
it("opens nothing for an id that is not a positive integer", () => {
|
||||
for (const raw of ["1.5", "0", "-2", "abc", ""]) expect(from(`fview=${raw}`)).toBeNull();
|
||||
expect(from("q=x")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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
|
||||
// sort (`?fsort=`, always written, lib/columnSort's `none` for the list's own order). Here, beside the
|
||||
// other pages' codecs (lib/<id>UrlState), where the URL contract test reads them all.
|
||||
export const FILES_FOLDER_KEY = "folder";
|
||||
export const FILES_ITEM_KEY = "item";
|
||||
export const FILES_SEARCH_KEY = "fq";
|
||||
export const FILES_SORT_KEY = "fsort";
|
||||
// The file open over the list: in the viewer, or in the player. Each dialog names itself in its own
|
||||
// history entry (ui/Modal's `url`), so Back lands on the list's address without it.
|
||||
export const FILES_VIEW_KEY = "fview";
|
||||
export const FILES_PLAY_KEY = "fplay";
|
||||
export const FILES_KEYS = [
|
||||
FILES_FOLDER_KEY,
|
||||
FILES_ITEM_KEY,
|
||||
FILES_SEARCH_KEY,
|
||||
FILES_SORT_KEY,
|
||||
FILES_VIEW_KEY,
|
||||
FILES_PLAY_KEY,
|
||||
] as const;
|
||||
|
||||
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. */
|
||||
export function openFileFrom(params: URLSearchParams): FilesOpenFile | null {
|
||||
for (const [open, key] of [
|
||||
["play", FILES_PLAY_KEY],
|
||||
["view", FILES_VIEW_KEY],
|
||||
] as const) {
|
||||
const id = Number(params.get(key));
|
||||
if (Number.isInteger(id) && id > 0) return { open, id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type FilterMap } from "./tableColumns";
|
||||
|
||||
// A table's column-filter map as one address-bar value (the channel manager's `cf`, a playlist's `plf`).
|
||||
// The map mixes scalar column filters (select, text, the sync chip) with array ones (multi), so it's
|
||||
// carried as a compact JSON blob rather than a nested query string — JSON round-trips both shapes
|
||||
// exactly (and future multi columns for free) where a readable querystring buys little. A text
|
||||
// column's entry is what the user typed, so a map with one is a typed key (ModuleDef.typedUrlKeys).
|
||||
// Empty entries are dropped so a cleared funnel emits nothing.
|
||||
export function encodeFilterMap(map: FilterMap): string | null {
|
||||
const clean: FilterMap = {};
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
if (Array.isArray(v) ? v.length : v) clean[k] = v;
|
||||
}
|
||||
return Object.keys(clean).length ? JSON.stringify(clean) : null;
|
||||
}
|
||||
|
||||
// A URL blob is untrusted input: accept only a plain object of string / string[] entries, anything else
|
||||
// heals to empty (same discipline as useFilterState's storage hydrate).
|
||||
export function decodeFilterMap(raw: string): FilterMap {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
||||
const out: FilterMap = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
if (typeof val === "string") out[k] = val;
|
||||
else if (Array.isArray(val) && val.every((x) => typeof x === "string"))
|
||||
out[k] = val as string[];
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@
|
||||
{ "allowShortCircuit": false, "allowTaggedTemplates": false, "allowTernary": false }
|
||||
],
|
||||
"playwright/no-focused-test": [2],
|
||||
"playwright/no-skipped-test": [2]
|
||||
"playwright/no-skipped-test": [2, { "allowConditional": false, "disallowFixme": true }]
|
||||
},
|
||||
"settings": null
|
||||
},
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// An overlay that is part of the view (a player) names itself in its own history entry's address, so a
|
||||
// reload or a bookmark opens it — and the entry under it never carries those keys, so Back lands on the
|
||||
// page as it was. Driven through jsdom's real history: its back()/forward() land their popstate a
|
||||
// task later, like a browser's.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useBackToClose } from "./history";
|
||||
|
||||
const settle = () =>
|
||||
act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
// Step through the history the way the browser does, and wait for its popstate to land.
|
||||
const step = (go: () => void) =>
|
||||
act(async () => {
|
||||
const landed = new Promise((r) => window.addEventListener("popstate", r, { once: true }));
|
||||
go();
|
||||
await landed;
|
||||
await Promise.resolve();
|
||||
});
|
||||
const search = () => new URLSearchParams(window.location.search);
|
||||
|
||||
/** A player-like overlay whose close really unmounts it, naming `url` in its entry. */
|
||||
function mountPlayer(initial: Record<string, string | null>) {
|
||||
const holder: { unmount?: () => void } = {};
|
||||
const r = renderHook(
|
||||
(url: Record<string, string | null>) => useBackToClose(() => holder.unmount?.(), url),
|
||||
{ initialProps: initial },
|
||||
);
|
||||
holder.unmount = r.unmount;
|
||||
return r;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
describe("an overlay named in its entry's address", () => {
|
||||
it("is named in its own entry, and Back lands on the address under it without it", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
|
||||
const depth = window.history.length;
|
||||
mountPlayer({ watch: "v1", wtab: null });
|
||||
await settle();
|
||||
expect(window.history.length).toBe(depth + 1);
|
||||
expect(search().get("watch")).toBe("v1");
|
||||
expect(search().get("q")).toBe("music");
|
||||
// What a page reads as Back lands (a host mounting there reopens a player its address names):
|
||||
// the entry under the player never carried it, so there is nothing to clear first.
|
||||
let landedOn = "";
|
||||
window.addEventListener("popstate", () => (landedOn = window.location.search), { once: true });
|
||||
await step(() => window.history.back());
|
||||
expect(landedOn).toBe("?q=music");
|
||||
expect(search().has("watch")).toBe(false);
|
||||
expect(search().get("q")).toBe("music");
|
||||
});
|
||||
|
||||
it("follows what it names as it changes", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
|
||||
const r = mountPlayer({ watch: "v1", wtab: null });
|
||||
await settle();
|
||||
r.rerender({ watch: "v2", wtab: "related" });
|
||||
expect(search().get("watch")).toBe("v2");
|
||||
expect(search().get("wtab")).toBe("related");
|
||||
r.rerender({ watch: "v2", wtab: null });
|
||||
expect(search().has("wtab")).toBe(false);
|
||||
await step(() => r.unmount());
|
||||
expect(search().has("watch")).toBe(false);
|
||||
expect(search().get("q")).toBe("music");
|
||||
});
|
||||
|
||||
it("closed by its own control, leaves the entry under it as it was", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
|
||||
const r = mountPlayer({ watch: "v1" });
|
||||
await settle();
|
||||
await step(() => r.unmount());
|
||||
expect(window.location.search).toBe("?q=music");
|
||||
});
|
||||
|
||||
it("an entry that still names it after it closed is cleared once it is the current one", async () => {
|
||||
// A reload under an open player: the entry the new document starts from names the player.
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&watch=v1");
|
||||
const r = mountPlayer({ watch: "v1" });
|
||||
await settle();
|
||||
await step(() => r.unmount());
|
||||
expect(search().has("watch")).toBe(false);
|
||||
// Forward onto the closed player's entry: nothing is open there, so neither is it named.
|
||||
await step(() => window.history.forward());
|
||||
expect(search().has("watch")).toBe(false);
|
||||
expect(search().get("q")).toBe("music");
|
||||
});
|
||||
});
|
||||
@@ -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,37 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload under two overlays: the player reopens on the upper one's entry, the lower one's is dead
|
||||
// under it. A browser Back closes the player — and steps over the dead entry too, onto the page, as
|
||||
// the player's own close does. Its own file: the entry is read as the module loads, so it is set up
|
||||
// before the import.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
const landing = () =>
|
||||
new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||
|
||||
describe("a browser Back over an overlay reopened on the entry a reload under two started from", () => {
|
||||
it("lands on the page under both", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
|
||||
window.history.pushState({ sfPage: "feed", _ov: 1, _ovd: 1 }, "", "/?page=feed&watch=v1");
|
||||
window.history.pushState({ sfPage: "feed", _ov: 2, _ovd: 2 }, "", "/?page=feed&watch=v1");
|
||||
const { useBackToClose } = await import("./history");
|
||||
|
||||
let closed = false;
|
||||
renderHook(() => useBackToClose(() => (closed = true), { watch: "v1" }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const first = landing();
|
||||
window.history.back();
|
||||
await first;
|
||||
await landing(); // the step over the dead entry
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(closed).toBe(true);
|
||||
expect(window.history.state?._ov).toBeUndefined();
|
||||
expect(window.location.search).toBe("?page=feed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload under two overlays, in a tab whose entries below the dead one are not this document's
|
||||
// (Chrome dropped them, see backStaysInDocument): closing the reopened player cannot step over both,
|
||||
// so it steps back one — onto the dead entry, still in the app — rather than leaving it. Its own file:
|
||||
// the entry is read as the module loads, so it is set up before the import.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "navigation");
|
||||
});
|
||||
|
||||
describe("an overlay reopened over a dead entry with nothing of this document below it", () => {
|
||||
it("closes one step back, staying in the document", async () => {
|
||||
window.history.replaceState({ sfPage: "feed", _ov: 1, _ovd: 1 }, "", "/?page=feed&watch=v1");
|
||||
window.history.pushState({ sfPage: "feed", _ov: 2, _ovd: 2 }, "", "/?page=feed&watch=v1");
|
||||
const { useBackToClose } = await import("./history");
|
||||
// Two steps back would leave the document; one stays in it.
|
||||
Object.defineProperty(window, "navigation", {
|
||||
configurable: true,
|
||||
value: {
|
||||
currentEntry: { index: 2 },
|
||||
entries: () => [{ sameDocument: false }, { sameDocument: true }, { sameDocument: true }],
|
||||
},
|
||||
});
|
||||
const go = vi.spyOn(window.history, "go");
|
||||
const back = vi.spyOn(window.history, "back");
|
||||
|
||||
const r = renderHook(() => useBackToClose(() => {}, { watch: "v1" }));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||
r.unmount();
|
||||
await landed;
|
||||
});
|
||||
expect(go).not.toHaveBeenCalled();
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
expect(window.history.state?._ov).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload under an open overlay (a player the address names) loads the document on that overlay's
|
||||
// entry, and the page opens the overlay again. It takes that entry over: pushed over it instead, Back
|
||||
// would close the player onto the same page once more — one dead step for every reload. Its own file:
|
||||
// the entry is read as the module loads, so it is set up before the import.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
const search = () => new URLSearchParams(window.location.search);
|
||||
|
||||
describe("an overlay reopened on the entry a reload started from", () => {
|
||||
it("takes that entry over, and Back lands on the page under it", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
|
||||
window.history.pushState({ sfPage: "feed", _ov: 3 }, "", "/?q=music&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);
|
||||
expect(search().get("watch")).toBe("v1");
|
||||
|
||||
await act(async () => {
|
||||
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||
window.history.back();
|
||||
await landed;
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(window.location.search).toBe("?q=music");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe("useHistorySubview seed", () => {
|
||||
});
|
||||
|
||||
it("prefers history.state._sub over the URL param (in-app entry)", () => {
|
||||
setUrl("?v=a", { _sub: "b" });
|
||||
setUrl("?v=a", { _sub: "b", _subOf: "v" });
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
expect(result.current.view).toBe("b");
|
||||
});
|
||||
@@ -63,7 +63,7 @@ describe("useHistorySubview seed", () => {
|
||||
});
|
||||
|
||||
it("ignores the URL entirely when no SubviewUrl is supplied (state-only mirror)", () => {
|
||||
setUrl("?v=a", { _sub: "fromState" });
|
||||
setUrl("?v=a", { _sub: "fromState", _subOf: "" });
|
||||
const { result } = renderHook(() => useHistorySubview("root"));
|
||||
expect(result.current.view).toBe("fromState");
|
||||
// A URL param with no url config must NOT leak in.
|
||||
@@ -128,6 +128,64 @@ describe("useHistorySubview open", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// One entry, two modules: the sign-in card mounts on the entry a Plex season stood on (signed out
|
||||
// there), and Plex on one the card's register view stood on (signed in from it). Each reads only its
|
||||
// own sub-view: another's is not one of its views (the card rendered a mode it has no button for).
|
||||
describe("useHistorySubview on an entry another module's sub-view stood on", () => {
|
||||
const other: SubviewUrl<string> = {
|
||||
key: "m",
|
||||
encode: (v) => (v === "signin" ? null : v),
|
||||
decode: (raw) => (raw === "register" ? raw : null),
|
||||
};
|
||||
|
||||
it("opens at its own root, not on the other's view", () => {
|
||||
const plex = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => plex.result.current.open("season"));
|
||||
plex.unmount();
|
||||
const card = renderHook(() => useHistorySubview("signin", other));
|
||||
expect(card.result.current.view).toBe("signin");
|
||||
});
|
||||
|
||||
it("the other way round too", () => {
|
||||
const card = renderHook(() => useHistorySubview("signin", other));
|
||||
act(() => card.result.current.open("register"));
|
||||
card.unmount();
|
||||
window.history.replaceState(window.history.state, "", "/");
|
||||
const plex = renderHook(() => useHistorySubview("root", url));
|
||||
expect(plex.result.current.view).toBe("root");
|
||||
});
|
||||
|
||||
it("an older build's entry, which says nothing: its own only when its address names its view", () => {
|
||||
setUrl("?v=season", { _sub: "season" });
|
||||
expect(renderHook(() => useHistorySubview("signin", other)).result.current.view).toBe("signin");
|
||||
expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season");
|
||||
});
|
||||
|
||||
it("an older build's entry whose address names both: the _sub goes to the one it is the view of", () => {
|
||||
// The card's push kept the season's `v` beside its own `m`.
|
||||
setUrl("?v=season&m=register", { _sub: "register" });
|
||||
expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season");
|
||||
expect(renderHook(() => useHistorySubview("signin", other)).result.current.view).toBe(
|
||||
"register",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads its own from an entry it rewrote in place, which no address names", () => {
|
||||
const first = renderHook(() => useHistorySubview("root"));
|
||||
act(() => first.result.current.open("x", { replace: true }));
|
||||
first.unmount();
|
||||
expect(renderHook(() => useHistorySubview("root")).result.current.view).toBe("x");
|
||||
});
|
||||
|
||||
it("still reads its own", () => {
|
||||
const plex = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => plex.result.current.open("season"));
|
||||
plex.unmount();
|
||||
window.history.replaceState(window.history.state, "", "/");
|
||||
expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useHistorySubview popstate", () => {
|
||||
it("restores the sub-view from state._sub on Back/Forward", () => {
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
@@ -185,6 +243,84 @@ describe("useHistorySubview entry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A view that moves on by itself — the Plex player stepping to the next episode — is still the same
|
||||
// entry: no push, nothing for Back to step through. But the entry must say where it moved, or a reload
|
||||
// reopens the item the viewer started with (the owner, 2026-10-25: F5 during a binge restarted the
|
||||
// first episode).
|
||||
describe("useHistorySubview follow", () => {
|
||||
async function overlayOver() {
|
||||
const closed = vi.fn();
|
||||
const layer = renderHook(() => useBackToClose(closed));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
return { closed, layer };
|
||||
}
|
||||
|
||||
it("rewrites the entry it stands on, so a reload reopens the view it moved on to", () => {
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
const before = window.history.length;
|
||||
act(() => result.current.follow("b"));
|
||||
expect(result.current.view).toBe("b");
|
||||
expect(window.history.state?._sub).toBe("b");
|
||||
expect(window.location.search).toBe("?v=b");
|
||||
expect(window.history.length).toBe(before);
|
||||
const { result: reloaded } = renderHook(() => useHistorySubview("root", url));
|
||||
expect(reloaded.current.view).toBe("b");
|
||||
});
|
||||
|
||||
it("is not a navigation: an overlay over the view stays open", async () => {
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
const { closed, layer } = await overlayOver();
|
||||
act(() => result.current.follow("b"));
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
expect(closed).not.toHaveBeenCalled();
|
||||
expect(window.history.state?._ov).toBeDefined();
|
||||
// A reload with the overlay up reopens the view it moved on to as well.
|
||||
expect(window.history.state?._sub).toBe("b");
|
||||
layer.unmount(); // the history goes back to the view's own entry before the next test
|
||||
await waitFor(() => expect(window.history.state?._ov).toBeUndefined());
|
||||
});
|
||||
|
||||
it("the entry under an overlay follows too: closing the overlay lands on the moved-on view", async () => {
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
const { layer } = await overlayOver();
|
||||
act(() => result.current.follow("b"));
|
||||
layer.unmount(); // the overlay closes: its entry goes by a back() onto the view's own entry
|
||||
await waitFor(() => expect(window.history.state?._ov).toBeUndefined());
|
||||
expect(result.current.view).toBe("b");
|
||||
expect(window.history.state?._sub).toBe("b");
|
||||
expect(window.location.search).toBe("?v=b");
|
||||
});
|
||||
|
||||
it("a navigation on the same entry ends it: an overlay closing over it lands on the new view", async () => {
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
act(() => result.current.follow("b"));
|
||||
act(() => result.current.open("c", { replace: true }));
|
||||
const { layer } = await overlayOver();
|
||||
layer.unmount();
|
||||
await waitFor(() => expect(window.history.state?._ov).toBeUndefined());
|
||||
expect(result.current.view).toBe("c");
|
||||
expect(window.history.state?._sub).toBe("c");
|
||||
});
|
||||
|
||||
it("does not follow onto another entry: Back to the view below restores that one", async () => {
|
||||
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||
act(() => result.current.open("a"));
|
||||
act(() => result.current.open("p"));
|
||||
act(() => result.current.follow("q"));
|
||||
act(() => window.history.back());
|
||||
await waitFor(() => expect(result.current.view).toBe("a"));
|
||||
expect(window.history.state?._sub).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
// Chrome keeps 50 entries a tab; when full, a new entry drops the oldest "skippable" one — one the page
|
||||
// left by a pushState with no user gesture — and that can be this page's own entry under the
|
||||
// sub-view. back() then left the document for an older one (measured 2026-10-07: its restored Plex
|
||||
|
||||
+189
-33
@@ -6,10 +6,11 @@
|
||||
// a module (e.g. the sign-in card's "forgot password" → sign-in) returns to the parent view first.
|
||||
// - useBackToClose: while an overlay/modal is mounted it occupies one history entry; Back
|
||||
// closes the topmost one. A module-level stack keeps nesting correct — closing one modal by
|
||||
// its own button pops exactly its entry without tripping the modals underneath.
|
||||
// its own button pops exactly its entry without tripping the modals underneath. An overlay
|
||||
// that is part of the view (a player) names itself in its entry's address.
|
||||
//
|
||||
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
|
||||
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
|
||||
import { urlWithParams } from "./urlState";
|
||||
import { useLatest } from "./useLatest";
|
||||
@@ -86,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
|
||||
* 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> {
|
||||
// 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.
|
||||
@@ -94,7 +98,8 @@ export function rememberNavPosition(): Record<string, unknown> {
|
||||
const { sfNav: _stale, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
|
||||
const pos = navPosition();
|
||||
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? --------------------------------------------
|
||||
@@ -110,12 +115,12 @@ interface NavigationLike {
|
||||
entries: () => readonly { sameDocument: boolean }[];
|
||||
}
|
||||
|
||||
/** Would one more history.back() still land in this document? */
|
||||
export function backStaysInDocument(): boolean {
|
||||
/** Would `steps` more steps back (one, a history.back()) still land in this document? */
|
||||
export function backStaysInDocument(steps = 1): boolean {
|
||||
const nav = (window as { navigation?: NavigationLike }).navigation;
|
||||
const at = nav?.currentEntry?.index;
|
||||
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) ---------------------------------------
|
||||
@@ -134,6 +139,9 @@ export interface SubviewUrl<T> {
|
||||
entry?: (view: T) => T;
|
||||
}
|
||||
|
||||
/** Whose sub-view an entry's `_sub` is: the key its module mirrors to (a state-only one has none). */
|
||||
const subOwner = (url: { key: string } | undefined): string => url?.key ?? "";
|
||||
|
||||
export function useHistorySubview<T>(
|
||||
root: T,
|
||||
url?: SubviewUrl<T>,
|
||||
@@ -143,6 +151,9 @@ export function useHistorySubview<T>(
|
||||
* continuation of the navigation that just arrived here, not a step the user took inside the
|
||||
* module. See the `jumpTo` hand-off in PlexBrowse. */
|
||||
open: (next: T, opts?: { replace?: boolean }) => void;
|
||||
/** The view moved on by itself (the Plex player stepped to the next episode): the entry it stands
|
||||
* on says so from now on, without a navigation — the overlays over it stay open. */
|
||||
follow: (next: T) => void;
|
||||
back: () => void;
|
||||
} {
|
||||
// Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the
|
||||
@@ -150,18 +161,35 @@ export function useHistorySubview<T>(
|
||||
// ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a
|
||||
// bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and
|
||||
// reading history.state alone would wrongly collapse the bookmarked sub-view to root.
|
||||
// The entry's _sub is this module's only when it says so (`_subOf`): one entry can carry another
|
||||
// module's — the sign-in card mounts on the entry a Plex season stood on when signed out there, and
|
||||
// took that season for one of its modes (a card with no button for it). An entry an older build
|
||||
// wrote says nothing: its _sub is this module's when it is the view the address names under this
|
||||
// module's key. The key alone is not enough: the card's push kept the season's `v` beside its `m`.
|
||||
const readView = (): T => {
|
||||
const fromState = window.history.state?._sub as T | undefined;
|
||||
const st = window.history.state;
|
||||
const raw = url ? new URLSearchParams(window.location.search).get(url.key) : null;
|
||||
const decoded = url && raw != null ? url.decode(raw) : null;
|
||||
const owned =
|
||||
st?._subOf === undefined
|
||||
? decoded != null && st?._sub != null && url?.encode(st._sub as T) === raw
|
||||
: st._subOf === subOwner(url);
|
||||
const fromState = owned ? (st?._sub as T | undefined) : undefined;
|
||||
if (fromState != null) return fromState;
|
||||
if (url) {
|
||||
const raw = new URLSearchParams(window.location.search).get(url.key);
|
||||
const decoded = raw != null ? url.decode(raw) : null;
|
||||
if (decoded != null) return decoded;
|
||||
}
|
||||
if (decoded != null) return decoded;
|
||||
return root;
|
||||
};
|
||||
const [view, setView] = useState<T>(readView);
|
||||
const readLandedView = useEffectEvent(readView);
|
||||
// Where `follow` moved the view, and on which entry. An overlay over the view holds an entry of its
|
||||
// own (a copy of the view's state, same sfScroll id), and only the current entry can be rewritten:
|
||||
// the view's own entry under the overlay still says where it was until a step lands back on it.
|
||||
const followedRef = useRef<{ entry: number; view: T } | null>(null);
|
||||
const readLandedView = useEffectEvent((): T => {
|
||||
const followed = followedRef.current;
|
||||
if (followed == null || peekNavId() !== followed.entry) return readView();
|
||||
writeSubEntry(followed.view, url);
|
||||
return followed.view;
|
||||
});
|
||||
useEffect(() => {
|
||||
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
|
||||
const onPop = () => setView(readLandedView());
|
||||
@@ -182,20 +210,19 @@ export function useHistorySubview<T>(
|
||||
const go = useCallback(
|
||||
(next: T, opts?: { replace?: boolean }) => {
|
||||
setView(next);
|
||||
const kept = url?.entry ? url.entry(next) : next;
|
||||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||||
// A navigation says where the view is now, on the entry it lands on or rewrites.
|
||||
followedRef.current = null;
|
||||
if (opts?.replace) {
|
||||
// Same ENTRY, so it keeps its sfScroll id: PageScroller keys offsets per (entry, view), and
|
||||
// the view key has changed, so the sub-view still opens at the top without minting an id
|
||||
// that would orphan the one this entry already carries.
|
||||
window.history.replaceState({ ...window.history.state, _sub: kept }, "", href);
|
||||
writeSubEntry(next, url);
|
||||
notifyNav();
|
||||
return;
|
||||
}
|
||||
const kept = url?.entry ? url.entry(next) : next;
|
||||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||||
// A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back
|
||||
// restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
|
||||
window.history.pushState(
|
||||
{ ...rememberNavPosition(), _sub: kept, sfScroll: nextNavId() },
|
||||
{ ...rememberNavPosition(), _sub: kept, _subOf: subOwner(url), sfScroll: nextNavId() },
|
||||
"",
|
||||
href,
|
||||
);
|
||||
@@ -218,9 +245,26 @@ export function useHistorySubview<T>(
|
||||
}),
|
||||
[go, rootRef],
|
||||
);
|
||||
const follow = useCallback(
|
||||
(next: T) => {
|
||||
setView(next);
|
||||
followedRef.current = { entry: ensureNavId(), view: next };
|
||||
writeSubEntry(next, url);
|
||||
},
|
||||
[url],
|
||||
);
|
||||
// Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves"
|
||||
// can now write `[sub]` and mean it, instead of re-running on every parent render.
|
||||
return useMemo(() => ({ view, open, back }), [view, open, back]);
|
||||
return useMemo(() => ({ view, open, follow, back }), [view, open, follow, back]);
|
||||
}
|
||||
|
||||
// Rewrite the CURRENT entry to hold `next`. Same entry, so it keeps its sfScroll id: PageScroller keys
|
||||
// offsets per (entry, view), and the view key has changed, so the sub-view still opens at the top
|
||||
// without minting an id that would orphan the one this entry already carries.
|
||||
function writeSubEntry<T>(next: T, url: SubviewUrl<T> | undefined): void {
|
||||
const kept = url?.entry ? url.entry(next) : next;
|
||||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||||
replaceView({ ...window.history.state, _sub: kept, _subOf: subOwner(url) }, href);
|
||||
}
|
||||
|
||||
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
|
||||
@@ -245,16 +289,69 @@ export function useHistorySubview<T>(
|
||||
// both undid the navigation AND closed the dialog above it, and the dead Back press after that (the
|
||||
// last two measured in Chrome). With nothing ever buried, the stack and the history agree by
|
||||
// construction.
|
||||
type Overlay = { token: number; cb: () => void };
|
||||
// `url`: the keys an overlay names in the address of its own entry (and of the entries above it).
|
||||
type Overlay = { token: number; cb: () => void; url?: OverlayUrl };
|
||||
type OverlayUrl = Record<string, string | null>;
|
||||
const overlayStack: Overlay[] = [];
|
||||
// Every key an overlay has named in this document: an entry carries those of the overlays it stands
|
||||
// for and none of the rest. The entry under an overlay is never written with its keys, so Back lands
|
||||
// on an address without it; and one that still names a closed overlay (the entry a reload under an
|
||||
// open player started from, a Forward onto an entry whose overlay is gone) is cleared once it is
|
||||
// the current entry.
|
||||
const overlayUrlKeys = new Set<string>();
|
||||
let overlayCounter = 0;
|
||||
// A plain function, not `++overlayCounter` inline: the React Compiler cannot lower an update of a
|
||||
// module variable inside a hook, and skipped the whole hook for it (react-hooks/todo).
|
||||
const nextOverlayToken = () => ++overlayCounter;
|
||||
// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
|
||||
let historyDepth = 0;
|
||||
// Programmatic history.back() calls still awaiting their popstate — each must be ignored.
|
||||
// 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
|
||||
// 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;
|
||||
const reloadedDepth: unknown = window.history.state?._ovd;
|
||||
let deadBelow = 0;
|
||||
|
||||
/** Rewrite the current entry in place for another view (a refined search, another channel, a
|
||||
* sub-view followed): `state` and `href` as replaceState takes them. With no overlay open, the
|
||||
* entry's overlay marks go — the ones a reload under an overlay left. They named the view it was
|
||||
* left from, and the first overlay over the new one would take this entry over: closed, it stepped
|
||||
* back onto the entry below and the screen went back to the view before the rewrite. */
|
||||
export function replaceView(state: Record<string, unknown>, href?: string): void {
|
||||
if (historyDepth > 0) {
|
||||
window.history.replaceState(state, "", href);
|
||||
return;
|
||||
}
|
||||
reloadedOverlayEntry = null;
|
||||
const { _ov: _overlay, _ovd: _depth, ...view } = state;
|
||||
window.history.replaceState(view, "", href);
|
||||
}
|
||||
// Programmatic history steps back still awaiting their popstate — each must be ignored — and how
|
||||
// many entries they step over in all (a step over dead entries is one traversal of several).
|
||||
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 listenerInstalled = false;
|
||||
// Re-checked after every reconcile — see `afterClosing`.
|
||||
@@ -270,22 +367,59 @@ function reconcileHistory(): void {
|
||||
const want = overlayStack.length;
|
||||
while (historyDepth < want && pendingProgrammatic === 0) {
|
||||
historyDepth++;
|
||||
window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, "");
|
||||
const here = window.history.state?._ov;
|
||||
if (historyDepth === 1 && here != null && here === reloadedOverlayEntry) {
|
||||
reloadedOverlayEntry = null;
|
||||
if (typeof reloadedDepth === "number" && reloadedDepth > 1) deadBelow = reloadedDepth - 1;
|
||||
continue;
|
||||
}
|
||||
reloadedOverlayEntry = null;
|
||||
window.history.pushState(
|
||||
{ ...window.history.state, _ov: nextOverlayToken(), _ovd: historyDepth + deadBelow },
|
||||
"",
|
||||
);
|
||||
}
|
||||
while (historyDepth > want) {
|
||||
historyDepth--;
|
||||
if (backStaysInDocument()) {
|
||||
pendingProgrammatic++;
|
||||
window.history.back();
|
||||
} else {
|
||||
const steps = stepsToClose();
|
||||
if (backStaysInDocument(steps)) stepBack(steps);
|
||||
else if (steps > 1 && backStaysInDocument()) stepBack(1);
|
||||
else {
|
||||
// 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, "");
|
||||
}
|
||||
}
|
||||
nameOverlaysHere();
|
||||
syncWaiters.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// The address of the entry the open overlays stand on: their keys, every other overlay's cleared.
|
||||
// Undefined (the address as it is) while no overlay names any. Written by `nameOverlaysHere` alone:
|
||||
// a pushed entry starts as a copy of the one under it and is named once it is the current one.
|
||||
function overlayHref(): string | undefined {
|
||||
if (overlayUrlKeys.size === 0) return undefined;
|
||||
const updates: OverlayUrl = {};
|
||||
for (const k of overlayUrlKeys) updates[k] = null;
|
||||
for (const o of overlayStack) Object.assign(updates, o.url);
|
||||
return urlWithParams(updates);
|
||||
}
|
||||
|
||||
// Write the current entry's overlay keys, once the history has caught up with the stack (before
|
||||
// that, the current entry may be one about to be pushed over or stepped back from).
|
||||
function nameOverlaysHere(): void {
|
||||
if (historyDepth !== overlayStack.length || pendingProgrammatic > 0) return;
|
||||
const href = overlayHref();
|
||||
if (href != null && href !== hrefNow())
|
||||
window.history.replaceState(window.history.state, "", href);
|
||||
}
|
||||
|
||||
const hrefNow = () => window.location.pathname + window.location.search + window.location.hash;
|
||||
|
||||
function scheduleReconcile(): void {
|
||||
if (reconcileScheduled) return;
|
||||
reconcileScheduled = true;
|
||||
@@ -296,15 +430,21 @@ function ensureOverlayListener(): void {
|
||||
if (listenerInstalled) return;
|
||||
listenerInstalled = true;
|
||||
window.addEventListener("popstate", () => {
|
||||
reloadedOverlayEntry = null;
|
||||
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.
|
||||
if (pendingProgrammatic === 0 && historyDepth < overlayStack.length) scheduleReconcile();
|
||||
else nameOverlaysHere();
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
// The last one closing over dead entries (see `deadBelow`) steps over them too.
|
||||
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();
|
||||
if (top) top.cb();
|
||||
scheduleReconcile();
|
||||
@@ -421,15 +561,21 @@ export function closeTopOverlayThenRun(close: () => void, run: () => void): void
|
||||
}
|
||||
|
||||
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via
|
||||
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */
|
||||
export function useBackToClose(onClose: () => void): void {
|
||||
* `onClose` instead of navigating away. Mount the component only while the overlay is open.
|
||||
* `url`: what the overlay names in its entry's address (a null value: the key absent), followed as
|
||||
* it changes; the entry under it never carries those keys. */
|
||||
export function useBackToClose(onClose: () => void, url?: OverlayUrl): void {
|
||||
const cb = useEffectEvent(onClose);
|
||||
const named = useEffectEvent(() => url);
|
||||
const entryRef = useRef<Overlay | null>(null);
|
||||
useEffect(() => {
|
||||
ensureOverlayListener();
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
|
||||
entryRef.current = entry;
|
||||
overlayStack.push(entry);
|
||||
scheduleReconcile();
|
||||
return () => {
|
||||
entryRef.current = null;
|
||||
const idx = overlayStack.indexOf(entry);
|
||||
// idx === -1 means the shared handler already removed us on a real user Back; either way,
|
||||
// just re-sync history to the (possibly unchanged) overlay count on the next tick.
|
||||
@@ -437,6 +583,16 @@ export function useBackToClose(onClose: () => void): void {
|
||||
scheduleReconcile();
|
||||
};
|
||||
}, []);
|
||||
// After the effect above (declaration order), so the first run names the entry before it is pushed.
|
||||
const urlDep = url ? JSON.stringify(url) : "";
|
||||
useEffect(() => {
|
||||
const entry = entryRef.current;
|
||||
const next = named();
|
||||
if (!entry || !next) return;
|
||||
entry.url = next;
|
||||
for (const k of Object.keys(next)) overlayUrlKeys.add(k);
|
||||
nameOverlaysHere();
|
||||
}, [urlDep]);
|
||||
}
|
||||
|
||||
/** A Back-closable layer that exists only WHILE `active`. Stacks on top of `useBackToClose` and any
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Home's time budget in the address bar (`?page=home&hb=short`): a page SETTING, like a feed filter,
|
||||
// so it is a link and survives a reload. What Home draws under it is not: the question's seed, the
|
||||
// press and the cards already offered stay on the history entry (Home.tsx), because the same question
|
||||
// draws other cards once what you have watched moves on — an address cannot promise "that view".
|
||||
import { DISCOVER_BUDGETS, type DiscoverBudget } from "./api";
|
||||
|
||||
const HOME_BUDGET_KEY = "hb";
|
||||
export const HOME_KEYS = [HOME_BUDGET_KEY] as const;
|
||||
export const DEFAULT_BUDGET: DiscoverBudget = "any";
|
||||
|
||||
/** The budget a URL names, or null when it names none (or none Home offers). */
|
||||
export function budgetFrom(params: URLSearchParams): DiscoverBudget | null {
|
||||
const raw = params.get(HOME_BUDGET_KEY);
|
||||
return DISCOVER_BUDGETS.includes(raw as DiscoverBudget) ? (raw as DiscoverBudget) : null;
|
||||
}
|
||||
|
||||
/** The URL update for a budget: the default clears its key. */
|
||||
export function budgetParams(budget: DiscoverBudget): Record<string, string | null> {
|
||||
return { [HOME_BUDGET_KEY]: budget === DEFAULT_BUDGET ? null : budget };
|
||||
}
|
||||
@@ -10,9 +10,12 @@ import { installClientLog } from "./logHooks";
|
||||
const appCopy = (text: string | null) =>
|
||||
["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null;
|
||||
|
||||
// The pages' typed keys, as main.tsx hands them over from the module definitions.
|
||||
const TYPED = ["q", "fq", "yt"];
|
||||
|
||||
beforeAll(() => {
|
||||
installClientLog(appCopy);
|
||||
installClientLog(appCopy); // a hot reload's second call must not wrap History twice
|
||||
installClientLog(appCopy, TYPED);
|
||||
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"]);
|
||||
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", () => {
|
||||
window.history.pushState({ sfPage: "feed", _ov: 1 }, "");
|
||||
// What Back does: the browser restores the previous entry's state (the unwrapped prototype
|
||||
|
||||
@@ -13,9 +13,10 @@ import { FRONTEND_SHA, FRONTEND_VERSION } from "./version";
|
||||
import { enabled, errorAttrs, log } from "./log";
|
||||
import { pathTemplate } from "./http";
|
||||
|
||||
// URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes").
|
||||
// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG.
|
||||
const TYPED_PARAMS = new Set(["q", "keywords", "search"]);
|
||||
// URL parameters that carry what the user TYPED — a search, a column's text filter. Logged only at
|
||||
// TRACE ("Teljes"). Every other parameter is filter/sort/view state: its NAME is kept at INFO, its
|
||||
// value at DEBUG. The shell has none; each page's join at install (ModuleDef.typedUrlKeys).
|
||||
const TYPED_PARAMS = new Set<string>();
|
||||
|
||||
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.
|
||||
* `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this
|
||||
* 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;
|
||||
installed = true;
|
||||
for (const k of typedParams) TYPED_PARAMS.add(k);
|
||||
|
||||
log.info("app.start", "app loaded", {
|
||||
version: FRONTEND_VERSION,
|
||||
|
||||
@@ -44,4 +44,18 @@ export type ModuleDef = {
|
||||
// Static, unlike the page: it is on the boot path by design. It reads no other module's store, so
|
||||
// the order the stores nest in does not matter (moduleStores.contract.test).
|
||||
Store?: ComponentType<{ children: ReactNode }>;
|
||||
// Every address-bar key this page's view writes: its filters, sort, tab, the item it shows. The URL
|
||||
// IS the view, so a bookmark reopens it. The shell clears them all on a page switch, and opens a page
|
||||
// with no other page's keys in its address (NavigationProvider), so one page's state never rides
|
||||
// along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to
|
||||
// one module and none is a shell key (moduleUrl.contract.test).
|
||||
urlKeys: readonly string[];
|
||||
// Those of them that carry what the user TYPED (a search box's text, a column filter map holding a
|
||||
// text filter): the client log keeps their values out below its most detailed level (lib/logHooks).
|
||||
// moduleUrl.contract.test sorts every key of urlKeys as typed or not, so a new one is decided.
|
||||
typedUrlKeys?: readonly string[];
|
||||
// Its page plays YouTube videos in the player and reopens it from an address that names one (a
|
||||
// reload, a bookmark — lib/playerUrlState), with its own list as the queue. A page without it gets
|
||||
// the shell's player for such an address: the video alone.
|
||||
playsVideos?: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// The YouTube player's address-bar keys. The player opens over any page (the feed, a channel page, Home,
|
||||
// a playlist, a channel's watched videos), so its keys are the shell's: no module owns them, a page
|
||||
// switch clears them, and the page under it keeps its own. The player names them in its own history
|
||||
// entry (lib/history's `useBackToClose`), so Back lands on the page as it was, without the player.
|
||||
// The list it plays from is the page's: the feed's filters, the open playlist (`pl`) — already named.
|
||||
// No position: the player resumes from the server's (`video_states`), which is where it was left.
|
||||
const PLAYER_WATCH_KEY = "watch"; // the item of the list playing now
|
||||
const PLAYER_RELATED_KEY = "wrel"; // a related video played in place over it
|
||||
const PLAYER_PANEL_KEY = "wtab"; // the wide panel's tab, when it is not the description
|
||||
export const PLAYER_KEYS = [PLAYER_WATCH_KEY, PLAYER_RELATED_KEY, PLAYER_PANEL_KEY] as const;
|
||||
|
||||
export type PlayerPanelTab = "description" | "related";
|
||||
|
||||
export type PlayerUrlState = { watch: string; related: string | null; panel: PlayerPanelTab };
|
||||
|
||||
/** The player's keys for its entry. The panel's tab is written only when it is not the default: no
|
||||
* browser remembers it, so a link without one opens the description as this one would. */
|
||||
export function playerParamUpdates(state: PlayerUrlState): Record<string, string | null> {
|
||||
return {
|
||||
[PLAYER_WATCH_KEY]: state.watch,
|
||||
[PLAYER_RELATED_KEY]: state.related,
|
||||
[PLAYER_PANEL_KEY]: state.panel === "related" ? "related" : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The player an address names, or null when it names none. */
|
||||
function playerFrom(params: URLSearchParams): PlayerUrlState | null {
|
||||
const watch = params.get(PLAYER_WATCH_KEY);
|
||||
if (!watch) return null;
|
||||
return {
|
||||
watch,
|
||||
related: params.get(PLAYER_RELATED_KEY) || null,
|
||||
panel: params.get(PLAYER_PANEL_KEY) === "related" ? "related" : "description",
|
||||
};
|
||||
}
|
||||
|
||||
/** The player the address names now: read by a host as it mounts (a reload, a bookmark), and by the
|
||||
* player as it opens. No entry but the player's own carries these keys once the history has caught
|
||||
* up, so a host mounted later (a page switch back) finds none. */
|
||||
export function linkedPlayer(): PlayerUrlState | null {
|
||||
return playerFrom(new URLSearchParams(window.location.search));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
import { decodeFilterMap, encodeFilterMap } from "./filterMapParam";
|
||||
import { type FilterMap } from "./tableColumns";
|
||||
|
||||
// The Playlists page's address-bar keys: the playlist shown (`pl`), the rail's name search (`plq`), and
|
||||
// the open playlist's view — its track sort, column filters and grouping (`plsort`/`plf`/`plgroup`).
|
||||
// The playlist is remembered per account too (PlaylistsProvider); the search and the view are
|
||||
// remembered nowhere else (the view starts over on every playlist), so each of those is written only
|
||||
// when it differs from the default: its key's absence IS the default.
|
||||
export const PLAYLISTS_KEYS = ["pl", "plq", "plsort", "plf", "plgroup"] as const;
|
||||
|
||||
export type PlaylistView = { sort: SortState; filters: FilterMap; groupBy: boolean };
|
||||
|
||||
// The neutral view: "#" ascending IS the stored order, so the header can show a real arrow for it
|
||||
// instead of the list looking unsorted (or, worse, looking sorted by whatever column the stored
|
||||
// order happens to correlate with — the user read a channel-grouped playlist as sorted by Channel).
|
||||
export const PLAYLIST_STORED_SORT: SortState = { key: "pos", dir: "asc" };
|
||||
|
||||
export function isStoredSort(s: SortState): boolean {
|
||||
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). */
|
||||
export function playlistViewParamUpdates(v: PlaylistView | null): Record<string, string | null> {
|
||||
if (!v) return { plsort: null, plf: null, plgroup: null };
|
||||
return {
|
||||
// An unsorted view (a header clicked through to "none") is the stored order too.
|
||||
plsort: v.sort && !isStoredSort(v.sort) ? encodeSortParam(v.sort) : null,
|
||||
plf: encodeFilterMap(v.filters),
|
||||
plgroup: v.groupBy ? "1" : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The view an address names; what it leaves out is the default. */
|
||||
export function playlistViewFrom(params: URLSearchParams): PlaylistView {
|
||||
const sort = params.get("plsort");
|
||||
const filters = params.get("plf");
|
||||
return {
|
||||
sort: (sort != null && decodeSortParam(sort)) || PLAYLIST_STORED_SORT,
|
||||
filters: filters != null ? decodeFilterMap(filters) : {},
|
||||
groupBy: params.get("plgroup") === "1",
|
||||
};
|
||||
}
|
||||
@@ -48,15 +48,36 @@ const BASE: PlexUrlState = {
|
||||
};
|
||||
|
||||
describe("plexParamUpdates", () => {
|
||||
it("emits nothing for the all-default state", () => {
|
||||
it("writes the default view in full: this account remembers one a link must not inherit", () => {
|
||||
expect(plexParamUpdates(BASE)).toEqual({
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
pscope: "both",
|
||||
psort: "title:asc",
|
||||
pshow: "all",
|
||||
pf: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes an unsorted view as such, so a link to it is not sorted by the remembered sort", () => {
|
||||
const unsorted = { ...BASE, sort: null };
|
||||
expect(plexParamUpdates(unsorted).psort).toBe("none");
|
||||
const back = plexParamsToState(applyUpdates(plexParamUpdates(unsorted)), {
|
||||
...BASE,
|
||||
sort: { key: "year", dir: "desc" },
|
||||
});
|
||||
expect(back?.sort).toBeNull();
|
||||
});
|
||||
|
||||
it("a link to the default view opens it over another remembered one", () => {
|
||||
const params = applyUpdates(plexParamUpdates(BASE));
|
||||
const back = plexParamsToState(params, {
|
||||
scope: "movie",
|
||||
sort: { key: "year", dir: "desc" },
|
||||
show: "watched",
|
||||
filters: RICH_FILTERS,
|
||||
});
|
||||
expect(back).toEqual(BASE);
|
||||
});
|
||||
|
||||
it("clears every key when passed null (off-plex page)", () => {
|
||||
expect(plexParamUpdates(null)).toEqual({
|
||||
pscope: null,
|
||||
@@ -66,7 +87,7 @@ describe("plexParamUpdates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("emits only non-default keys", () => {
|
||||
it("writes the view picked", () => {
|
||||
const u = plexParamUpdates({
|
||||
scope: "movie",
|
||||
sort: { key: "year", dir: "desc" },
|
||||
@@ -76,7 +97,7 @@ describe("plexParamUpdates", () => {
|
||||
expect(u.pscope).toBe("movie");
|
||||
expect(u.psort).toBe("year:desc");
|
||||
expect(u.pshow).toBe("in_progress");
|
||||
expect(u.pf).toBeNull();
|
||||
expect(u.pf).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
// urlState. Keys are NAMESPACED (`pscope`/`psort`/`pshow`/`pf`) rather than reusing the feed's bare
|
||||
// `scope`/`sort`/`show`: FeedFiltersProvider actively STRIPS its own KEYS from the URL whenever the app
|
||||
// is off the feed page, so a shared key would be deleted out from under the Plex page. The namespaced
|
||||
// keys are Plex-unique (like `?v=`/`?pl`), so no other provider ever reads or clears them. Only non-
|
||||
// default values are emitted, so URLs stay clean.
|
||||
// keys are Plex-unique (like `?v=`/`?pl`), so no other provider ever reads or clears them. Every key is
|
||||
// written on the page, its default too: a page opened without one takes this account's remembered view,
|
||||
// which may not be the one bookmarked.
|
||||
import { parsePlexFilters, plexFiltersToParams, type PlexFilters } from "./api";
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
import { PLEX_SORT_FALLBACK } from "./plexSort";
|
||||
import { decodeSortParamOrNone, encodeSortParamOrNone, type SortState } from "./columnSort";
|
||||
import { parsePlexField, type PlexSearchField } from "./plexSearch";
|
||||
|
||||
// The content state this mirrors. `sort` is the RAW provider SortState (scope-healed only at the point of
|
||||
// use), so the user's real preference round-trips through the URL.
|
||||
@@ -18,29 +19,29 @@ export interface PlexUrlState {
|
||||
filters: PlexFilters;
|
||||
}
|
||||
|
||||
const KEYS = ["pscope", "psort", "pshow", "pf"] as const;
|
||||
export const PLEX_KEYS = ["pscope", "psort", "pshow", "pf"] as const;
|
||||
const KEYS = PLEX_KEYS;
|
||||
// The header search: its term and the field it searches. Both remembered per account, so both are
|
||||
// always written on the page (an empty term too): a link without them would open with whatever this
|
||||
// browser searched last. The field means something only beside a term.
|
||||
export const PLEX_SEARCH_KEYS = ["pq", "pqf"] as const;
|
||||
// The Plex sub-view's key (show, season, info, person… — modules/plex/plexSubUrl encodes it).
|
||||
export const PLEX_SUB_KEY = "v";
|
||||
const SCOPES = ["movie", "show", "both"];
|
||||
const SHOWS = ["all", "unwatched", "in_progress", "watched"];
|
||||
|
||||
// Sort ⇄ `key:dir` (e.g. `year:desc`) via the shared codec in columnSort; the Plex default (`title:asc`)
|
||||
// emits nothing.
|
||||
function encodeSort(s: SortState): string | null {
|
||||
if (!s || (s.key === PLEX_SORT_FALLBACK?.key && s.dir === PLEX_SORT_FALLBACK?.dir)) return null;
|
||||
return encodeSortParam(s);
|
||||
}
|
||||
|
||||
/** The address-bar delta for the Plex content state: each namespaced key mapped to its emitted value,
|
||||
* or null to delete it (so a control back at its default clears the key). Pass `null` to clear ALL Plex
|
||||
/** The address-bar delta for the Plex content state: each namespaced key mapped to its value (sort as
|
||||
* `key:dir`, the shared columnSort codec; an empty filter set as `pf=`). Pass `null` to clear ALL Plex
|
||||
* keys — used when the app is off the Plex page (localStorage still holds the state; it re-mirrors on
|
||||
* return). Feed the result into urlWithParams(). */
|
||||
export function plexParamUpdates(state: PlexUrlState | null): Record<string, string | null> {
|
||||
if (!state) return { pscope: null, psort: null, pshow: null, pf: null };
|
||||
const pf = plexFiltersToParams(state.filters).toString();
|
||||
return {
|
||||
pscope: state.scope && state.scope !== "both" ? state.scope : null,
|
||||
psort: encodeSort(state.sort),
|
||||
pshow: state.show && state.show !== "all" ? state.show : null,
|
||||
pf: pf || null,
|
||||
pscope: state.scope || "both",
|
||||
psort: encodeSortParamOrNone(state.sort),
|
||||
pshow: state.show || "all",
|
||||
pf,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,8 +55,8 @@ export function hasPlexParams(params: URLSearchParams): boolean {
|
||||
export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): PlexUrlState {
|
||||
const scope = params.get("pscope");
|
||||
const show = params.get("pshow");
|
||||
const psort = params.get("psort");
|
||||
const sort = psort != null ? (decodeSortParam(psort) ?? base.sort) : base.sort;
|
||||
const linkedSort = decodeSortParamOrNone(params.get("psort"));
|
||||
const sort = linkedSort === undefined ? base.sort : linkedSort;
|
||||
return {
|
||||
scope: scope && SCOPES.includes(scope) ? scope : base.scope,
|
||||
sort,
|
||||
@@ -65,3 +66,24 @@ export function plexParamsToState(params: URLSearchParams, base: PlexUrlState):
|
||||
: base.filters,
|
||||
};
|
||||
}
|
||||
|
||||
export type PlexSearchUrlState = { q: string; field: PlexSearchField };
|
||||
|
||||
/** The address-bar delta for the header search; `null` clears it (off the Plex page). */
|
||||
export function plexSearchParamUpdates(
|
||||
state: PlexSearchUrlState | null,
|
||||
): Record<string, string | null> {
|
||||
if (!state) return { pq: null, pqf: null };
|
||||
const q = state.q.trim();
|
||||
return { pq: q, pqf: q ? state.field : null };
|
||||
}
|
||||
|
||||
/** What of the header search an address names (absent: the remembered value applies). */
|
||||
export function plexSearchFrom(params: URLSearchParams): Partial<PlexSearchUrlState> {
|
||||
const q = params.get("pq");
|
||||
const field = params.get("pqf");
|
||||
return {
|
||||
...(q != null && { q }),
|
||||
...(field != null && { field: parsePlexField(field) }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ describe("qk — single-id and scalar keys", () => {
|
||||
it("returns [root, id]", () => {
|
||||
expect(qk.channel("c1")).toEqual(["channel", "c1"]);
|
||||
expect(qk.videoDetail("v1")).toEqual(["video-detail", "v1"]);
|
||||
expect(qk.videoRow("v1")).toEqual(["video-row", "v1"]);
|
||||
expect(qk.playlistMembership("v1")).toEqual(["playlist-membership", "v1"]);
|
||||
expect(qk.plexItem("i1")).toEqual(["plex-item", "i1"]);
|
||||
expect(qk.plexPlaylist(9)).toEqual(["plex-playlist", 9]);
|
||||
|
||||
@@ -32,6 +32,7 @@ export const qk = {
|
||||
ytSearch: (q?: string | null, count?: number) =>
|
||||
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
|
||||
videoDetail: (id: string) => ["video-detail", id] as const,
|
||||
videoRow: (id: string) => ["video-row", id] as const,
|
||||
related: (id?: string, region?: string) =>
|
||||
id !== undefined ? (["related", id, region] as const) : (["related"] as const),
|
||||
// This session's confirmed marks per video (lib/progressCache `markVideo`): no fetch behind it.
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { LS, setTabRaw } from "./storage";
|
||||
|
||||
// The Settings page's tabs, and a helper to pre-select one before navigating there (a notice's link,
|
||||
// a return from Google). useTabPersistedState reads the key when the page mounts, and the page mounts
|
||||
// fresh on navigation. Kept out of SettingsPanel.tsx so a caller does not pull the page in with it.
|
||||
// a return from Google). useTabPersistedState reads the key when the page mounts, and the page on
|
||||
// screen hears it. Kept out of SettingsPanel.tsx so a caller does not pull the page in with it. The
|
||||
// address-bar key the page mirrors its tab to is here too, beside the other pages' keys.
|
||||
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 =
|
||||
| "appearance"
|
||||
| "notifications"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
// Central registry of every key the app keeps in the browser (localStorage, and a tab's
|
||||
// sessionStorage). One place to see them all, rename safely, and avoid collisions — instead of
|
||||
@@ -307,10 +307,11 @@ export function useAccountPersistedState(
|
||||
|
||||
// --- a page's sub-tab, per browser tab ---------------------------------------------------------
|
||||
|
||||
// Which sub-tab a page was left on (Settings, Configuration, Statistics, Users) is kept for THIS browser
|
||||
// tab only — sessionStorage, the account's (see ownKey): it survives a reload and a trip to another page, and a new
|
||||
// Which sub-tab a page was left on (Settings, Configuration, Users) is kept for THIS browser tab only —
|
||||
// sessionStorage, the account's (see ownKey): it survives a reload and a trip to another page, and a new
|
||||
// tab opens the page on its first sub-tab (the owner's call: a position remembered forever in
|
||||
// localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home).
|
||||
// localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home). The
|
||||
// address names the tab too (the page mirrors it): a link or a bookmark that carries one wins.
|
||||
|
||||
/** The sub-tab stored for this browser tab, or null. */
|
||||
function getTabRaw(base: string): string | null {
|
||||
@@ -322,8 +323,7 @@ function getTabRaw(base: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-select a page's sub-tab before navigating there (a "Review" link, the Google-link return). */
|
||||
export function setTabRaw(base: string, value: string): void {
|
||||
function writeTabRaw(base: string, value: string): void {
|
||||
const k = ownKey(base);
|
||||
if (!k) return;
|
||||
try {
|
||||
@@ -333,10 +333,23 @@ export function setTabRaw(base: string, value: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). */
|
||||
// A pre-selection made while the page is already on screen (a notice's link opened over Settings) has
|
||||
// no mount to be read at, and the navigation to the same page is a no-op: the page's hook hears it.
|
||||
const TAB_SELECT_EVENT = "siftlode:tab-select";
|
||||
type TabSelect = { base: string; value: string };
|
||||
|
||||
/** Pre-select a page's sub-tab before navigating there (a "Review" link, the Google-link return). */
|
||||
export function setTabRaw(base: string, value: string): void {
|
||||
writeTabRaw(base, value);
|
||||
window.dispatchEvent(new CustomEvent<TabSelect>(TAB_SELECT_EVENT, { detail: { base, value } }));
|
||||
}
|
||||
|
||||
/** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). `urlKey`: the
|
||||
* address-bar key the page mirrors its tab to — a tab it names is the one the page opens on. */
|
||||
export function useTabPersistedState(
|
||||
base: string,
|
||||
fallback = "",
|
||||
urlKey?: string,
|
||||
): [string, (value: string) => void] {
|
||||
const [value, setValue] = useState<string>(() => {
|
||||
// The position used to live in localStorage; a value left there would never be read again.
|
||||
@@ -348,12 +361,27 @@ export function useTabPersistedState(
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const linked = urlKey ? new URLSearchParams(window.location.search).get(urlKey) : null;
|
||||
if (linked) {
|
||||
// Kept for this browser tab too, so a later trip back to the page (its address cleared by the
|
||||
// page switch) still opens it here.
|
||||
writeTabRaw(base, linked);
|
||||
return linked;
|
||||
}
|
||||
return getTabRaw(base) ?? fallback;
|
||||
});
|
||||
useEffect(() => {
|
||||
function onSelect(e: Event) {
|
||||
const { detail } = e as CustomEvent<TabSelect>;
|
||||
if (detail.base === base) setValue(detail.value);
|
||||
}
|
||||
window.addEventListener(TAB_SELECT_EVENT, onSelect);
|
||||
return () => window.removeEventListener(TAB_SELECT_EVENT, onSelect);
|
||||
}, [base]);
|
||||
const set = useCallback(
|
||||
(next: string) => {
|
||||
setValue(next);
|
||||
setTabRaw(base, next);
|
||||
writeTabRaw(base, next);
|
||||
},
|
||||
[base],
|
||||
);
|
||||
|
||||
@@ -41,4 +41,21 @@ describe("a page's sub-tab, per browser tab", () => {
|
||||
const { result } = renderHook(() => useTabPersistedState("siftlode.adminUsersTab", "roles"));
|
||||
expect(result.current[0]).toBe("access");
|
||||
});
|
||||
|
||||
it("opens on the tab the address names, and keeps it for this browser tab", () => {
|
||||
setTabRaw("siftlode.settingsTab", "account");
|
||||
window.history.replaceState(null, "", "/?page=settings&stab=tokens");
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useTabPersistedState("siftlode.settingsTab", "appearance", "stab"),
|
||||
);
|
||||
expect(result.current[0]).toBe("tokens");
|
||||
unmount();
|
||||
// Back on the page later, through the rail (the page switch cleared its address).
|
||||
window.history.replaceState(null, "", "/?page=settings");
|
||||
expect(
|
||||
renderHook(() => useTabPersistedState("siftlode.settingsTab", "appearance", "stab")).result
|
||||
.current[0],
|
||||
).toBe("tokens");
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,13 +127,13 @@ describe("a page opened at a path that starts with //", () => {
|
||||
});
|
||||
|
||||
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 upd = filterParamUpdates(f);
|
||||
expect(upd.q).toBe("react");
|
||||
expect(upd.tags).toBe("3,7");
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
|
||||
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
|
||||
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
|
||||
import { TAG_CATEGORIES, type FeedFilters } from "./api";
|
||||
import { FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, TAG_CATEGORIES, type FeedFilters } from "./api";
|
||||
import { CHANNEL_PAGE_KEYS } from "./channelPageUrlState";
|
||||
import { PLAYER_KEYS } from "./playerUrlState";
|
||||
|
||||
const KEYS = [
|
||||
export const FEED_KEYS = [
|
||||
"q",
|
||||
"show",
|
||||
"sort",
|
||||
@@ -29,12 +31,32 @@ const KEYS = [
|
||||
"mindur",
|
||||
"maxdur",
|
||||
] as const;
|
||||
const KEYS = FEED_KEYS;
|
||||
|
||||
// The keys the shell itself reads, whatever page is on screen: the page, the channel page and its view
|
||||
// (it opens over any page, lib/channelPageUrlState), the YouTube player (the same, lib/playerUrlState),
|
||||
// the welcome card's mode and the one-time entry links. No module may declare one of these
|
||||
// (moduleUrl.contract.test); a page switch clears only the channel's and the player's.
|
||||
export const SHELL_KEYS = [
|
||||
"page",
|
||||
"chan",
|
||||
...CHANNEL_PAGE_KEYS,
|
||||
...PLAYER_KEYS,
|
||||
"m",
|
||||
"link",
|
||||
"oauth",
|
||||
"admin",
|
||||
"account",
|
||||
"login",
|
||||
"deleted",
|
||||
"token",
|
||||
] as const;
|
||||
|
||||
export function filtersToParams(f: FeedFilters): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
if (f.q) p.set("q", f.q);
|
||||
if (f.show && f.show !== "unwatched") p.set("show", f.show);
|
||||
if (f.sort && f.sort !== "newest") p.set("sort", f.sort);
|
||||
if (f.show && f.show !== FEED_DEFAULT_SHOW) p.set("show", f.show);
|
||||
if (f.sort && f.sort !== FEED_DEFAULT_SORT) p.set("sort", f.sort);
|
||||
if (f.scope === "all") p.set("scope", "all");
|
||||
// Library provenance filter (only meaningful in "all" scope); "organic" is the default.
|
||||
if (f.scope === "all" && f.librarySource && f.librarySource !== "organic")
|
||||
@@ -58,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
|
||||
* value, or null to delete it (so switching a filter back to its default clears the key). Pass `null`
|
||||
* to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them.
|
||||
* Feed the result into urlWithParams(). */
|
||||
* value, or null to delete it (so switching a filter back to its default clears the key) — except the
|
||||
* view and the order, always written (defaults too): a feed address then always names its view, so a
|
||||
* 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> {
|
||||
const p = f ? filtersToParams(f) : new URLSearchParams();
|
||||
const out: Record<string, string | null> = {};
|
||||
for (const k of KEYS) out[k] = p.get(k);
|
||||
if (f) {
|
||||
out.show = f.show || FEED_DEFAULT_SHOW;
|
||||
out.sort = f.sort || FEED_DEFAULT_SORT;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -78,8 +106,8 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
|
||||
};
|
||||
|
||||
if (params.has("q")) f.q = params.get("q") ?? "";
|
||||
if (params.has("show")) f.show = params.get("show") || "unwatched";
|
||||
if (params.has("sort")) f.sort = params.get("sort") || "newest";
|
||||
if (params.has("show")) f.show = params.get("show") || FEED_DEFAULT_SHOW;
|
||||
if (params.has("sort")) f.sort = params.get("sort") || FEED_DEFAULT_SORT;
|
||||
if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
|
||||
if (params.has("source")) {
|
||||
const s = params.get("source");
|
||||
@@ -211,6 +239,22 @@ export function urlWithParams(updates: Record<string, string | null>): string {
|
||||
return hrefHere(params);
|
||||
}
|
||||
|
||||
/** 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
|
||||
* (the channel page writes its own, lib/channelPageUrlState), and no player (opening a channel closes
|
||||
* it). What `openChannel` pushes, and what a
|
||||
* channel link points at, so a new tab opens the same view. */
|
||||
export function channelUrl(id: string): string {
|
||||
const here = new URLSearchParams(window.location.search);
|
||||
const viewKeys: readonly string[] = [...CHANNEL_PAGE_KEYS, ...PLAYER_KEYS];
|
||||
const params = new URLSearchParams();
|
||||
for (const k of SHELL_KEYS) {
|
||||
const v = k === "chan" ? id : here.get(k);
|
||||
if (v != null && !viewKeys.includes(k)) params.set(k, v);
|
||||
}
|
||||
return hrefHere(params);
|
||||
}
|
||||
|
||||
/** This page's address with its query replaced by `query` and its fragment by `hash` — what every
|
||||
* address-bar writer hands to `history`. Built on the URL itself, never by gluing `pathname` in front:
|
||||
* a path opened as `//evil.com/x` would read as a link to ANOTHER host, `history.replaceState` refuses
|
||||
@@ -224,10 +268,11 @@ export function hrefHere(query: URLSearchParams, hash: string = window.location.
|
||||
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,
|
||||
* ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now
|
||||
* live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now
|
||||
* that the query is the source of truth for navigation. No-op when none of the keys are present. */
|
||||
/** Take `keys` off the current entry's address, WITHOUT touching the nav/filter params that live
|
||||
* there: TRANSIENT params once read (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
|
||||
* ?reset), and what a link named that the page could not show (a file or a video that is gone, said
|
||||
* 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 {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let changed = false;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
|
||||
import { DEFAULT_TAG_MODES, FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, type FeedFilters } from "./api";
|
||||
import { tagPickLabel } from "./tagPicks";
|
||||
import { useChannelNames } from "./useChannelNames";
|
||||
import { parseSort } from "./feedSort";
|
||||
@@ -20,11 +20,11 @@ const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
untagged: false,
|
||||
channelIds: [],
|
||||
sort: "newest",
|
||||
sort: FEED_DEFAULT_SORT,
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
show: FEED_DEFAULT_SHOW,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -74,19 +74,19 @@ export function useActiveFilters(
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.show !== "unwatched") {
|
||||
if (filters.show !== FEED_DEFAULT_SHOW) {
|
||||
out.push({
|
||||
key: "show",
|
||||
label: t("sidebar.show." + filters.show),
|
||||
remove: () => setFilters({ ...filters, show: "unwatched" }),
|
||||
remove: () => setFilters({ ...filters, show: FEED_DEFAULT_SHOW }),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.sort !== "newest" && filters.sort !== "relevance") {
|
||||
if (filters.sort !== FEED_DEFAULT_SORT && filters.sort !== "relevance") {
|
||||
out.push({
|
||||
key: "sort",
|
||||
label: t("feed.sortKey." + parseSort(filters.sort).key),
|
||||
remove: () => setFilters({ ...filters, sort: "newest", seed: undefined }),
|
||||
remove: () => setFilters({ ...filters, sort: FEED_DEFAULT_SORT, seed: undefined }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 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.
|
||||
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";
|
||||
|
||||
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
|
||||
@@ -15,7 +15,10 @@ beforeEach(() => {
|
||||
setUrl("/");
|
||||
spy = vi.spyOn(window.history, "replaceState");
|
||||
});
|
||||
afterEach(() => spy.mockRestore());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
describe("useUrlMirror", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
// 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", () => {
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload under an overlay that reopens (the player) loads on the overlay's entry, its mark on it,
|
||||
// and the overlay takes that entry over as it opens again. A page's first mirror write on that load
|
||||
// (the defaults its address did not name yet) is the same view, not another: the mark stays, and the
|
||||
// reopened overlay still takes its entry over rather than pushing one more. Its own file: the entry
|
||||
// is read as the history 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 page's first mirror write on the entry a reload under an overlay started from", () => {
|
||||
it("leaves the entry to the overlay that reopens", async () => {
|
||||
window.history.replaceState({ sfPage: "activity" }, "", "/?page=activity");
|
||||
window.history.pushState({ sfPage: "activity", _ov: 1, _ovd: 1 }, "", "/?page=activity");
|
||||
const { useUrlMirror } = await import("./useUrlMirror");
|
||||
const { useBackToClose } = await import("./history");
|
||||
renderHook(() => useUrlMirror({ at: "runs" }));
|
||||
expect(new URLSearchParams(window.location.search).get("at")).toBe("runs");
|
||||
|
||||
const depth = window.history.length;
|
||||
renderHook(() => useBackToClose(() => {}));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(window.history.length).toBe(depth);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload under a dialog that does not reopen (About) loads on the dialog's entry, its overlay mark
|
||||
// still on it. A page's view mirrored onto that entry (another tab) is another view: the next overlay
|
||||
// pushes its own entry over it rather than taking this one over — taken over, closing it stepped back
|
||||
// onto the entry below, and the page read its old view back from that address. Its own file: the
|
||||
// entry is read as the history 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 mirrored onto the entry a reload under a dialog started from", () => {
|
||||
it("is where an overlay over it closes to", async () => {
|
||||
window.history.replaceState({ sfPage: "activity" }, "", "/?page=activity&at=runs");
|
||||
window.history.pushState(
|
||||
{ sfPage: "activity", _ov: 1, _ovd: 1 },
|
||||
"",
|
||||
"/?page=activity&at=runs",
|
||||
);
|
||||
const { useUrlMirror } = await import("./useUrlMirror");
|
||||
const { useBackToClose } = await import("./history");
|
||||
const mirror = renderHook(({ at }) => useUrlMirror({ at }), { initialProps: { at: "runs" } });
|
||||
mirror.rerender({ at: "admin" });
|
||||
expect(new URLSearchParams(window.location.search).get("at")).toBe("admin");
|
||||
|
||||
const depth = window.history.length;
|
||||
const overlay = renderHook(() => useBackToClose(() => {}));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(window.history.length).toBe(depth + 1);
|
||||
|
||||
await act(async () => {
|
||||
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||
overlay.unmount();
|
||||
await landed;
|
||||
});
|
||||
expect(new URLSearchParams(window.location.search).get("at")).toBe("admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload under an overlay that reopens (the player) loads on the overlay's entry, its mark on it.
|
||||
// A page that settles before the overlay is back (the rail steps off a playlist deleted since, before
|
||||
// the player's row has loaded) has not been sent to another view: nothing the user did moved it. The
|
||||
// mark stays, and the reopened overlay takes its entry over rather than pushing one more — one more
|
||||
// was a Back that went nowhere. Its own file: the entry is read as the history 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 page settling on the entry a reload under an overlay started from", () => {
|
||||
it("before the user has done anything, leaves the entry to the overlay that reopens", async () => {
|
||||
Object.defineProperty(navigator, "userActivation", {
|
||||
configurable: true,
|
||||
value: { hasBeenActive: false },
|
||||
});
|
||||
window.history.replaceState({ sfPage: "playlists" }, "", "/?page=playlists&pl=7");
|
||||
window.history.pushState(
|
||||
{ sfPage: "playlists", _ov: 1, _ovd: 1 },
|
||||
"",
|
||||
"/?page=playlists&pl=7&watch=Z",
|
||||
);
|
||||
const { useUrlMirror } = await import("./useUrlMirror");
|
||||
const { useBackToClose } = await import("./history");
|
||||
const mirror = renderHook(({ pl }) => useUrlMirror({ pl }), { initialProps: { pl: "7" } });
|
||||
mirror.rerender({ pl: "1" });
|
||||
expect(new URLSearchParams(window.location.search).get("pl")).toBe("1");
|
||||
|
||||
const depth = window.history.length;
|
||||
renderHook(() => useBackToClose(() => {}));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(window.history.length).toBe(depth);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useEffectEvent } from "react";
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { replaceView } from "./history";
|
||||
import { urlWithParams } from "./urlState";
|
||||
|
||||
// Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new
|
||||
@@ -6,17 +7,61 @@ import { urlWithParams } from "./urlState";
|
||||
// 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
|
||||
// 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.)
|
||||
// Whether a click or a key press has reached the page since it loaded. A browser without the API
|
||||
// counts as yes: every change is then another view, as before it existed.
|
||||
const userHasActed = (): boolean =>
|
||||
(navigator as { userActivation?: { hasBeenActive: boolean } }).userActivation?.hasBeenActive ??
|
||||
true;
|
||||
|
||||
export function useUrlMirror(updates: Record<string, string | null>): void {
|
||||
// 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.
|
||||
const dep = JSON.stringify(updates);
|
||||
// The object itself is read through an effect event, so the effect's one reactive input is the
|
||||
// serialized value — which is exactly what the dep list says: an effect event for the identity that
|
||||
// churns, a primitive for the trigger.
|
||||
// The object itself is read through an effect event, so the effect's reactive inputs are the
|
||||
// serialized value and the landing count — which is exactly what the dep list says: an effect event
|
||||
// for the identity that churns, primitives for the triggers.
|
||||
// A change of what this mirror writes, after its first write, is another view on the same entry
|
||||
// (replaceView: it drops the marks a reload under an overlay left). Its first write on a mount and
|
||||
// its write on a landing name the view the entry already shows, so the marks stay: an overlay that
|
||||
// reopens after a reload still finds its entry. So does a change before the user has done anything
|
||||
// since the load: that is the page settling (the rail stepping off a playlist deleted since), and
|
||||
// the address under the entry names the same screen, settled again on the way back.
|
||||
const written = useRef<string | null>(null);
|
||||
const mirror = useEffectEvent(() => {
|
||||
window.history.replaceState(window.history.state, "", urlWithParams(updates));
|
||||
const href = urlWithParams(updates);
|
||||
const changed = written.current != null && written.current !== dep && userHasActed();
|
||||
written.current = dep;
|
||||
// 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({})) return;
|
||||
if (changed) replaceView(window.history.state ?? {}, href);
|
||||
else 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(() => {
|
||||
mirror();
|
||||
}, [dep]);
|
||||
}, [dep, landings]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ytCountFrom, ytSearchFrom, ytSearchParams } from "./ytSearchUrlState";
|
||||
|
||||
const p = (q: string) => new URLSearchParams(q);
|
||||
|
||||
describe("the live YouTube search's address", () => {
|
||||
it("reads the term, trimmed; a blank one names no search", () => {
|
||||
expect(ytSearchFrom(p("yt=%20lofi%20"))).toBe("lofi");
|
||||
expect(ytSearchFrom(p("yt=%20%20"))).toBeNull();
|
||||
expect(ytSearchFrom(p("q=lofi"))).toBeNull();
|
||||
});
|
||||
|
||||
it("reads one of the offered counts, else the default", () => {
|
||||
expect(ytCountFrom(p("ytn=60"))).toBe(60);
|
||||
expect(ytCountFrom(p("ytn=61"))).toBe(20);
|
||||
expect(ytCountFrom(p("ytn=x"))).toBe(20);
|
||||
expect(ytCountFrom(p(""))).toBe(20);
|
||||
});
|
||||
|
||||
it("writes the count only off its default, and nothing with no search", () => {
|
||||
expect(ytSearchParams("lofi", 40)).toEqual({ yt: "lofi", ytn: "40" });
|
||||
expect(ytSearchParams("lofi", 20)).toEqual({ yt: "lofi", ytn: null });
|
||||
expect(ytSearchParams(null, 40)).toEqual({ yt: null, ytn: null });
|
||||
});
|
||||
|
||||
it("round-trips", () => {
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(ytSearchParams("lofi beats", 100))) if (v) q.set(k, v);
|
||||
expect([ytSearchFrom(q), ytCountFrom(q)]).toEqual(["lofi beats", 100]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// The feed's live YouTube search in the address bar (`?page=feed&yt=lofi&ytn=40`), so a search is a
|
||||
// link and survives a reload. Only the term and how many results to gather: which source answers is
|
||||
// the instance's setting at the moment the link opens (an admin may have switched it since), and that
|
||||
// decides whether the search runs by itself (the free page scrape) or waits for a press (an API
|
||||
// search spends 100 quota units, so nobody's reload or bookmark spends them unasked).
|
||||
|
||||
const YT_SEARCH_KEY = "yt";
|
||||
const YT_COUNT_KEY = "ytn";
|
||||
export const YT_SEARCH_KEYS = [YT_SEARCH_KEY, YT_COUNT_KEY] as const;
|
||||
|
||||
/** How many live-search results one fetch gathers; the first is the default, left out of the URL. */
|
||||
export const YT_COUNTS = [20, 40, 60, 100] as const;
|
||||
export const DEFAULT_YT_COUNT = YT_COUNTS[0];
|
||||
|
||||
/** The search a URL names, or null: a blank term names none (the search box never sends one). */
|
||||
export function ytSearchFrom(params: URLSearchParams): string | null {
|
||||
return params.get(YT_SEARCH_KEY)?.trim() || null;
|
||||
}
|
||||
|
||||
/** The result count a URL names; anything but one of the offered counts is the default. */
|
||||
export function ytCountFrom(params: URLSearchParams): number {
|
||||
const n = Number(params.get(YT_COUNT_KEY));
|
||||
return (YT_COUNTS as readonly number[]).includes(n) ? n : DEFAULT_YT_COUNT;
|
||||
}
|
||||
|
||||
/** The URL updates for a search (null = none): a default count clears its key. */
|
||||
export function ytSearchParams(term: string | null, count: number): Record<string, string | null> {
|
||||
return {
|
||||
[YT_SEARCH_KEY]: term,
|
||||
[YT_COUNT_KEY]: term != null && count !== DEFAULT_YT_COUNT ? String(count) : null,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { ConfirmProvider } from "./components/ui/ConfirmProvider";
|
||||
import BootGate, { BootScreen } from "./shell/BootGate";
|
||||
import { NavigationProvider } from "./features/navigation/NavigationProvider";
|
||||
import { ModuleStores } from "./features/navigation/ModuleStores";
|
||||
import { typedUrlKeys } from "./features/navigation/moduleRegistry";
|
||||
import { PrefsProvider } from "./features/prefs/PrefsProvider";
|
||||
import { WizardProvider } from "./features/wizard/WizardProvider";
|
||||
import "./i18n";
|
||||
@@ -19,7 +20,7 @@ import { installStaleBuildReload } from "./lib/staleBuild";
|
||||
import { appCopyOf } from "./i18n/appCopy";
|
||||
|
||||
// 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.
|
||||
installStaleBuildReload();
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The URL is the view: a page's name search is named in the address, so a reload or a bookmark opens
|
||||
// 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.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
import { NavigationProvider } from "./features/navigation/NavigationProvider";
|
||||
import {
|
||||
useNavigationActions,
|
||||
type NavigationActions,
|
||||
} from "./features/navigation/navigationContext";
|
||||
import { ModuleStores } from "./features/navigation/ModuleStores";
|
||||
import { useChannels } from "./modules/channels/channelsContext";
|
||||
import { useFiles, useFilesActions } from "./modules/files/filesContext";
|
||||
import { usePlaylists, usePlaylistsActions } from "./modules/playlists/playlistsContext";
|
||||
import { usePlex } from "./modules/plex/plexContext";
|
||||
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "./lib/storage";
|
||||
|
||||
const seen: { current: Record<string, string> } = { current: {} };
|
||||
const nav: { current: NavigationActions | null } = { current: null };
|
||||
const typeIn: { current: Record<string, (q: string) => void> } = { current: {} };
|
||||
|
||||
function Probe() {
|
||||
const channels = useChannels();
|
||||
const files = useFiles();
|
||||
const playlists = usePlaylists();
|
||||
const plex = usePlex();
|
||||
const actions = useNavigationActions();
|
||||
const filesActions = useFilesActions();
|
||||
const playlistsActions = usePlaylistsActions();
|
||||
useEffect(() => {
|
||||
nav.current = actions;
|
||||
typeIn.current = { files: filesActions.setSearch, playlists: playlistsActions.setSearch };
|
||||
seen.current = {
|
||||
channels: channels.search,
|
||||
files: files.search,
|
||||
playlists: playlists.search,
|
||||
plex: plex.q,
|
||||
plexField: plex.field,
|
||||
};
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(3);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
function mountAt(url: string, page: string) {
|
||||
window.history.replaceState({ sfPage: page }, "", url);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<NavigationProvider>
|
||||
<ModuleStores>
|
||||
<Probe />
|
||||
</ModuleStores>
|
||||
</NavigationProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const search = () => new URLSearchParams(window.location.search);
|
||||
|
||||
describe.each([
|
||||
["channels", "cq", "tom"],
|
||||
["files", "fq", "notes"],
|
||||
["playlists", "plq", "jazz"],
|
||||
["plex", "pq", "poirot"],
|
||||
])("%s's name search", (page, key, value) => {
|
||||
it("opens from the address, and stays in it", async () => {
|
||||
mountAt(`/?page=${page}&${key}=${value}`, page);
|
||||
await waitFor(() => expect(seen.current[page]).toBe(value));
|
||||
expect(search().get(key)).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plex's search", () => {
|
||||
it("is named in full, an empty one too: this browser remembers one a link must not inherit", async () => {
|
||||
setAccountRaw(LS.plexQ, "poirot");
|
||||
mountAt("/?page=plex&pq=", "plex");
|
||||
await waitFor(() => expect(seen.current.plex).toBe(""));
|
||||
expect(search().get("pq")).toBe("");
|
||||
});
|
||||
|
||||
it("takes the field a link names, and remembers the link's search", async () => {
|
||||
mountAt("/?page=plex&pq=hastings&pqf=people", "plex");
|
||||
await waitFor(() => expect(seen.current.plexField).toBe("people"));
|
||||
expect(seen.current.plex).toBe("hastings");
|
||||
await waitFor(() => expect(getAccountRaw(LS.plexQ)).toBe("hastings"));
|
||||
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(""));
|
||||
// At once, not after the typing pause: a reload right after the return opens it unsearched.
|
||||
expect(search().get(key)).toBeNull();
|
||||
// Typing again within the pause names the new text or nothing, never the ended search.
|
||||
act(() => typeIn.current[page]!("x"));
|
||||
expect(search().get(key)).not.toBe(value);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,516 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// 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,
|
||||
// derived from these declarations). That only holds while the declarations are true, so this pins
|
||||
// them: each key belongs to one module and is no shell key, the keys a module declares are exactly
|
||||
// what its encoders write, a writer outside lib/ hands the address a lib/ function's result (or
|
||||
// literal keys, checked here) — never a key of its own making — every key literal read from the live
|
||||
// query is declared, and every key is sorted as typed text or not (the client log's part).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { moduleDef, REGISTERED_PAGES, typedUrlKeys } from "./features/navigation/moduleRegistry";
|
||||
import { DEFAULT_TAG_MODES, EMPTY_PLEX_FILTERS, type FeedFilters } from "./lib/api";
|
||||
import { filterParamUpdates, SHELL_KEYS, type Page } from "./lib/urlState";
|
||||
import { channelsParamUpdates } from "./lib/channelsUrlState";
|
||||
import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState";
|
||||
import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState";
|
||||
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState";
|
||||
import { filesListParams, filesOpenParams, filesSortParams } from "./lib/filesUrlState";
|
||||
import { ytSearchParams } from "./lib/ytSearchUrlState";
|
||||
import { budgetParams } from "./lib/homeUrlState";
|
||||
import { PLAYER_KEYS, playerParamUpdates } from "./lib/playerUrlState";
|
||||
import { playlistsParams, playlistViewParamUpdates } from "./lib/playlistsUrlState";
|
||||
import { settingsTabParams } from "./lib/settingsTab";
|
||||
import { configTabParams } from "./lib/configTab";
|
||||
import { usersTabParams } from "./lib/adminUsersTab";
|
||||
import { PLEX_SUB_URL } from "./modules/plex/plexSubUrl";
|
||||
import { MODE_URL } from "./shell/welcomeMode";
|
||||
import { CHANNEL_PAGE_KEYS, channelPageParamUpdates } from "./lib/channelPageUrlState";
|
||||
|
||||
const SRC = __dirname;
|
||||
|
||||
const FEED: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
// What each module's own encoders write, from the encoders themselves. Every module that declares a
|
||||
// key has a row, so a new one cannot declare keys its codec does not write, or the other way round.
|
||||
const WRITES: Partial<Record<Page, () => string[]>> = {
|
||||
feed: () => [...Object.keys(filterParamUpdates(FEED)), ...Object.keys(ytSearchParams("x", 40))],
|
||||
home: () => Object.keys(budgetParams("short")),
|
||||
channels: () =>
|
||||
Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {}, search: "" })),
|
||||
plex: () => [
|
||||
...Object.keys(
|
||||
plexParamUpdates({ scope: "both", sort: null, show: "all", filters: EMPTY_PLEX_FILTERS }),
|
||||
),
|
||||
...Object.keys(plexSearchParamUpdates({ q: "x", field: "all" })),
|
||||
PLEX_SUB_URL.key,
|
||||
],
|
||||
files: () => [
|
||||
...Object.keys(filesListParams(null)),
|
||||
...Object.keys(filesSortParams(null)),
|
||||
...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: () => [
|
||||
...Object.keys(playlistsParams(null, "")),
|
||||
...Object.keys(playlistViewParamUpdates({ sort: null, filters: {}, groupBy: false })),
|
||||
],
|
||||
activity: () => [
|
||||
...Object.keys(activityParamUpdates(DEFAULT_ACTIVITY_STATE)),
|
||||
...Object.keys(logsParamUpdates(DEFAULT_LOGS_STATE)),
|
||||
],
|
||||
};
|
||||
|
||||
function sources(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) return sources(path);
|
||||
return /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
// One of each form a writer is handed its value in: a scan that matched nothing in the tree would
|
||||
// pass every check.
|
||||
const WRITER_FIXTURE = `
|
||||
useUrlMirror({ fxa: x, fxf });
|
||||
<Modal url={{ fxb: String(id) }} />
|
||||
useBackToClose(onClose, { fxc: "1" });
|
||||
useBackToClose(() => setOpen(false), { fxd: "1" });
|
||||
urlWithParams({ ...fxCodec({ ...fxState, job }), fxe: "1" });
|
||||
useUrlMirror(shown ? { fxg: x } : null);
|
||||
`;
|
||||
|
||||
// Where a writer outside lib/ is handed its value: the address writers' argument, and an
|
||||
// overlay's (Modal's `url`, `useBackToClose`'s second). An object literal (its keys are scanned), a
|
||||
// call of a lib/ function — the codecs, and the shell's own (pageRootParams, …) — or a choice
|
||||
// between them; anything else (a variable, a helper of the module's own) is a key no scan reads.
|
||||
const WRITER_CALLS = /\b(useUrlMirror|urlWithParams|useBackToClose)\(|\burl=\{/g;
|
||||
function closing(text: string, open: number): number {
|
||||
let depth = 0;
|
||||
for (let i = open; i < text.length; i++) {
|
||||
const c = text[i]!;
|
||||
if ("([{".includes(c)) depth++;
|
||||
else if (")]}".includes(c) && --depth === 0) return i;
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
/** The top-level pieces of `text` split at `sep` (outside any brackets). */
|
||||
function topLevel(text: string, sep: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let from = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i]!;
|
||||
if ("([{".includes(c)) depth++;
|
||||
else if (")]}".includes(c)) depth--;
|
||||
else if (depth === 0 && c === sep) {
|
||||
out.push(text.slice(from, i));
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(text.slice(from));
|
||||
return out.map((s) => s.trim());
|
||||
}
|
||||
/** The top-level pieces of every writer's object literal in `text` (`a: x`, `...f(y)`, `[K]: v`, `b`):
|
||||
* the values `writerValueOk` passes, read the same way, a choice's branches included — every literal
|
||||
* it lets through is one whose keys are scanned. */
|
||||
function writerObjects(text: string): string[][] {
|
||||
return writerValues(text).flatMap(literalObjects);
|
||||
}
|
||||
function literalObjects(value: string): string[][] {
|
||||
const v = value.trim();
|
||||
const choice = topLevel(v, "?");
|
||||
if (choice.length === 2) return topLevel(choice[1]!, ":").flatMap(literalObjects);
|
||||
return v.startsWith("{") ? [topLevel(v.slice(1, closing(v, 0)), ",").filter(Boolean)] : [];
|
||||
}
|
||||
/** Where the call of the name ending at `at` opens its arguments, past a type argument list of any
|
||||
* depth (`<Array<X>>`, `<(v: V) => V>`); null when the name is not called there (an import). */
|
||||
function callOpen(text: string, at: number): number | null {
|
||||
let i = at;
|
||||
while (/\s/.test(text[i] ?? "")) i++;
|
||||
if (text[i] === "<") {
|
||||
let depth = 0;
|
||||
for (; i < text.length; i++) {
|
||||
if (text[i] === "<") depth++;
|
||||
else if (text[i] === ">" && text[i - 1] !== "=" && --depth === 0) break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return text[i] === "(" ? i : null;
|
||||
}
|
||||
/** The key a piece names as a literal: `key: value`, or the shorthand `key`. */
|
||||
const keyOf = (piece: string) => /^(\w+)\s*:/.exec(piece)?.[1] ?? /^(\w+)$/.exec(piece)?.[1];
|
||||
|
||||
const LIB_EXPORTS = new Set(
|
||||
sources(join(SRC, "lib")).flatMap((f) =>
|
||||
[...readFileSync(f, "utf8").matchAll(/export (?:function|const) (\w+)/g)].map((m) => m[1]!),
|
||||
),
|
||||
);
|
||||
function writerValueOk(value: string): boolean {
|
||||
const v = value.trim();
|
||||
if (v === "null" || v === "undefined") return true;
|
||||
const choice = topLevel(v, "?");
|
||||
if (choice.length === 2) return topLevel(choice[1]!, ":").every(writerValueOk);
|
||||
if (v.startsWith("{")) {
|
||||
// Its spreads are a lib/ function's result too; its other pieces a literal key and its value —
|
||||
// a computed or a shorthand one is a key no literal scan reads.
|
||||
return topLevel(v.slice(1, closing(v, 0)), ",")
|
||||
.filter(Boolean)
|
||||
.every((piece) =>
|
||||
piece.startsWith("...") ? writerValueOk(piece.slice(3)) : /^\w+\s*:/.test(piece),
|
||||
);
|
||||
}
|
||||
const call = /^(\w+)\(/.exec(v);
|
||||
return (
|
||||
call != null && LIB_EXPORTS.has(call[1]!) && closing(v, call[0].length - 1) === v.length - 1
|
||||
);
|
||||
}
|
||||
function writerValues(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const m of text.matchAll(WRITER_CALLS)) {
|
||||
const open = m.index + m[0].length - 1;
|
||||
const args = topLevel(text.slice(open + 1, closing(text, open)), ",");
|
||||
if (m[1] === "useBackToClose") {
|
||||
if (args.length > 1) out.push(args[1]!);
|
||||
} else if (args[0]) out.push(args[0]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function unreadWriterValues(): string[] {
|
||||
const out: string[] = [];
|
||||
for (const file of sources(SRC)) {
|
||||
const where = relative(SRC, file).split("\\").join("/");
|
||||
if (where.startsWith("lib/")) continue;
|
||||
for (const value of writerValues(readFileSync(file, "utf8"))) {
|
||||
// ui/Modal hands its own `url` prop on: its callers' values are the ones checked.
|
||||
if (where === "components/ui/Modal.tsx" && value === "url") continue;
|
||||
// The shell's page switch writes what the declarations name (moduleUrlKeys, foreignUrlKeys —
|
||||
// every module's keys cleared) and the shell's own: its helpers are that, held above.
|
||||
if (where === "features/navigation/NavigationProvider.tsx") continue;
|
||||
if (!writerValueOk(value)) out.push(`${where}: ${value.slice(0, 60)}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Key literals that reach the address bar's QUERY: the literal keys of a writer's object, and a
|
||||
// `.get("…")` / `.has("…")` in a file that reads the live query (any params object there: the
|
||||
// two-line form names its params before it reads them). The fragment (`hash`) is not the query.
|
||||
function queryKeyLiterals(): { key: string; where: string }[] {
|
||||
const out: { key: string; where: string }[] = [];
|
||||
for (const file of sources(SRC)) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const where = relative(SRC, file).split("\\").join("/");
|
||||
for (const pieces of writerObjects(text)) {
|
||||
for (const piece of pieces) {
|
||||
const key = keyOf(piece);
|
||||
if (key) out.push({ key, where });
|
||||
}
|
||||
}
|
||||
if (!text.includes("location.search")) continue;
|
||||
for (const m of text.matchAll(/(\b\w+|\))\.(?:get|has)\("(\w+)"\)/g)) {
|
||||
if (m[1] !== "hash") out.push({ key: m[2]!, where });
|
||||
}
|
||||
}
|
||||
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 pieces of writerObjects(text)) {
|
||||
const computed = pieces.find((piece) => piece.startsWith("["));
|
||||
if (computed) out.push(`${where}: ${computed.slice(0, 60)}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The keys whose value is what the user typed (a search box, a column filter map with a text
|
||||
// filter in it), and every other one: chosen from what the page offers (a tab, a sort, a filter's
|
||||
// option, an id).
|
||||
const TYPED_KEYS = ["aq", "cf", "cq", "fq", "plf", "plq", "pq", "q", "yt"];
|
||||
const PICKED_KEYS = [
|
||||
"aact",
|
||||
"at",
|
||||
"au",
|
||||
"aw",
|
||||
"cfgtab",
|
||||
"channel",
|
||||
"csort",
|
||||
"ctab",
|
||||
"folder",
|
||||
"fplay",
|
||||
"from",
|
||||
"fsort",
|
||||
"fview",
|
||||
"hb",
|
||||
"item",
|
||||
"lev",
|
||||
"lgrp",
|
||||
"live",
|
||||
"ljob",
|
||||
"lreq",
|
||||
"lrun",
|
||||
"lsrc",
|
||||
"maxage",
|
||||
"maxdur",
|
||||
"mindur",
|
||||
"normal",
|
||||
"notag",
|
||||
"pf",
|
||||
"pl",
|
||||
"plgroup",
|
||||
"plsort",
|
||||
"pqf",
|
||||
"pscope",
|
||||
"pshow",
|
||||
"psort",
|
||||
"rjob",
|
||||
"rst",
|
||||
"scope",
|
||||
"shorts",
|
||||
"show",
|
||||
"sort",
|
||||
"source",
|
||||
"stab",
|
||||
"tagmode",
|
||||
"tagmode_language",
|
||||
"tagmode_other",
|
||||
"tagmode_topic",
|
||||
"tags",
|
||||
"to",
|
||||
"utab",
|
||||
"v",
|
||||
"year",
|
||||
"ytn",
|
||||
];
|
||||
|
||||
// A retired page's link keys: read once to open the page that replaced it, and cleared (lib/urlState
|
||||
// RETIRED) — no module writes them any more.
|
||||
const RETIRED_LINK_KEYS = ["lu", "lq", "lwin"];
|
||||
|
||||
describe("module URL keys", () => {
|
||||
const owner = new Map<string, Page>();
|
||||
for (const page of REGISTERED_PAGES) {
|
||||
for (const key of moduleDef(page).urlKeys) owner.set(key, owner.get(key) ?? page);
|
||||
}
|
||||
|
||||
it("belong to one module each", () => {
|
||||
const all = REGISTERED_PAGES.flatMap((p) => moduleDef(p).urlKeys);
|
||||
expect(all.filter((k, i) => all.indexOf(k) !== i)).toEqual([]);
|
||||
});
|
||||
|
||||
it("are never a shell key", () => {
|
||||
const shell: readonly string[] = SHELL_KEYS;
|
||||
expect([...owner.keys()].filter((k) => shell.includes(k))).toEqual([]);
|
||||
// The welcome card's sub-view key is the shell's own.
|
||||
expect(shell).toContain(MODE_URL.key);
|
||||
});
|
||||
|
||||
// The channel page opens over any page, so no page's switch or stamp may clear its view's keys as
|
||||
// its own: they are the shell's, and exactly what the page's encoder writes.
|
||||
it("leave the channel page's keys to the shell", () => {
|
||||
const shell: readonly string[] = SHELL_KEYS;
|
||||
expect(CHANNEL_PAGE_KEYS.filter((k) => !shell.includes(k))).toEqual([]);
|
||||
const written = channelPageParamUpdates({
|
||||
tab: "about",
|
||||
sort: "oldest",
|
||||
types: { includeNormal: true, includeShorts: true, includeLive: true },
|
||||
});
|
||||
expect(Object.keys(written).sort()).toEqual([...CHANNEL_PAGE_KEYS].sort());
|
||||
expect(Object.keys(channelPageParamUpdates(null)).sort()).toEqual(
|
||||
[...CHANNEL_PAGE_KEYS].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
// The player opens over any page too, and names itself in its own history entry.
|
||||
it("leave the player's keys to the shell", () => {
|
||||
const shell: readonly string[] = SHELL_KEYS;
|
||||
expect(PLAYER_KEYS.filter((k) => !shell.includes(k))).toEqual([]);
|
||||
const written = playerParamUpdates({ watch: "v1", related: "v2", panel: "related" });
|
||||
expect(Object.keys(written).sort()).toEqual([...PLAYER_KEYS].sort());
|
||||
});
|
||||
|
||||
it.each([...REGISTERED_PAGES])("are exactly what %s's encoders write", (page) => {
|
||||
const declared = [...moduleDef(page).urlKeys].sort();
|
||||
const writes = WRITES[page];
|
||||
if (!declared.length) {
|
||||
expect(writes).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
expect(writes, `${page} declares keys but has no WRITES row`).toBeDefined();
|
||||
expect([...new Set(writes!())].sort()).toEqual(declared);
|
||||
});
|
||||
|
||||
it("cover every key literal the app reads or writes in the address bar", () => {
|
||||
const shell: readonly string[] = SHELL_KEYS;
|
||||
const undeclared = queryKeyLiterals().filter(
|
||||
({ key }) => !owner.has(key) && !shell.includes(key) && !RETIRED_LINK_KEYS.includes(key),
|
||||
);
|
||||
expect(undeclared).toEqual([]);
|
||||
});
|
||||
|
||||
it("the literal scan sees the call sites it is meant to (a regex that matched nothing would pass)", () => {
|
||||
const seen = new Set(queryKeyLiterals().map(({ key }) => key));
|
||||
// A writer's literal, a one-line read, a two-line read, a retired link's.
|
||||
for (const key of ["page", "link", "plq", "folder", "lq"]) expect(seen).toContain(key);
|
||||
});
|
||||
|
||||
it("the writer scans see each form they are meant to", () => {
|
||||
const keys = writerObjects(WRITER_FIXTURE).flatMap((pieces) =>
|
||||
pieces.flatMap((piece) => (piece.startsWith("...") ? [] : (keyOf(piece) ?? []))),
|
||||
);
|
||||
expect(keys.sort()).toEqual(["fxa", "fxb", "fxc", "fxd", "fxe", "fxf", "fxg"]);
|
||||
expect(writerValues(WRITER_FIXTURE)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("are written outside lib/ only as a literal or a lib/ function's result", () => {
|
||||
expect(computedWriterKeys()).toEqual([]);
|
||||
expect(unreadWriterValues()).toEqual([]);
|
||||
// …which the check tells apart (a check that passed everything would pass here too).
|
||||
expect(writerValueOk("{ page: p, ...activityParamUpdates(state) }")).toBe(true);
|
||||
expect(writerValueOk('shown ? playlistsParams(id, q) : playlistsParams(null, "")')).toBe(true);
|
||||
expect(writerValueOk("upd")).toBe(false);
|
||||
expect(writerValueOk("myOwnParams(v)")).toBe(false);
|
||||
expect(writerValueOk("{ ...st, zz: 1 }")).toBe(false);
|
||||
// A key no scan reads as a literal: computed, or shorthand.
|
||||
expect(writerValueOk("{ page: p, [K]: v }")).toBe(false);
|
||||
expect(writerValueOk("{ page: p, zz }")).toBe(false);
|
||||
expect(writerValueOk("Object.fromEntries(rows)")).toBe(false);
|
||||
});
|
||||
|
||||
// A sub-view's address key is written by the history plumbing in lib/, from its definition: each
|
||||
// one is in a WRITES row (Plex's) or the shell's keys (the welcome card's), by name.
|
||||
it("name their sub-views' keys", () => {
|
||||
const defined = sources(SRC).flatMap((f) =>
|
||||
[...readFileSync(f, "utf8").matchAll(/const (\w+): SubviewUrl</g)].map((m) => m[1]!),
|
||||
);
|
||||
expect(defined.sort()).toEqual(["MODE_URL", "PLEX_SUB_URL"]);
|
||||
});
|
||||
|
||||
// …and every sub-view is handed one of those, by name: a definition written any other way (inline,
|
||||
// `satisfies`) is one the census above does not count.
|
||||
it("hand each sub-view a definition the census names", () => {
|
||||
const handed = (text: string) =>
|
||||
[...text.matchAll(/\buseHistorySubview\b/g)].flatMap((m) => {
|
||||
const open = callOpen(text, m.index + m[0].length);
|
||||
if (open == null) return [];
|
||||
const args = topLevel(text.slice(open + 1, closing(text, open)), ",");
|
||||
return args.length > 1 ? [args[1]!] : [];
|
||||
});
|
||||
const named = new Set(["MODE_URL", "PLEX_SUB_URL"]);
|
||||
const unnamed = (text: string) => handed(text).filter((arg) => !named.has(arg));
|
||||
const files = sources(SRC).filter(
|
||||
(f) => !relative(SRC, f).split("\\").join("/").startsWith("lib/history"),
|
||||
);
|
||||
expect(files.flatMap((f) => unnamed(readFileSync(f, "utf8")))).toEqual([]);
|
||||
// The call sites are seen (a scan of none would pass)…
|
||||
expect(new Set(files.flatMap((f) => handed(readFileSync(f, "utf8"))))).toEqual(named);
|
||||
// …and the check tells an inline definition from a named one.
|
||||
expect(unnamed('useHistorySubview(sub, { key: "zz", encode, decode })')).toEqual([
|
||||
'{ key: "zz", encode, decode }',
|
||||
]);
|
||||
expect(unnamed("useHistorySubview<Sub>(sub, PLEX_SUB_URL)")).toEqual([]);
|
||||
expect(unnamed('useHistorySubview<Array<string>>(sub, { key: "zz" })')).toEqual([
|
||||
'{ key: "zz" }',
|
||||
]);
|
||||
expect(unnamed('useHistorySubview<(v: V) => V>(sub, { key: "zz" })')).toEqual([
|
||||
'{ key: "zz" }',
|
||||
]);
|
||||
// Under another name it is a call no scan sees.
|
||||
const aliased = (text: string) => /\buseHistorySubview\s+as\b/.test(text);
|
||||
expect(files.filter((f) => aliased(readFileSync(f, "utf8")))).toEqual([]);
|
||||
expect(aliased('import { useHistorySubview as useSub } from "./history";')).toBe(true);
|
||||
});
|
||||
|
||||
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([]);
|
||||
}
|
||||
// Every key sorted one way or the other, by name: a new one fails here until it is decided.
|
||||
const typed = new Set(typedUrlKeys());
|
||||
const all = REGISTERED_PAGES.flatMap((p) => moduleDef(p).urlKeys);
|
||||
expect([...typed].sort()).toEqual(TYPED_KEYS);
|
||||
expect(all.filter((k) => !typed.has(k)).sort()).toEqual(PICKED_KEYS);
|
||||
// …which the client log is handed as it installs.
|
||||
expect(readFileSync(join(SRC, "main.tsx"), "utf8")).toContain(
|
||||
"installClientLog(appCopyOf, typedUrlKeys());",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 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");
|
||||
// A test tagged with the page that opens a bookmark of what it set (`bookmarked(`).
|
||||
const tests = spec.split(/\n\s*test\(/).slice(1);
|
||||
const opened = (page: Page) =>
|
||||
tests.some((t) => t.includes(`tag: "@${page}"`) && t.includes("bookmarked("));
|
||||
|
||||
it.each(REGISTERED_PAGES.filter((p) => moduleDef(p).urlKeys.length > 0 && p !== "plex"))(
|
||||
"takes a round trip through %s",
|
||||
(page) => {
|
||||
expect(opened(page)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("names the page it leaves out", () => {
|
||||
expect(opened("plex")).toBe(false);
|
||||
expect(spec).toContain("Not Plex:");
|
||||
});
|
||||
});
|
||||
|
||||
// A page that opens the player an address names itself (as its queue) says so in its definition, and
|
||||
// the shell's player stands back over it: one that did not would get the shell's too — two players.
|
||||
describe("the player an address names", () => {
|
||||
const opensIt = (page: Page) =>
|
||||
sources(join(SRC, "modules", page)).some((f) =>
|
||||
readFileSync(f, "utf8").includes("useOpenLinkedPlayer("),
|
||||
);
|
||||
|
||||
it.each([...REGISTERED_PAGES])("is opened by %s exactly when it says it plays videos", (page) => {
|
||||
expect(!!moduleDef(page).playsVideos).toBe(opensIt(page));
|
||||
});
|
||||
|
||||
it("is left to the shell on every other page", () => {
|
||||
const app = readFileSync(join(SRC, "shell", "App.tsx"), "utf8");
|
||||
expect(app).toContain(
|
||||
"<LinkedPlayer hosted={!!channelView || !!moduleDef(page).playsVideos} />",
|
||||
);
|
||||
expect(REGISTERED_PAGES.filter(opensIt).sort()).toEqual(["feed", "home", "playlists"]);
|
||||
});
|
||||
});
|
||||
@@ -297,7 +297,9 @@ describe("Activity", () => {
|
||||
|
||||
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
|
||||
// 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 addOther = window.addEventListener.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.
|
||||
window.history.pushState(null, "", "/?page=activity&at=logs&aw=90d&lrun=41");
|
||||
for (const l of [...listeners]) {
|
||||
await act(async () => l(new PopStateEvent("popstate")));
|
||||
act(() => l(new PopStateEvent("popstate")));
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(logsMock).toHaveBeenLastCalledWith(expect.objectContaining({ run_id: 41 }), null),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ScrollText } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
|
||||
export const activityModule: ModuleDef = {
|
||||
@@ -12,4 +13,6 @@ export const activityModule: ModuleDef = {
|
||||
system: true,
|
||||
gate: (me) => me.role === "admin",
|
||||
Page: lazyWithPreload(() => import("./Activity")),
|
||||
urlKeys: ACTIVITY_KEYS,
|
||||
typedUrlKeys: ["aq"],
|
||||
};
|
||||
|
||||
@@ -106,13 +106,13 @@ describe("ChannelsProvider — shareable URL", () => {
|
||||
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
||||
});
|
||||
|
||||
it("mirrors the tab into the address bar and clears it back to the default", () => {
|
||||
it("mirrors the tab into the address bar, the default one too", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setView("blocked"));
|
||||
expect(new URLSearchParams(window.location.search).get("ctab")).toBe("blocked");
|
||||
// Back to the default tab clears the key (only non-defaults are emitted).
|
||||
// The default tab is named as well: a link without it would open the tab this browser remembers.
|
||||
act(() => result.current.setView("subscribed"));
|
||||
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
|
||||
expect(new URLSearchParams(window.location.search).get("ctab")).toBe("subscribed");
|
||||
});
|
||||
|
||||
it("a PARTIAL shared link does not clobber the account's stored sort/filters", () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useSortState } from "../../lib/useSortState";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { useFilterState } from "../../lib/useFilterState";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { rememberNavPosition } from "../../lib/history";
|
||||
import {
|
||||
channelsParamsToState,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from "../../lib/channelsUrlState";
|
||||
import type { FilterMap } from "../../lib/tableColumns";
|
||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { urlPage } from "../../features/navigation/urlPage";
|
||||
import {
|
||||
ChannelLayoutContext,
|
||||
ChannelsActionsContext,
|
||||
@@ -42,7 +44,8 @@ import {
|
||||
//
|
||||
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
|
||||
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are
|
||||
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
|
||||
// per-account persisted (localStorage); the search lives only in the address (below); focus and the
|
||||
// reset token are ephemeral. An account
|
||||
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
|
||||
//
|
||||
// The contexts, their value types and the hooks live in channelsContext.ts.
|
||||
@@ -92,14 +95,20 @@ function readChannelsUrlSeed(): ChannelsUrlState | null {
|
||||
view: clampView(getAccountRaw(LS.channelsView)),
|
||||
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
|
||||
filters: readStoredFilters(),
|
||||
search: "",
|
||||
});
|
||||
}
|
||||
|
||||
export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
const { page } = useNavigation();
|
||||
const nav = useNavigation();
|
||||
const { page } = nav;
|
||||
const { setPage } = useNavigationActions();
|
||||
|
||||
const [search, setSearchState] = useState("");
|
||||
// Shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||
const [urlSeed] = useState(readChannelsUrlSeed);
|
||||
const seededRef = useRef(false);
|
||||
|
||||
const [search, setSearchState] = useState(() => urlSeed?.search ?? "");
|
||||
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
|
||||
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
||||
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
||||
@@ -120,10 +129,6 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
const [focusNameRaw, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
|
||||
// Shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||
const [urlSeed] = useState(readChannelsUrlSeed);
|
||||
const seededRef = useRef(false);
|
||||
|
||||
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
|
||||
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
|
||||
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
|
||||
@@ -180,7 +185,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
// column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
|
||||
setFilters({ sync: "needsFull" });
|
||||
setView("subscribed"); // the sync filter applies to the subscriptions tab
|
||||
setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips
|
||||
setFiltersResetToken((n) => n + 1); // also clear the name search + tag chips
|
||||
setPage("channels");
|
||||
}, [setFilters, setView, setPage]);
|
||||
|
||||
@@ -245,11 +250,17 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
],
|
||||
);
|
||||
|
||||
// Mirror the content state (tab + sort + column filters) into the address bar under the namespaced
|
||||
// keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared
|
||||
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
|
||||
// deliberately transient rail-search, like the feed's and plex's.
|
||||
useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null));
|
||||
// Mirror the content state (tab + sort + column filters + the name search) into the address bar under
|
||||
// the namespaced keys so a manager view is shareable. Gated on urlPage===channels: off the manager (or
|
||||
// under a channel page) the keys are cleared (localStorage still holds the rest; it re-mirrors on
|
||||
// return; the search is this visit's). The search is written once the typing pauses: one address-bar
|
||||
// write per keystroke is history churn a browser throttles.
|
||||
const typed = useDebounced(search, 300);
|
||||
useUrlMirror(
|
||||
channelsParamUpdates(
|
||||
urlPage(nav) === "channels" ? { view, sort, filters, search: typed } : null,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<ChannelsActionsContext.Provider value={actions}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Tv } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
import { CHANNELS_KEYS } from "../../lib/channelsUrlState";
|
||||
import { ChannelsProvider } from "./ChannelsProvider";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
|
||||
@@ -13,4 +14,6 @@ export const channelsModule: ModuleDef = {
|
||||
Page: lazyWithPreload(() => import("./Channels")),
|
||||
maxWidth: "96rem",
|
||||
Store: ChannelsProvider,
|
||||
urlKeys: CHANNELS_KEYS,
|
||||
typedUrlKeys: ["cq", "cf"],
|
||||
};
|
||||
|
||||
@@ -55,6 +55,8 @@ import ConfigPanel from "./ConfigPanel";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
const renderPanel = () =>
|
||||
@@ -90,3 +92,24 @@ describe("Configuration sections", () => {
|
||||
expect(screen.queryByText("config.fields.ka.label")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The URL is the view: the group on screen is named in the address, so a bookmark opens it.
|
||||
describe("the group in the address", () => {
|
||||
const cfgtab = () => new URLSearchParams(window.location.search).get("cfgtab");
|
||||
|
||||
it("opens on the group a link names, and names the one picked", async () => {
|
||||
window.history.replaceState(null, "", "/?page=config&cfgtab=c");
|
||||
renderPanel();
|
||||
expect(await screen.findByText("config.fields.kc.label")).toBeTruthy();
|
||||
expect(cfgtab()).toBe("c");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "config.groups.b" }));
|
||||
expect(cfgtab()).toBe("b");
|
||||
});
|
||||
|
||||
it("names the group shown when the link's is gone from the registry", async () => {
|
||||
window.history.replaceState(null, "", "/?page=config&cfgtab=retired");
|
||||
renderPanel();
|
||||
expect(await screen.findByText("config.fields.ka.label")).toBeTruthy();
|
||||
expect(cfgtab()).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
import FilterSelect from "../../components/ui/FilterSelect";
|
||||
import { DraftSaveBar, type SaveState } from "../../components/ui/DraftSaveBar";
|
||||
import { LS, useTabPersistedState } from "../../lib/storage";
|
||||
import { CONFIG_TAB_KEY, configTabParams } from "../../lib/configTab";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { CheckRow } from "../../components/ui/NativeControls";
|
||||
@@ -95,7 +97,7 @@ export default function ConfigPanel() {
|
||||
const [reseedToken, setReseedToken] = useState(0);
|
||||
// One tab per config group (persisted). Called before the loading guard so hook order is
|
||||
// stable; the active id is clamped to a real group at render time once data has loaded.
|
||||
const [tab, setTab] = useTabPersistedState(LS.configTab);
|
||||
const [tab, setTab] = useTabPersistedState(LS.configTab, "", CONFIG_TAB_KEY);
|
||||
|
||||
// A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
|
||||
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
|
||||
@@ -181,6 +183,17 @@ export default function ConfigPanel() {
|
||||
setDraft((d) => ({ ...d, plex_libraries: next.join(",") }));
|
||||
};
|
||||
|
||||
// The groups in the registry's sections, in order (sixteen tabs wrapped to three rows at 133%
|
||||
// zoom, so they are a side list under four headings, as Settings has).
|
||||
const groupKeys = data?.sections.flatMap((s) => s.groups) ?? [];
|
||||
// Clamp the persisted tab to a real group (the registry can change between sessions). The
|
||||
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
|
||||
// never silently lost behind the global Save bar.
|
||||
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
|
||||
// The address names the group on screen (always: see SettingsPanel); until the registry is in, the
|
||||
// one it was opened on.
|
||||
useUrlMirror(configTabParams((data ? activeGroup : tab) || null));
|
||||
|
||||
if (q.isError && !data) {
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
@@ -192,13 +205,6 @@ export default function ConfigPanel() {
|
||||
return <LoadingState label={t("config.loading")} />;
|
||||
}
|
||||
|
||||
// The groups in the registry's sections, in order (sixteen tabs wrapped to three rows at 133%
|
||||
// zoom, so they are a side list under four headings, as Settings has).
|
||||
const groupKeys = data.sections.flatMap((s) => s.groups);
|
||||
// Clamp the persisted tab to a real group (the registry can change between sessions). The
|
||||
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
|
||||
// never silently lost behind the global Save bar.
|
||||
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
|
||||
const dirtyByGroup = new Set(dirtyItems.map((i) => i.group).filter(Boolean));
|
||||
const groupTabs = data.sections.flatMap((s) =>
|
||||
s.groups.map((g) => ({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
import { CONFIG_TAB_KEY } from "../../lib/configTab";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
|
||||
export const configModule: ModuleDef = {
|
||||
@@ -12,4 +13,5 @@ export const configModule: ModuleDef = {
|
||||
system: true,
|
||||
gate: (me) => me.role === "admin",
|
||||
Page: lazyWithPreload(() => import("./ConfigPanel")),
|
||||
urlKeys: [CONFIG_TAB_KEY],
|
||||
};
|
||||
|
||||
@@ -34,6 +34,12 @@ import { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||
import { useShowChannelInFeed } from "./useFeedPageHooks";
|
||||
import { SHOW_IDS } from "./feedFiltersInit";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import {
|
||||
channelPageParamUpdates,
|
||||
channelPageViewFrom,
|
||||
type ChannelTab,
|
||||
} from "../../lib/channelPageUrlState";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { notify } from "../../lib/notifications";
|
||||
@@ -88,7 +94,21 @@ export default function ChannelPage({
|
||||
// to: the feed defaults to scope "my", so filtering it to a channel you don't follow would just
|
||||
// land you on an empty page.
|
||||
const onShowInFeed = useShowChannelInFeed(channelId);
|
||||
const [tab, setTab] = useState<"videos" | "about">("videos");
|
||||
// The view this page opens in: what its address names (a reload, a bookmark), else a fresh page —
|
||||
// the videos, newest first, in the feed's content types (see `ownFilters` below). Read once, at
|
||||
// mount: the page is keyed by its channel, and from here on it writes its address itself.
|
||||
const [opened] = useState(() =>
|
||||
channelPageViewFrom(new URLSearchParams(window.location.search), {
|
||||
tab: "videos",
|
||||
sort: "newest",
|
||||
types: {
|
||||
includeNormal: globalFilters.includeNormal,
|
||||
includeShorts: globalFilters.includeShorts,
|
||||
includeLive: globalFilters.includeLive,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [tab, setTab] = useState<ChannelTab>(opened.tab);
|
||||
const panel = useTabPanel();
|
||||
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
|
||||
// fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is
|
||||
@@ -266,17 +286,27 @@ export default function ChannelPage({
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
q: "",
|
||||
sort: "newest",
|
||||
sort: opened.sort,
|
||||
scope: "all",
|
||||
librarySource: "all",
|
||||
// Content-type prefs inherited from the global feed (seeded once) — no longer forced.
|
||||
includeNormal: globalFilters.includeNormal,
|
||||
includeShorts: globalFilters.includeShorts,
|
||||
includeLive: globalFilters.includeLive,
|
||||
...opened.types,
|
||||
show: "unwatched",
|
||||
channelIds: [channelId],
|
||||
}));
|
||||
const filters: FeedFilters = { ...ownFilters, show };
|
||||
// The address is this view: a reload or a bookmark reopens the tab, the sort and the content types.
|
||||
useUrlMirror(
|
||||
channelPageParamUpdates({
|
||||
tab,
|
||||
sort: ownFilters.sort,
|
||||
types: {
|
||||
includeNormal: ownFilters.includeNormal,
|
||||
includeShorts: ownFilters.includeShorts,
|
||||
includeLive: ownFilters.includeLive,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const setFilters = (next: FeedFilters) => {
|
||||
if (next.show !== show) setShow(next.show);
|
||||
setOwnFilters(next);
|
||||
@@ -436,7 +466,7 @@ export default function ChannelPage({
|
||||
idBase={panel.idBase}
|
||||
className="flex gap-1"
|
||||
active={tab}
|
||||
onChange={(id) => setTab(id as "videos" | "about")}
|
||||
onChange={(id) => setTab(id as ChannelTab)}
|
||||
tabs={[
|
||||
{ id: "videos", label: t("channel.tabVideos") },
|
||||
{ id: "about", label: t("channel.tabAbout") },
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel page's address is its view (the owner, 2026-10-06: a bookmark reopens exactly that view):
|
||||
// its tab, its sort and the content types it shows are in the address, and a link reopens them. They
|
||||
// used to live in the page's own state alone, so a reload landed on the videos tab, newest first.
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, act, screen, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { FeedFilters } from "../../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
// The feed's content types, which a page opened without a link takes.
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: true, includeLive: false }),
|
||||
useFeedFiltersActions: () => ({ setFilters: () => {} }),
|
||||
}));
|
||||
const feed = vi.hoisted(() => ({
|
||||
last: null as null | { filters: FeedFilters; setFilters: (f: FeedFilters) => void },
|
||||
}));
|
||||
vi.mock("./Feed", () => ({
|
||||
default: (p: { filters: FeedFilters; setFilters: (f: FeedFilters) => void }) => {
|
||||
feed.last = p;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
import { api, type ChannelDetail, type Me } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
feed.last = null;
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
|
||||
|
||||
function mount(search: string) {
|
||||
window.history.replaceState({ _chan: CH }, "", `/${search}`);
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||
id: CH,
|
||||
title: "StarTalk",
|
||||
subscribed: true,
|
||||
stored_videos: 10,
|
||||
} as unknown as ChannelDetail);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, preferences: {} } as unknown as Me);
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="StarTalk"
|
||||
me={{ id: 1, can_write: true, can_read: true } as unknown as Me}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const param = (k: string) => new URLSearchParams(window.location.search).get(k);
|
||||
const selectedTab = () =>
|
||||
screen.getAllByRole("tab").find((t) => t.getAttribute("aria-selected") === "true")?.textContent;
|
||||
|
||||
describe("ChannelPage — its address", () => {
|
||||
it("a page opened without a link shows the videos, newest first, in the feed's content types", () => {
|
||||
mount(`?chan=${CH}`);
|
||||
expect(selectedTab()).toBe("channel.tabVideos");
|
||||
expect(feed.last?.filters.sort).toBe("newest");
|
||||
expect(feed.last?.filters.includeShorts).toBe(true);
|
||||
// …and says so, so a bookmark of it reopens those types wherever it is opened.
|
||||
expect(param("chtypes")).toBe("ns");
|
||||
expect(param("chtab")).toBeNull();
|
||||
expect(param("chsort")).toBeNull();
|
||||
});
|
||||
|
||||
it("writes the tab, the sort and the content types the viewer picks", () => {
|
||||
mount(`?chan=${CH}`);
|
||||
act(() =>
|
||||
feed.last!.setFilters({
|
||||
...feed.last!.filters,
|
||||
sort: "oldest",
|
||||
includeShorts: false,
|
||||
includeLive: true,
|
||||
}),
|
||||
);
|
||||
expect(param("chsort")).toBe("oldest");
|
||||
expect(param("chtypes")).toBe("nl");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "channel.tabAbout" }));
|
||||
expect(param("chtab")).toBe("about");
|
||||
expect(param("chan")).toBe(CH);
|
||||
});
|
||||
|
||||
it("reopens the view its address names", () => {
|
||||
mount(`?chan=${CH}&chtab=about&chsort=oldest&chtypes=l`);
|
||||
expect(selectedTab()).toBe("channel.tabAbout");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "channel.tabVideos" }));
|
||||
expect(feed.last?.filters.sort).toBe("oldest");
|
||||
expect(feed.last?.filters.includeNormal).toBe(false);
|
||||
expect(feed.last?.filters.includeShorts).toBe(false);
|
||||
expect(feed.last?.filters.includeLive).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -53,11 +53,13 @@ vi.mock("../../features/player/PlayerModal", async () => {
|
||||
return {
|
||||
default: function Player({
|
||||
video,
|
||||
queue,
|
||||
onState,
|
||||
onWrite,
|
||||
onClose,
|
||||
}: {
|
||||
video: { id: string };
|
||||
queue?: { id: string }[];
|
||||
onState: (id: string, status: string, v?: unknown) => void;
|
||||
onWrite?: (write: Promise<unknown>) => void;
|
||||
onClose: () => void;
|
||||
@@ -71,6 +73,9 @@ vi.mock("../../features/player/PlayerModal", async () => {
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
playing {video.id} in {queue ? queue.map((v) => v.id).join(",") : "no list"}
|
||||
</p>
|
||||
<button onClick={() => onState(video.id, "watched")}>mark</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
@@ -116,6 +121,7 @@ afterEach(() => {
|
||||
player.watchOnUnmount = false;
|
||||
player.flush = null;
|
||||
clearAll();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
// A cached related list (the player's, kept for the session) holding these videos.
|
||||
@@ -190,6 +196,26 @@ function mount({
|
||||
return { qc, feed, summary };
|
||||
}
|
||||
|
||||
// The URL is the view: a reload or a bookmark of the player reopens it once the list is in.
|
||||
describe("Feed — a player the address names", () => {
|
||||
it("reopens on its video, the list as its queue", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=v1");
|
||||
// The catalog has it too: asked before the list is in, the player would open on it alone.
|
||||
const row = vi.spyOn(api, "videoRow").mockResolvedValue(video("new"));
|
||||
mount();
|
||||
expect(await screen.findByText("playing v1 in v1")).toBeTruthy();
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("plays a video the loaded list does not hold alone, from the catalog", async () => {
|
||||
window.history.replaceState(null, "", "/?watch=far");
|
||||
const row = vi.spyOn(api, "videoRow").mockResolvedValue({ ...video("new"), id: "far" });
|
||||
mount();
|
||||
expect(await screen.findByText("playing far in no list")).toBeTruthy();
|
||||
expect(row).toHaveBeenCalledWith("far");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feed — a video marked watched in the player", () => {
|
||||
it("stays out of an Unwatched view when the player writes its position into the cache", async () => {
|
||||
const { qc } = mount();
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import {
|
||||
api,
|
||||
FEED_DEFAULT_SORT,
|
||||
HttpError,
|
||||
refusalReason,
|
||||
type FeedFilters,
|
||||
@@ -40,6 +41,8 @@ import {
|
||||
} from "../../features/video/videoWrites";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { YT_COUNTS } from "../../lib/ytSearchUrlState";
|
||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import {
|
||||
markedRows,
|
||||
@@ -80,6 +83,8 @@ import ViewSwitcher from "../../components/ui/ViewSwitcher";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
|
||||
|
||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
|
||||
|
||||
@@ -146,9 +151,6 @@ const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T
|
||||
return next;
|
||||
};
|
||||
|
||||
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
|
||||
const YT_COUNTS = [20, 40, 60, 100] as const;
|
||||
|
||||
// The live-search refusals a retry cannot answer: the day's cap, turned off on this instance, the
|
||||
// shared budget spent (ours, or YouTube's own) — tomorrow or the admin does.
|
||||
const SEARCH_FINAL_REASONS = new Set([
|
||||
@@ -209,14 +211,18 @@ export default function Feed({
|
||||
// A reset's position 0 lives in the cached lists (see onResetState), where a refetch that set out
|
||||
// before the server took the reset puts the old position back: written again on its landing.
|
||||
const resets = useRef(new Map<string, number>());
|
||||
// The open player: which video and where to start (null = resume from saved position).
|
||||
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
||||
null,
|
||||
);
|
||||
// The open player: which video and where to start (null = resume from saved position). `alone`:
|
||||
// opened from an address naming a video this list has not loaded, so it plays without the queue.
|
||||
const [activeVideo, setActiveVideo] = useState<{
|
||||
video: Video;
|
||||
startAt: number | null;
|
||||
alone?: boolean;
|
||||
} | null>(null);
|
||||
const qc = useQueryClient();
|
||||
const videoMarks = useVideoMarks();
|
||||
// Live search turned off on this instance: the empty feed offers no search that would be refused.
|
||||
const searchOn = useMe().search_enabled;
|
||||
const me = useMe();
|
||||
const searchOn = me.search_enabled;
|
||||
|
||||
// What changed state while the player was open, and the writes still in flight: closing it
|
||||
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
|
||||
@@ -258,8 +264,16 @@ export default function Feed({
|
||||
|
||||
const ytActive = !!ytSearch;
|
||||
// How many live-search results to gather (the count selector replaces a manual "load more";
|
||||
// the free scrape source pages through continuations until this many are collected).
|
||||
const [ytCount, setYtCount] = useState(20);
|
||||
// the free scrape source pages through continuations until this many are collected). Named in the
|
||||
// address with the term, so it is the navigation's, like the term.
|
||||
const { ytCount, ytSearchAsked } = useNavigation();
|
||||
const { setYtCount, askYtSearch } = useNavigationActions();
|
||||
// A search runs by itself only where nobody's reload or link can spend quota with it: the free
|
||||
// page scrape, or a search asked for in this document. An API search reopened by a reload, a link
|
||||
// or Back waits for "Search YouTube" — unless its results are still cached, which a disabled
|
||||
// query shows all the same. Turned off on this instance it runs too: the server refuses it before
|
||||
// any quota, and its refusal is what says so (no press offered that could only be refused).
|
||||
const ytRuns = ytActive && (me.search_source !== "api" || ytSearchAsked || !searchOn);
|
||||
// How to order the loaded results. Its OWN pref, not `filters.sort`: sorting a live-search page
|
||||
// must not silently re-sort the library you go back to. Default `relevance` — the provider's
|
||||
// ranking, which is what the view always showed before it had a control.
|
||||
@@ -293,23 +307,11 @@ export default function Feed({
|
||||
stampFetch(() => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
enabled: ytActive,
|
||||
enabled: ytRuns,
|
||||
staleTime: 5 * 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Remember (in history.state) that this live search was served by the zero-quota scrape source,
|
||||
// so a reload restores the results instead of dropping to the feed — re-fetching scrape pages
|
||||
// costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units).
|
||||
useEffect(() => {
|
||||
if (!ytActive) return;
|
||||
const st = window.history.state;
|
||||
if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry
|
||||
if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) {
|
||||
window.history.replaceState({ ...st, _ytScrape: true }, "");
|
||||
}
|
||||
}, [ytActive, ytSearch, ytQuery.data]);
|
||||
|
||||
// Switching to the relevance sort when a search starts happens atomically in the header's
|
||||
// input onChange (race-free with the query update). Here we only handle the reverse: when
|
||||
// the term is cleared, fall back to the default sort — relevance has no dropdown option and
|
||||
@@ -317,7 +319,7 @@ export default function Feed({
|
||||
const qEmpty = !filters.q.trim();
|
||||
useEffect(() => {
|
||||
if (qEmpty && parseSort(filters.sort).key === "relevance") {
|
||||
setFilters({ ...filters, sort: buildSort("date", "desc") });
|
||||
setFilters({ ...filters, sort: FEED_DEFAULT_SORT });
|
||||
}
|
||||
// Naming `filters` is safe precisely because the write below falsifies the condition above: the
|
||||
// sort it sets is "date", so the one re-run it causes does nothing. The old `[qEmpty]` was
|
||||
@@ -502,6 +504,10 @@ export default function Feed({
|
||||
const saved = savedOverrides[v.id];
|
||||
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 ytQueueOf = (shown: Video[]): Video[] => shown.filter((v) => v.status !== "hidden");
|
||||
const merged: Video[] = withOverrides(loaded);
|
||||
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
|
||||
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
|
||||
@@ -509,6 +515,20 @@ export default function Feed({
|
||||
// sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that
|
||||
// goes hidden still drops out ("visible affects").
|
||||
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
|
||||
// (the feed's filters are in the address under it) — over a live search too, its results the
|
||||
// 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(
|
||||
ytActive ? ytQuery.data !== undefined || ytQuery.isError || !ytRuns : !query.isLoading,
|
||||
(id) => (ytActive ? ytQueueOf(ytShown(ytLoaded)) : playerQueue).find((v) => v.id === id),
|
||||
(video, inList) => {
|
||||
openVideo(video);
|
||||
// Not in what this list has loaded (further down it, or no longer in it): it plays alone.
|
||||
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
|
||||
// pre-search cache. Drop them so the feed re-fetches fresh on return.
|
||||
@@ -521,13 +541,8 @@ export default function Feed({
|
||||
// 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).
|
||||
if (ytActive) {
|
||||
const ytItems: Video[] = sortYtResults(
|
||||
withOverrides(ytLoaded, true),
|
||||
ytSort.key as SortKey,
|
||||
ytSort.dir,
|
||||
lang,
|
||||
);
|
||||
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
|
||||
const ytItems: Video[] = ytShown(ytLoaded);
|
||||
const ytPlayerQueue: Video[] = ytQueueOf(ytItems);
|
||||
const ytError =
|
||||
ytQuery.error instanceof HttpError
|
||||
? ytQuery.error.detail || t("feed.yt.error")
|
||||
@@ -539,7 +554,8 @@ export default function Feed({
|
||||
const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? "");
|
||||
// The backend reports which source served the search; scrape spends no search quota, so we
|
||||
// drop the quota warning + "uses quota" wording in that mode.
|
||||
const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
|
||||
// Before an answer (a search waiting for its press), the instance's own source says it.
|
||||
const zeroQuota = (ytQuery.data?.pages?.[0]?.source ?? me.search_source) === "scrape";
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
@@ -609,7 +625,7 @@ export default function Feed({
|
||||
.catch((e: unknown) =>
|
||||
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
|
||||
);
|
||||
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
|
||||
setFilters({ ...filters, librarySource: "organic", sort: FEED_DEFAULT_SORT });
|
||||
onExitYtSearch();
|
||||
dropFeedCaches();
|
||||
qc.removeQueries({ queryKey: qk.ytSearch() });
|
||||
@@ -621,11 +637,20 @@ export default function Feed({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ytQuery.isLoading ? (
|
||||
{!ytRuns && ytQuery.data === undefined ? (
|
||||
// Reopened, not asked for: the term is on screen, the search waits for a press.
|
||||
<div className="py-16 text-center max-w-md mx-auto">
|
||||
<p className="font-semibold">{t("feed.yt.notRunTitle")}</p>
|
||||
<p className="mt-1 text-sm text-muted">{t("feed.yt.notRunBody")}</p>
|
||||
<Button className="mt-4" icon={<YouTubeIcon />} onClick={askYtSearch}>
|
||||
{t("feed.yt.runAgain")}
|
||||
</Button>
|
||||
</div>
|
||||
) : ytQuery.isLoading ? (
|
||||
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
|
||||
) : ytError && !ytItems.length ? (
|
||||
// A first page that failed is said as the error it is, with Retry where trying again can
|
||||
// help (YouTube busy or unreachable): Search YouTube again with the same term reads the
|
||||
// help (YouTube busy or unreachable): Search YouTube with the same term reads the
|
||||
// failure from the cache and sends nothing.
|
||||
<StateMessage
|
||||
tone="error"
|
||||
@@ -681,7 +706,7 @@ export default function Feed({
|
||||
<PlayerModal
|
||||
video={activeVideo.video}
|
||||
startAt={activeVideo.startAt}
|
||||
queue={ytPlayerQueue}
|
||||
queue={activeVideo.alone ? undefined : ytPlayerQueue}
|
||||
onClose={closePlayer}
|
||||
onState={onState}
|
||||
onWrite={trackSessionWrite}
|
||||
@@ -1006,7 +1031,7 @@ export default function Feed({
|
||||
<PlayerModal
|
||||
video={activeVideo.video}
|
||||
startAt={activeVideo.startAt}
|
||||
queue={playerQueue}
|
||||
queue={activeVideo.alone ? undefined : playerQueue}
|
||||
onClose={closePlayer}
|
||||
onState={onState}
|
||||
onWrite={trackSessionWrite}
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("Feed — a live search's Load more that fails", () => {
|
||||
});
|
||||
|
||||
describe("Feed — a live search whose first page fails", () => {
|
||||
// Search YouTube again with the same term reads the cached failure: Retry is the way to ask again.
|
||||
// Search YouTube with the same term reads the cached failure: Retry is the way to ask again.
|
||||
it("offers Retry where trying again can help, and reads the page on it", async () => {
|
||||
const search = vi
|
||||
.spyOn(api, "searchYoutube")
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A live YouTube search the address reopens (a reload, a link, Back) runs by itself only where it
|
||||
// costs no quota: the free page scrape. An API search spends 100 units, so it waits for "Search
|
||||
// YouTube" — unless its results are still cached, or it was asked for in this document.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/wizard/wizardContext", () => ({
|
||||
useWizard: () => ({ openWizard: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({ items }: { items: { id: string; title: string }[] }) => (
|
||||
<ol aria-label="results">
|
||||
{items.map((v) => (
|
||||
<li key={v.id}>{v.title}</li>
|
||||
))}
|
||||
</ol>
|
||||
),
|
||||
}));
|
||||
|
||||
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 { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import {
|
||||
NavigationActionsContext,
|
||||
NavigationStateContext,
|
||||
type NavigationActions,
|
||||
} from "../../features/navigation/navigationContext";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
const hit = (id: string): Video =>
|
||||
({
|
||||
id,
|
||||
title: `hit ${id}`,
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
}) as unknown as Video;
|
||||
|
||||
const actions = (over: Partial<NavigationActions> = {}): NavigationActions => ({
|
||||
setPage: () => {},
|
||||
openChannel: () => {},
|
||||
closeChannel: () => {},
|
||||
enterYtSearch: () => {},
|
||||
exitYtSearch: () => {},
|
||||
askYtSearch: () => {},
|
||||
setYtCount: () => {},
|
||||
...over,
|
||||
});
|
||||
|
||||
function mount({
|
||||
source,
|
||||
asked,
|
||||
enabled = true,
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
||||
nav = actions(),
|
||||
}: {
|
||||
source: "scrape" | "api";
|
||||
asked: boolean;
|
||||
enabled?: boolean;
|
||||
qc?: QueryClient;
|
||||
nav?: NavigationActions;
|
||||
}) {
|
||||
qc.setQueryData(qk.me(), {
|
||||
id: 1,
|
||||
can_write: true,
|
||||
prefs: {},
|
||||
search_source: source,
|
||||
search_enabled: enabled,
|
||||
});
|
||||
const tree = (count: number, ytSearch: string | null = "lofi") => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<NavigationActionsContext.Provider value={nav}>
|
||||
<NavigationStateContext.Provider
|
||||
value={{
|
||||
page: "feed",
|
||||
channelView: null,
|
||||
ytSearch,
|
||||
ytCount: count,
|
||||
ytSearchAsked: asked,
|
||||
}}
|
||||
>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch={ytSearch}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</NavigationStateContext.Provider>
|
||||
</NavigationActionsContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const view = render(tree(20));
|
||||
return {
|
||||
showing: (count: number) => view.rerender(tree(count)),
|
||||
leave: () => view.rerender(tree(20, null)),
|
||||
};
|
||||
}
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
describe("Feed — a live search the address reopened", () => {
|
||||
it("waits for a press under the API source, and spends nothing meanwhile", async () => {
|
||||
const search = vi.spyOn(api, "searchYoutube");
|
||||
const askYtSearch = vi.fn();
|
||||
mount({ source: "api", asked: false, nav: actions({ askYtSearch }) });
|
||||
expect(screen.getByText("feed.yt.notRunTitle")).toBeTruthy();
|
||||
expect(screen.getByText("feed.yt.resultsFor")).toBeTruthy(); // the term stays on screen
|
||||
expect(screen.getByText("feed.yt.quotaNote")).toBeTruthy();
|
||||
await settle();
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "feed.yt.runAgain" }));
|
||||
expect(askYtSearch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs an API search asked for in this document", async () => {
|
||||
const search = vi
|
||||
.spyOn(api, "searchYoutube")
|
||||
.mockResolvedValue({ items: [hit("a")], next_cursor: null, source: "api" } as never);
|
||||
mount({ source: "api", asked: true });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit a"),
|
||||
);
|
||||
expect(search).toHaveBeenCalledWith("lofi", null, 20);
|
||||
expect(screen.queryByText("feed.yt.notRunTitle")).toBeNull();
|
||||
});
|
||||
|
||||
it("runs by itself under the free scrape source", async () => {
|
||||
const search = vi
|
||||
.spyOn(api, "searchYoutube")
|
||||
.mockResolvedValue({ items: [hit("a")], next_cursor: null, source: "scrape" } as never);
|
||||
mount({ source: "scrape", asked: false });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit a"),
|
||||
);
|
||||
expect(search).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText("feed.yt.freeNote")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows an API search's results still cached, without asking again", async () => {
|
||||
const search = vi.spyOn(api, "searchYoutube");
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.ytSearch("lofi", 20), {
|
||||
pages: [{ items: [hit("c")], next_cursor: null, source: "api" }],
|
||||
pageParams: [null],
|
||||
});
|
||||
mount({ source: "api", asked: false, qc });
|
||||
expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit c");
|
||||
expect(screen.queryByText("feed.yt.notRunTitle")).toBeNull();
|
||||
await settle();
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks again for another count — each count is a search of its own", async () => {
|
||||
const search = vi
|
||||
.spyOn(api, "searchYoutube")
|
||||
.mockResolvedValue({ items: [hit("a")], next_cursor: null, source: "scrape" } as never);
|
||||
const { showing } = mount({ source: "scrape", asked: false });
|
||||
await waitFor(() => expect(search).toHaveBeenCalledWith("lofi", null, 20));
|
||||
showing(40);
|
||||
await waitFor(() => expect(search).toHaveBeenCalledWith("lofi", null, 40));
|
||||
});
|
||||
|
||||
it("says the instance's own source while the first answer is on its way", () => {
|
||||
vi.spyOn(api, "searchYoutube").mockReturnValue(new Promise(() => {}));
|
||||
mount({ source: "scrape", asked: false });
|
||||
expect(screen.getByText("feed.yt.searching")).toBeTruthy();
|
||||
expect(screen.getByText("feed.yt.freeNote")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("turned off on this instance, lets the server's refusal say so — no press offered", async () => {
|
||||
vi.spyOn(api, "searchYoutube").mockRejectedValue(
|
||||
new HttpError(409, "Live search is off", { code: "refused", reason: "search_disabled" }),
|
||||
);
|
||||
mount({ source: "api", asked: false, enabled: false });
|
||||
expect(await screen.findByText("Live search is off")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "feed.yt.runAgain" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// 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,9 +2,11 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
|
||||
import { type FeedFilters } from "../../lib/api";
|
||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../../lib/urlState";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { ownKey, writeJSON } from "../../lib/storage";
|
||||
import { logFilterChange } from "../../lib/filterLog";
|
||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||
import { urlPage } from "../../features/navigation/urlPage";
|
||||
import {
|
||||
FeedFiltersActionsContext,
|
||||
FeedFiltersStateContext,
|
||||
@@ -50,13 +52,16 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||
if (key) writeJSON(key, next);
|
||||
}, []);
|
||||
|
||||
// Mirror the feed filters into the address bar so every feed view is bookmarkable. Gated on
|
||||
// page===feed: on other pages the filter keys are cleared from the URL (localStorage still holds
|
||||
// them; they re-mirror on return to the feed). The channel detail page keeps its own isolated
|
||||
// filters, so those never reach here. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
|
||||
// Mirror the feed filters into the address bar so every feed view is bookmarkable. Gated on the
|
||||
// feed being what the address shows (urlPage): on other pages, and under a channel page, the filter
|
||||
// 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
|
||||
// other providers own.
|
||||
const { page } = useNavigation();
|
||||
useUrlMirror(filterParamUpdates(page === "feed" ? filters : null));
|
||||
// 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 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
|
||||
// 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,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FEED_DEFAULT_SORT } from "../../lib/api";
|
||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { PageHeader } from "../../components/ui/PageShell";
|
||||
@@ -25,9 +26,9 @@ export default function FeedHeader() {
|
||||
placeholder={t("header.searchPlaceholder")}
|
||||
onChange={(q) => {
|
||||
// When a search first appears, rank the feed by relevance — set atomically with the
|
||||
// query (race-free vs. per-keystroke updates). Only overrides the default "newest"
|
||||
// query (race-free vs. per-keystroke updates). Only overrides the default
|
||||
// sort; a custom sort the user chose is left alone. Cleared in Feed.
|
||||
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
|
||||
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === FEED_DEFAULT_SORT;
|
||||
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
||||
}}
|
||||
// Turned off on this instance, the box only filters: no Go that could only be refused.
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api";
|
||||
import {
|
||||
coerceTagModes,
|
||||
coerceUntagged,
|
||||
DEFAULT_TAG_MODES,
|
||||
FEED_DEFAULT_SHOW,
|
||||
FEED_DEFAULT_SORT,
|
||||
type FeedFilters,
|
||||
} from "../../lib/api";
|
||||
import { LS, ownKey, readJSON } from "../../lib/storage";
|
||||
|
||||
// The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider
|
||||
@@ -10,12 +17,12 @@ export const DEFAULT_FILTERS: FeedFilters = {
|
||||
untagged: false,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
sort: FEED_DEFAULT_SORT,
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
show: FEED_DEFAULT_SHOW,
|
||||
};
|
||||
|
||||
/** The watch-state choices, in the order the filter offers them. */
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
import { FEED_KEYS } from "../../lib/urlState";
|
||||
import { YT_SEARCH_KEYS } from "../../lib/ytSearchUrlState";
|
||||
import { FeedStore } from "./FeedStore";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
@@ -15,6 +17,10 @@ export const feedModule: ModuleDef = {
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
|
||||
Store: FeedStore,
|
||||
// Its filters, and the live YouTube search shown in its place (lib/ytSearchUrlState).
|
||||
urlKeys: [...FEED_KEYS, ...YT_SEARCH_KEYS],
|
||||
typedUrlKeys: ["q", "yt"],
|
||||
playsVideos: true,
|
||||
};
|
||||
|
||||
// The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user