Compare commits

...
17 Commits
Author SHA1 Message Date
peter 4084dd2f92 release: 0.113.0 (feature/e46-url-is-the-view) 2026-10-28 02:19:29 +01:00
peter 6aa6a6efdf chore(release): 0.113.0 2026-10-28 02:07:48 +01:00
peter 5686759bca fix(nav): a page settling keeps a reload's overlay entry; an old entry's sub-view goes to its own (review round 4)
- useUrlMirror: a change before the user has done anything since the load is the page settling (the
  rail stepping off a playlist deleted since, Files off a folder gone), not another view: the reload
  mark stays, and the player that reopens takes its entry over — one Back closes it, as before.
- useHistorySubview: an entry an older build wrote is a module's only when its _sub is the view the
  address names under that module's key (the card's push kept the season's `v` beside its `m`).
- Playlists: the rail's default selection and the page's "the rail moved there" read one helper.
- moduleUrl contract: writer literals in a choice's branches are scanned too; the sub-view census
  walks type arguments of any depth and refuses an aliased import.
- A stale comment on the nav press over a live search.
2026-10-28 01:39:26 +01:00
peter 99a33e7fd1 fix(history): a sub-view reads only its own module's from the entry it mounts on
The entry's _sub now names its owner (_subOf, the module's address key). Signed out on a Plex season,
the sign-in card took the season for one of its modes and rendered a card with an empty button; the
other way round, Plex took the card's register view for one of its own. An entry an older build wrote
is read as the module's only when its address names one of that module's views.
2026-10-28 00:29:40 +01:00
peter 8804bdde63 Merge branch 'main' into feature/e46-url-is-the-view 2026-10-28 00:11:30 +01:00
peter 02906d0acb fix(nav): the address keeps what the app rewrites around it (review round 3)
- The Feed press over a live search ends only the search: the feed's own view stays named.
- Leaving a search steps back only off an entry the app pushed (_ytOwn); one a link or a reload
  opened is cleared in place, also where the Navigation API is missing.
- useUrlMirror rewrites a changed view through replaceView, so a reload's overlay marks go; its
  first write and its landing writes keep them for an overlay that reopens.
- Files and Playlists: a box emptied and filled again settles at once, never naming the ended search.
- Playlists: a linked player is scoped to the playlist the store started with, and only the rail's
  own move to the first playlist is no pick.
- Contract: writer objects are scanned to their matching brace, a computed or shorthand key is
  refused, and every useHistorySubview call hands a named definition.
- Lint: playwright/no-skipped-test also refuses fixme (gate snapshot regenerated).
- FEED_DEFAULT_SORT at the three remaining sites; linkedNotListed says to search by name.
2026-10-27 23:33:22 +01:00
peter 66e427dfa4 fix(nav): a search the address opened marks its entry, so a channel over it keeps it
A search reached by a reload or a link had no _yt mark on its entry, so a channel opened from its
results carried none, and a landing back on that channel (an overlay over it closing) ended the
search under it.
2026-10-27 22:43:17 +01:00
peter e2b725dba7 test(e2e): the Files sort round trip starts from a known sort and puts the account's back
A header press cycles the sort (asc, desc, none), so the case depended on what an earlier run left
in the account: the third run in a row pressed it to none.
2026-10-27 16:34:17 +01:00
peter 6d57d62ac8 fix(nav): a linked player, file and search survive what the app does by itself (review round 2)
- Playlists: a link over a playlist the account does not hold plays alone; the rail's own
  fallback no longer counts as a pick that gives the player up.
- The linked opener opens or clears nothing once the address names another player, and stops
  looking (and fetching its row) once it settled.
- Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a
  notice of its own (EN/HU).
- History: an in-place view rewrite (search refine, count, channel to channel, sub-view)
  drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a
  search keeps the search; the Feed press on a search opens the plain feed.
- Files/Playlists: an emptied search is written at once.
- Client log: plf/cf (column text filters) are typed keys; the shell has none of its own.
- Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is
  sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark
  net reads page tags.
- Feed defaults in one constant; one live-search queue helper; Scheduler writes through the
  Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
2026-10-27 16:08:52 +01:00
peter 0d5c6273f1 fix(nav): the address follows the view through overlays, reloads and Back (review round 1)
- A URL mirror names the view again on every entry Back or Forward lands on, so a change made
  under a dialog, a drawer or a sub-view is not undone by the next reload.
- A view pushed from the entry a reload under a dialog started from no longer inherits its overlay
  mark; a reload under two overlays closes onto the page in one step.
- The linked player reopens over a live search, gives itself up when its list moves first, and
  plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve.
- The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and
  Playlists searches end with their page; the feed always names its view and debounces its search.
- Search-box keys are declared per module and kept out of the client log below TRACE.
- Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and
  two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the
  Files sort and viewer added).
2026-10-27 13:52:42 +01:00
peter 7b4e007680 feat(nav): the live YouTube search and Home's budget are named in the address
A YouTube search is `?yt=<term>` (+ `ytn` for a non-default count), so a reload, a link
or Back/Forward reopens it. It runs by itself only where it costs no quota: the free page
scrape, a search asked for in this document, or results still cached. An API search
reopened any other way shows its term and waits for "Search YouTube again".

Home's time budget is `hb`; the question's draw stays on the history entry.

An e2e spec opens every page's bookmark in a fresh browser context; CLAUDE.md and
docs/development.md name the module URL codec as the place for view state.
2026-10-27 11:51:29 +01:00
peter 3d26ca0efa feat(player): a bookmark reopens every player as it was, once
The Plex player names the playlist it plays through (v=player:<id>:playlist:<pid>), so a bookmark
rebuilds the queue from the playlist's own order while the item is still in it.

Found in the browser and fixed:
- after a reload the shell's player opened a second one over Home and the feed: it read the catalog
  row the page had fetched (a disabled query still reads the shared cache); it now waits for ready.
- every reload under an open overlay left a dead history step: the first overlay to open takes over
  the entry the document was loaded on.
- the feed's address was implicit, the same as the start-page landing, so a reloaded or bookmarked
  feed opened as Home: the feed now names itself (page=feed) like every page.
- a linked file or video that is gone was said, but stayed in the address and was said again on
  every reload: its keys are taken off.

The overlay address is written in one place (the entry is named once it is current), and the player
keys only the codec reads are no longer exported.
2026-10-27 07:33:55 +01:00
peter fa893c73a3 feat(player): the YouTube player and the file viewer are named in the address
The player names what it plays in its own history entry (watch, wrel, wtab): a reload or a
bookmark reopens it on the page that hosts it, with that page's list as the queue, or alone
from the catalog (GET /api/videos/{id}/row); a page that hosts none gets the shell's. Back
lands on the page's address without it (useBackToClose's url; ui/Modal passes it on). The
Files viewer and player do the same (fview, fplay). The Plex and channel-manager views are
always written in full, as their fallback is remembered state.
2026-10-27 05:29:49 +01:00
peter d4f24de4ba feat(nav): tabs, list views and name searches are named in the address
Settings, Configuration and Users name their tab (stab/cfgtab/utab); a link's tab wins and a tab pre-selected
while its page is on screen now switches it. A playlist's sort, filters and grouping (plsort/plf/plgroup), the
Files sort (fsort, always written) and the name searches of Channels, Files, Playlists and Plex (cq/fq/plq/pq+pqf)
reopen from the address; the searches are written once the typing pauses.
2026-10-26 01:13:39 +01:00
peter 91d36b3357 fix(channel): the channel page's address names the channel and its own view, not the page under it
Opening a channel kept every key of the page under it (`?q=music&sort=oldest&chan=…`, a sort the
channel did not show), and the channel's own tab, sort and content types lived in its state alone, so a
reload or a bookmark lost them. Now the channel's address is `channelUrl`: the shell's keys and `chan`,
shared by `openChannel` and the channel link's href. The page writes its view (`chtab`, `chsort`,
`chtypes`, lib/channelPageUrlState) and reopens it; these are shell keys, cleared by a page switch and
by closing it in place. While a channel stands over a page, no module store writes its keys (`urlPage`);
Back finds them on the page's own entry. A link written by an older build opens without the page's keys.
2026-10-26 00:19:02 +01:00
peter 9bd7304bb9 fix(plex): a reload during a binge reopens the episode playing now
The player stepped to the next item in its own state while the history entry still held the item it
opened with, so F5 restarted the first episode. The player now says every item it moves on to, and the
page rewrites its entry with it (useHistorySubview's `follow`: no navigation, the overlays over the
player stay open, and the entry under an overlay is rewritten when a step lands back on it). The
player's URL is the item playing (`?v=player:<id>`), so a bookmark opens it; the list it plays from
stays in the history entry.
2026-10-25 23:24:29 +01:00
peter b30c60d1a5 feat(nav): each module declares its URL keys; a page switch clears them and a page opens without another's
The shell cleared a hand-kept key list on a page switch (it missed the Files and feed keys) and cleared
nothing when a page opened from a link carrying another page's keys. ModuleDef.urlKeys now names every
key a page's view writes, the shell derives both from the registry, and moduleUrl.contract.test holds the
declarations true: one owner per key, no shell key, exactly what each codec writes, and no undeclared
key literal in the app.
2026-10-25 20:48:14 +01:00
145 changed files with 5786 additions and 426 deletions
+14
View File
@@ -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). 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 ## 0.112.1 — 2026-10-27
### Fixed ### Fixed
+1
View File
@@ -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 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 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 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 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 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 | | 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 |
+1 -1
View File
@@ -1 +1 @@
0.112.1 0.113.0
+15
View File
@@ -249,6 +249,21 @@ def set_video_progress(
return {"ok": True, "video_id": video_id, "position_seconds": position} 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}") @router.get("/videos/{video_id}")
def get_video_detail( def get_video_detail(
video_id: str, video_id: str,
+47
View File
@@ -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
View File
@@ -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. 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 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 `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 3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's
hooks). Another folder reaches the module through this file or not at all. hooks). Another folder reaches the module through this file or not at all.
4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in 4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in
@@ -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. 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), 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 down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the
i18n parity test. i18n parity test.
+259
View File
@@ -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();
},
);
});
+2 -1
View File
@@ -898,7 +898,8 @@ export default tseslint.config(
plugins: { playwright }, plugins: { playwright },
rules: { rules: {
"playwright/no-focused-test": "error", "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 }],
}, },
}, },
{ {
+6 -1
View File
@@ -65,6 +65,7 @@ export default function Modal({
placement = "center", placement = "center",
initialFocus, initialFocus,
headerActions, headerActions,
url,
}: { }: {
title: ReactNode; title: ReactNode;
initialFocus: ModalFocus; initialFocus: ModalFocus;
@@ -81,10 +82,14 @@ export default function Modal({
placement?: "center" | "drawer" | "drawer-start" | "top"; placement?: "center" | "drawer" | "drawer-start" | "top";
/** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */ /** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */
headerActions?: ReactNode; 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(); const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away. // 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 // 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 // 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. // still gets the focus. Both rules live in the hook; `ui/AnchoredPopover` shares it.
+3 -2
View File
@@ -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: // 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 // 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 // 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): // lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings) and names it in
// a reload keeps the user where they were, and a fresh tab opens on the first sub-tab. Not every // 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 // 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. // 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", "version": "0.112.1",
"date": "2026-10-27", "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", "version": "0.112.1",
"date": "2026-10-27", "date": "2026-10-27",
@@ -27,6 +27,8 @@ vi.mock("react-i18next", async (importOriginal) => {
import { NavigationProvider } from "./NavigationProvider"; import { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext"; import { useNavigation, useNavigationActions } from "./navigationContext";
import { urlPage } from "./urlPage";
import { channelUrl } from "../../lib/urlState";
import { useBackToClose } from "../../lib/history"; import { useBackToClose } from "../../lib/history";
import { PageHeading } from "../../components/ui/Headings"; 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"); 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", () => { it("pushes nothing when it is already that page", () => {
const { result } = render(); const { result } = render();
act(() => result.current.setPage("plex")); 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", () => { describe("NavigationProvider · Back", () => {
// A tab that had a since-removed module open, reloaded onto the new build: its older history // 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. // 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"); 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. // …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.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", () => { 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"); window.history.replaceState(null, "", "/?page=scheduler&keep=1");
const { result } = render(); const { result } = render();
expect(result.current.page).toBe("feed"); 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" }); expect(window.history.state).toMatchObject({ sfPage: "feed" });
}); });
@@ -361,7 +463,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
qc.setQueryData(qk.me(), member); qc.setQueryData(qk.me(), member);
}); });
await settle(); await settle();
expect(window.location.search).toBe(""); expect(window.location.search).toBe("?page=feed");
expect(window.history.state).toMatchObject({ sfPage: "feed" }); expect(window.history.state).toMatchObject({ sfPage: "feed" });
expect(getAccountRaw(PAGE_KEY)).toBe("feed"); expect(getAccountRaw(PAGE_KEY)).toBe("feed");
act(() => { act(() => {
@@ -381,7 +483,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
}); });
await settle(); await settle();
expect(result.current.channelView).toBeNull(); expect(result.current.channelView).toBeNull();
expect(window.location.search).toBe(""); expect(window.location.search).toBe("?page=feed");
expect(window.history.state?._chan).toBeUndefined(); 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 { useLatest } from "../../lib/useLatest";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState"; import {
import { ACTIVITY_KEYS } from "../../lib/activityUrlState"; channelUrl,
resolvePage,
retiredParams,
urlWithParams,
type Page,
} from "../../lib/urlState";
import { useDocumentObjectTitle } from "../../lib/documentTitle"; import { useDocumentObjectTitle } from "../../lib/documentTitle";
import { getAccountRaw, setAccountRaw } from "../../lib/storage"; import { getAccountRaw, setAccountRaw } from "../../lib/storage";
import { import {
@@ -12,8 +17,17 @@ import {
nextNavId, nextNavId,
notifyNav, notifyNav,
rememberNavPosition, rememberNavPosition,
replaceView,
} from "../../lib/history"; } 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 { import {
channelViewFrom, channelViewFrom,
loadInitialPage, loadInitialPage,
@@ -37,24 +51,35 @@ import {
// //
// The contexts, their value types and the hooks live in navigationContext.ts. // 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> { function pageRootParams(page: Page): Record<string, string | null> {
return { return {
page: page === "feed" ? null : page, page,
chan: null, ...closedChannelParams(),
v: null, ...nulls(PLAYER_KEYS),
pl: null, ...nulls(moduleUrlKeys()),
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
}; };
} }
/** 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 }) { export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only // 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. // and the address bar (stamped from it below) agree.
const me = useMe(); const me = useMe();
const page = reachablePage(chosenPage, me); const page = reachablePage(chosenPage, me);
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search // The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState)
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides // so a reload or a link opens it again; its own history entry, so Back steps player → search → feed.
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so // Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was
// Back steps player → search → feed instead of the search vanishing without a history entry. // asked to search for (sent from the search box, or "Search YouTube" pressed), on the entries
const [ytSearch, setYtSearch] = useState<string | null>(() => { // it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or
const st = window.history.state; // a Forward onto a search — runs only when it costs no quota (Feed decides).
return st?._yt && st?._ytScrape ? (st._yt as string) : null; 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 // The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores // detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
// it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app // it, and a bookmarked ?chan link opens it. Reload of an in-app entry reads history.state._chan; a
// entry reads history.state._chan; a fresh bookmark has only the ?chan param. // fresh bookmark has only the ?chan param.
const [channelView, setChannelView] = useState<ChannelView | null>(() => const [channelView, setChannelView] = useState<ChannelView | null>(() =>
channelViewFrom(window.history.state, window.location.search), channelViewFrom(window.history.state, window.location.search),
); );
@@ -107,31 +144,36 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
closeOverlaysThenNavigate(() => { closeOverlaysThenNavigate(() => {
// A channel page overlays the content column, so a nav click must close it even when the // 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). // 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?.(); then?.();
return; return;
} }
setChannelView(null); // leave any open channel page when navigating via the rail setChannelView(null); // leave any open channel page when navigating via the rail
// A live search under that channel ends too: the page opens at its root, which the address
// below names — the Feed press over a channel opened from a search included.
endYtSearch();
setPageState(next); setPageState(next);
setAccountRaw(PAGE_KEY, next); setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its // 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 // root, and the address bar reflects the page (the feed too) while clearing `chan` and
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page // every module's own keys (pageRootParams) — a page switch resets to that module's root, and
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left // each module's store mirrors its state back in on its page. The item it was opened from is
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to // still focused: the state setters above have not re-rendered anything yet.
// their page. The item it was opened from is still focused: the state setters above have not // The press on a search over its own page ends only the search: the page's view is the one
// re-rendered anything yet. // on screen and stays named — its store, unchanged, writes nothing back in.
rememberNavPosition(); rememberNavPosition();
window.history.pushState( window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() }, { sfPage: next, sfScroll: nextNavId() },
"", "",
urlWithParams(pageRootParams(next)), urlWithParams(samePage ? searchEndParams() : pageRootParams(next)),
); );
notifyNav(); notifyNav();
then?.(); 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 // 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. // inside one.
const openChannel = useCallback((id: string, name?: string) => { const openChannel = useCallback((id: string, name?: string) => {
closeOverlaysThenNavigate(() => { 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) { if (window.history.state?._chan) {
const st = window.history.state; const st = window.history.state;
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url); replaceView({ ...st, _chan: id, _chanName: name ?? null }, url);
} else { } else {
const st = rememberNavPosition(); const st = rememberNavPosition();
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url); 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 // No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the // below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
// entry's channel marks and the ?chan param. // entry's channel marks and the ?chan param.
// A live search under it (the channel opened from its results) is on screen again, and named
// again: the channel's address carried none of it.
setChannelView(null); setChannelView(null);
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {}; const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
window.history.replaceState(st, "", urlWithParams({ chan: null })); replaceView(
st,
urlWithParams({
...closedChannelParams(),
...ytSearchParams(ytSearchRef.current, ytCountRef.current),
}),
);
} }
}, []); }, [ytSearchRef, ytCountRef]);
const enterYtSearch = useCallback((q: string) => { const enterYtSearch = useCallback((q: string) => {
setYtSearch(q); setYtSearch(q);
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it setYtAsked(q);
// once the results resolve, so a reload only restores a confirmed scrape-sourced search. const st = window.history.state ?? {};
const { _ytScrape: _drop, ...st } = window.history.state || {}; const here = new URLSearchParams(window.location.search);
if (st._yt) if (st._yt || ytSearchFrom(here) != null) {
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search // Refine the search on screen, in place, at the count it shows (one a link opened too: it
else { // is the first entry of this document, so its new `_yt` mark has nothing of ours below it).
// New sub-view entry. replaceView({ ...st, _yt: q }, urlWithParams(ytSearchParams(q, ytCountFrom(here))));
const { _ytScrape: _dropToo, ...left } = rememberNavPosition(); } else {
window.history.pushState({ ...left, sfPage: "feed", _yt: q }, ""); // 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(() => { const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own // 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). // one — a search a link or a reload opened is marked (`_yt`) but was pushed by nobody: below it is
if (window.history.state?._yt && backStaysInDocument()) window.history.back(); // 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 { else {
setYtSearch(null); endYtSearch();
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {}; const { _yt: _q, _ytOwn: _own, ...st } = window.history.state ?? {};
window.history.replaceState(st, ""); 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 // Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
// DURING the render that changes the page, against the page we were last on: as an effect it ran // DURING the render that changes the page, against the page we were last on: as an effect it ran
@@ -196,7 +271,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const [pageSearchWasOn, setPageSearchWasOn] = useState(page); const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
if (pageSearchWasOn !== page) { if (pageSearchWasOn !== page) {
setPageSearchWasOn(page); setPageSearchWasOn(page);
if (page !== "feed") setYtSearch(null); if (page !== "feed") endYtSearch();
} }
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"): // The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
@@ -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 // 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. // the popstate handler only needs to read history.state.
useEffect(() => { useEffect(() => {
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape // The entry of a search the address names carries its `_yt` mark, whichever way it opened (sent
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName // from the box, a reload, a link): a channel opened from its results reads the search under it
// are always kept so a channel page survives F5. // 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 || {}; const st = window.history.state || {};
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the // Mirror the resolved initial page to the address bar (the feed too) so a page reached from the
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any // stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams keeps the
// ?chan / ?v / ?pl / filter params the loaders already read. // shell's keys (?chan, a player's) and the page's own; other pages' go (below).
// A retired page's link — or the remembered last page, when that is what opened — also opens its // A retired page's link — or the remembered last page, when that is what opened — also opens its
// part of the page that replaced it (an `at` tab). // part of the page that replaced it (an `at` tab).
const linked = new URLSearchParams(window.location.search).get("page"); const linked = new URLSearchParams(window.location.search).get("page");
const remembered = getAccountRaw(PAGE_KEY); 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({ const url = urlWithParams({
...nulls(shown ? moduleUrlKeys() : foreignUrlKeys(mountPage)),
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)), ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
page: mountPage === "feed" ? null : mountPage, page: mountPage,
}); });
if (st._yt && st._ytScrape) { const named =
window.history.replaceState({ ...st, sfPage: mountPage }, "", url); mountPage === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null;
if (named != null) {
window.history.replaceState({ ...st, _yt: named, sfPage: mountPage }, "", url);
} else { } else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; const { _yt: _staleYt, _ytScrape: _staleScrape, _ytOwn: _staleOwn, ...rest } = st;
window.history.replaceState({ ...rest, sfPage: mountPage }, "", url); window.history.replaceState({ ...rest, sfPage: mountPage }, "", url);
} }
function onPop(e: PopStateEvent) { function onPop(e: PopStateEvent) {
@@ -267,13 +350,22 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
window.history.replaceState( window.history.replaceState(
{ ...window.history.state, sfPage: p }, { ...window.history.state, sfPage: p },
"", "",
urlWithParams({ ...retiredParams(sf), page: p === "feed" ? null : p }), urlWithParams({ ...retiredParams(sf), page: p }),
); );
setPageState(p); setPageState(p);
setAccountRaw(PAGE_KEY, p); setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward // The YouTube search is read from the address the browser restored, so Back/Forward opens or
// restores or clears them to match the entry we landed on. // clears it to match the entry we landed on. One landed on without it has ended: a Forward back
setYtSearch((e.state?._yt as string) ?? null); // 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 // The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a // entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
// popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom // popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
@@ -285,12 +377,26 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, [mountPage]); }, [mountPage]);
const state = useMemo<NavigationState>( 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>( 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 ( 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>; 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). // Look up a module's definition by page id (every Page is covered, incl. settings).
export function moduleDef(id: Page): ModuleDef { export function moduleDef(id: Page): ModuleDef {
return BY_ID[id]; return BY_ID[id];
@@ -1,5 +1,6 @@
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { Page } from "../../lib/urlState"; import type { Page } from "../../lib/urlState";
import { DEFAULT_YT_COUNT } from "../../lib/ytSearchUrlState";
import type { ChannelView } from "./navigationInit"; import type { ChannelView } from "./navigationInit";
// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only // The navigation contexts + their hooks, split out of NavigationProvider so that file exports only
@@ -15,8 +16,13 @@ export interface NavigationState {
page: Page; page: Page;
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */ /** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
channelView: ChannelView | null; 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; 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 { export interface NavigationActions {
@@ -33,12 +39,17 @@ export interface NavigationActions {
closeChannel: () => void; closeChannel: () => void;
enterYtSearch: (q: string) => void; enterYtSearch: (q: string) => void;
exitYtSearch: () => void; exitYtSearch: () => void;
/** Run the search on screen: the "Search YouTube" press. */
askYtSearch: () => void;
setYtCount: (n: number) => void;
} }
export const NavigationStateContext = createContext<NavigationState>({ export const NavigationStateContext = createContext<NavigationState>({
page: "feed", page: "feed",
channelView: null, channelView: null,
ytSearch: null, ytSearch: null,
ytCount: DEFAULT_YT_COUNT,
ytSearchAsked: false,
}); });
export const NavigationActionsContext = createContext<NavigationActions>({ export const NavigationActionsContext = createContext<NavigationActions>({
setPage: () => {}, setPage: () => {},
@@ -46,6 +57,8 @@ export const NavigationActionsContext = createContext<NavigationActions>({
closeChannel: () => {}, closeChannel: () => {},
enterYtSearch: () => {}, enterYtSearch: () => {},
exitYtSearch: () => {}, exitYtSearch: () => {},
askYtSearch: () => {},
setYtCount: () => {},
}); });
export function useNavigation(): NavigationState { 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(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks(); vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
started.length = 0; started.length = 0;
loaded.length = 0; loaded.length = 0;
seeks.length = 0; seeks.length = 0;
+32 -3
View File
@@ -68,6 +68,7 @@ import {
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen"; import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap"; import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
import { useBackToClose } from "../../lib/history"; import { useBackToClose } from "../../lib/history";
import { linkedPlayer, playerParamUpdates, type PlayerPanelTab } from "../../lib/playerUrlState";
import ChannelNameLink from "../video/ChannelNameLink"; import ChannelNameLink from "../video/ChannelNameLink";
import Toaster from "../../components/ui/Toaster"; import Toaster from "../../components/ui/Toaster";
import { useOverlayOrder } from "../../components/ui/overlayOrder"; import { useOverlayOrder } from "../../components/ui/overlayOrder";
@@ -222,8 +223,6 @@ export default function PlayerModal({
const { openChannel } = useNavigationActions(); const { openChannel } = useNavigationActions();
const qc = useQueryClient(); const qc = useQueryClient();
const { ref: descFadeRef, style: descFadeStyle } = useScrollFade(); 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 // 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 // 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 // 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); setRelatedOf(active.id);
setRelated(null); 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 subject = related?.video ?? active;
const subjectResume = related const subjectResume = related
? (related.startAt ?? related.video.position_seconds ?? 0) ? (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 // 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 // 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). // 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); 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 // 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. // tab instead. Left open, it reopened unasked when the window narrowed again.
@@ -762,6 +780,17 @@ export default function PlayerModal({
setPanelTab("related"); setPanelTab("related");
} }
const panelTabs = useTabPanel(); 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 // Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
// be refused. // be refused.
const relatedOn = useMe().related_enabled; 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", () => { it("is an anchor whose href opens this channel on the current page", () => {
const { link } = renderLink(); const { link } = renderLink();
expect(link.tagName).toBe("A"); expect(link.tagName).toBe("A");
// The same URL openChannel pushes: the current page's query plus ?chan=<id>. // The same URL openChannel pushes: the current page plus ?chan=<id> — not the page's own keys,
expect(link.getAttribute("href")).toBe(`/?page=feed&show=unwatched&chan=${CH}`); // 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", () => { 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(); expect(onOpenInApp).not.toHaveBeenCalled();
}); });
it("does not lose the rest of the address bar", () => { // The address names only what the screen shows: the channel over the page, not the page's filters
window.history.replaceState({}, "", "/?page=channels&q=abc"); // (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(); const { link } = renderLink();
expect(link.getAttribute("href")).toContain("page=channels"); expect(link.getAttribute("href")).toBe(`/?page=channels&chan=${CH}&m=signup#x=1`);
expect(link.getAttribute("href")).toContain("q=abc");
}); });
it("gives a channel with no title yet an accessible name", () => { it("gives a channel with no title yet an accessible name", () => {
@@ -1,15 +1,15 @@
import { forwardRef, type AnchorHTMLAttributes } from "react"; import { forwardRef, type AnchorHTMLAttributes } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isPlainLeftClick } from "../../lib/nav"; 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 // 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 // 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 // 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. // 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 // The href is the same URL `openChannel` pushes (`channelUrl`: the channel over the current page,
// lands where the in-app navigation would have. A plain left click is still handled in-app, because // 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 // 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). // Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & { type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
@@ -33,7 +33,7 @@ const ChannelNameLink = forwardRef<HTMLAnchorElement, Props>(function ChannelNam
<a <a
{...rest} {...rest}
ref={ref} ref={ref}
href={urlWithParams({ chan: channelId })} href={channelUrl(channelId)}
title={tooltip} title={tooltip}
onClick={(e) => { onClick={(e) => {
if (stopPropagation) e.stopPropagation(); if (stopPropagation) e.stopPropagation();
+4 -1
View File
@@ -75,6 +75,9 @@
"clearNow": "Clear now", "clearNow": "Clear now",
"cleared_one": "Cleared {{count}} result", "cleared_one": "Cleared {{count}} result",
"cleared_other": "Cleared {{count}} results", "cleared_other": "Cleared {{count}} results",
"showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size." "showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.",
"notRunTitle": "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"
} }
} }
+2
View File
@@ -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", "navLabel": "Files",
"searchPlaceholder": "Search files…", "searchPlaceholder": "Search files…",
"count_one": "{{count}} item", "count_one": "{{count}} item",
+1
View File
@@ -1,5 +1,6 @@
{ {
"loading": "Loading…", "loading": "Loading…",
"linkedFailed": "The video in the address could not be opened",
"backToOriginal": "Back to the original video", "backToOriginal": "Back to the original video",
"back": "Back", "back": "Back",
"previous": "Previous", "previous": "Previous",
+4 -1
View File
@@ -75,6 +75,9 @@
"clearNow": "Törlés most", "clearNow": "Törlés most",
"cleared_one": "{{count}} találat törölve", "cleared_one": "{{count}} találat törölve",
"cleared_other": "{{count}} találat törölve", "cleared_other": "{{count}} találat törölve",
"showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább." "showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább.",
"notRunTitle": "Ez a keresés 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"
} }
} }
+2
View File
@@ -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", "navLabel": "Fájlok",
"searchPlaceholder": "Keresés a fájlok között…", "searchPlaceholder": "Keresés a fájlok között…",
"count_one": "{{count}} elem", "count_one": "{{count}} elem",
+1
View File
@@ -1,5 +1,6 @@
{ {
"loading": "Betöltés…", "loading": "Betöltés…",
"linkedFailed": "A linkben szereplő videó nem nyitható meg",
"backToOriginal": "Vissza az eredeti videóhoz", "backToOriginal": "Vissza az eredeti videóhoz",
"back": "Vissza", "back": "Vissza",
"previous": "Előző", "previous": "Előző",
+8 -1
View File
@@ -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 // 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 — // 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 // 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 // WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull
// it back into the main bundle and defeat the code-split. // it back into the main bundle and defeat the code-split.
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
// The address-bar key the page mirrors its tab to.
export const USERS_TAB_URL_KEY = "utab";
/** The address-bar delta for the Users tab shown. */
export function usersTabParams(tab: string | null): Record<string, string | null> {
return { [USERS_TAB_URL_KEY]: tab };
}
const ADMIN_USERS_ACCESS_TAB = "access"; const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void { export function focusAccessRequestsTab(): void {
+1 -1
View File
@@ -20,7 +20,7 @@ import { setupApi } from "./api/setup";
export type * from "./api/setup"; export type * from "./api/setup";
import { feedApi } from "./api/feed"; import { feedApi } from "./api/feed";
export type * 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"; import { discoverApi } from "./api/discover";
export type * from "./api/discover"; export type * from "./api/discover";
export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover"; export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover";
+8
View File
@@ -81,6 +81,11 @@ export interface FeedResponse {
// or optional (dead surface). Re-add if a consumer ever needs it. // 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 { export interface FeedFilters {
tags: number[]; tags: number[];
/** Per-category tag match (language / topic / other each independently any/all). */ /** Per-category tag match (language / topic / other each independently any/all). */
@@ -314,6 +319,9 @@ export const feedApi = {
duration_seconds: Math.floor(durationSeconds), duration_seconds: Math.floor(durationSeconds),
}), }),
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`), 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. // 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), takeoutUpload: (file: File): Promise<TakeoutUpload> => uploadReq("/api/takeout", file),
takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"), 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");
});
});
+57
View File
@@ -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,
};
}
+35 -12
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// //
// The Channels-manager content state (active tab + column sort + the column-filter map) round-trips // The Channels-manager content state (active tab + column sort + the column-filter map + the name
// through namespaced URL keys (ctab/csort/cf) so a manager view is shareable, the plexUrlState // 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. // analogue. `cf` carries the mixed scalar/array FilterMap as a JSON blob.
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
@@ -20,7 +20,7 @@ const RICH_FILTERS: FilterMap = {
tags: ["12", "34"], 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). // Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
function applyUpdates(updates: Record<string, string | null>): URLSearchParams { function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
@@ -32,29 +32,49 @@ function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
} }
describe("channelsParamUpdates", () => { describe("channelsParamUpdates", () => {
it("emits nothing for the all-default state", () => { it("writes no search for a blank one", () => {
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null }); 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)", () => { 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({ const u = channelsParamUpdates({
view: "hidden", view: "hidden",
sort: { key: "subscribers", dir: "desc" }, sort: { key: "subscribers", dir: "desc" },
filters: { sync: "fully" }, filters: { sync: "fully" },
search: "tom",
}); });
expect(u.ctab).toBe("hidden"); expect(u.ctab).toBe("hidden");
expect(u.csort).toBe("subscribers:desc"); expect(u.csort).toBe("subscribers:desc");
expect(u.cf).toBe(JSON.stringify({ sync: "fully" })); 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", () => { it("drops empty filter entries so a cleared funnel writes an empty cf", () => {
expect( expect(channelsParamUpdates({ ...BASE, filters: { tags: [], sync: "" } }).cf).toBe("");
channelsParamUpdates({ view: "subscribed", sort: null, filters: { tags: [], sync: "" } }).cf,
).toBeNull();
}); });
}); });
@@ -64,12 +84,14 @@ describe("round-trip", () => {
view: "blocked", view: "blocked",
sort: { key: "priority", dir: "asc" }, sort: { key: "priority", dir: "asc" },
filters: RICH_FILTERS, filters: RICH_FILTERS,
search: "jazz",
}; };
const params = applyUpdates(channelsParamUpdates(state)); const params = applyUpdates(channelsParamUpdates(state));
const back = channelsParamsToState(params, BASE); const back = channelsParamsToState(params, BASE);
expect(back.view).toBe("blocked"); expect(back.view).toBe("blocked");
expect(back.sort).toEqual({ key: "priority", dir: "asc" }); expect(back.sort).toEqual({ key: "priority", dir: "asc" });
expect(back.filters).toEqual(RICH_FILTERS); expect(back.filters).toEqual(RICH_FILTERS);
expect(back.search).toBe("jazz");
// the array shape survives the JSON round-trip, not collapsed to a scalar // the array shape survives the JSON round-trip, not collapsed to a scalar
expect(Array.isArray(back.filters.tags)).toBe(true); expect(Array.isArray(back.filters.tags)).toBe(true);
}); });
@@ -80,6 +102,7 @@ describe("round-trip", () => {
view: "discovery", view: "discovery",
sort: null, sort: null,
filters: { sync: "needsFull" }, filters: { sync: "needsFull" },
search: "",
}); });
expect(back.view).toBe("discovery"); // from base, no ctab expect(back.view).toBe("discovery"); // from base, no ctab
expect(back.filters).toEqual({ sync: "needsFull" }); // from base, no cf 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", () => { 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 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.view).toBe("subscribed"); // bogus tab → base
expect(back.sort).toBeNull(); // bad dir → parseSortState null → base (null) expect(back.sort).toBeNull(); // bad dir → parseSortState null → base (null)
expect(back.filters).toEqual({}); // corrupt cf → decodeFilters heals to empty expect(back.filters).toEqual({}); // corrupt cf → decodeFilters heals to empty
+21 -44
View File
@@ -2,11 +2,13 @@
// from the address bar so a manager view is bookmarkable/shareable — the analogue of the feed's // 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 // 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 // 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 // feed's URL-stripping can't delete them out from under the manager. The tab, sort and filters are
// emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex // always written, their defaults too: a page opened without them takes this browser's remembered view,
// rail search) and stays OUT of the shared contract. // which may not be the one bookmarked. The name search (`cq`) is part of the view like the rest (a
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort"; // 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 { 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 // 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 // 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; view: string;
sort: SortState; sort: SortState;
filters: FilterMap; 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"]; const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default // Sort ⇄ `key:dir` (e.g. `subscribers:desc`, `none` unsorted) via the shared codec in columnSort. The
// sort here (fallback null), so any sort emits — no default short-circuit needed. // filter map rides as one JSON value (lib/filterMapParam), an empty one as `cf=`.
// 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 {};
}
}
/** The address-bar delta for the manager content state: each namespaced key mapped to its emitted /** 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 * 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(). */ * re-mirrors on return). Feed the result into urlWithParams(). */
export function channelsParamUpdates( export function channelsParamUpdates(
state: ChannelsUrlState | null, state: ChannelsUrlState | null,
): Record<string, string | 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 { return {
ctab: state.view && state.view !== "subscribed" ? state.view : null, ctab: state.view || "subscribed",
csort: encodeSortParam(state.sort), csort: encodeSortParamOrNone(state.sort),
cf: encodeFilters(state.filters), cf: encodeFilterMap(state.filters) ?? "",
cq: state.search.trim() || null,
}; };
} }
@@ -79,10 +55,11 @@ export function channelsParamsToState(
base: ChannelsUrlState, base: ChannelsUrlState,
): ChannelsUrlState { ): ChannelsUrlState {
const tab = params.get("ctab"); const tab = params.get("ctab");
const csort = params.get("csort"); const linkedSort = decodeSortParamOrNone(params.get("csort"));
return { return {
view: tab && VIEWS.includes(tab) ? tab : base.view, view: tab && VIEWS.includes(tab) ? tab : base.view,
sort: csort != null ? (decodeSortParam(csort) ?? base.sort) : base.sort, sort: linkedSort === undefined ? base.sort : linkedSort,
filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters, filters: params.has("cf") ? decodeFilterMap(params.get("cf") ?? "") : base.filters,
search: params.get("cq") ?? base.search,
}; };
} }
+16
View File
@@ -53,6 +53,22 @@ export function decodeSortParam(raw: string): SortState {
return parseSortState({ key, dir }); 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. */ /** The header-click cycle: unsorted → ascending → descending → unsorted. */
export function cycleSort(current: SortState, key: string): SortState { export function cycleSort(current: SortState, key: string): SortState {
if (!current || current.key !== key) return { key, dir: "asc" }; if (!current || current.key !== key) return { key, dir: "asc" };
+8
View File
@@ -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 };
}
+20
View File
@@ -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();
});
});
+56
View File
@@ -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;
}
+33
View File
@@ -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 {};
}
}
+1 -1
View File
@@ -78,7 +78,7 @@
{ "allowShortCircuit": false, "allowTaggedTemplates": false, "allowTernary": false } { "allowShortCircuit": false, "allowTaggedTemplates": false, "allowTernary": false }
], ],
"playwright/no-focused-test": [2], "playwright/no-focused-test": [2],
"playwright/no-skipped-test": [2] "playwright/no-skipped-test": [2, { "allowConditional": false, "disallowFixme": true }]
}, },
"settings": null "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");
});
});
+138 -2
View File
@@ -51,7 +51,7 @@ describe("useHistorySubview seed", () => {
}); });
it("prefers history.state._sub over the URL param (in-app entry)", () => { 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)); const { result } = renderHook(() => useHistorySubview("root", url));
expect(result.current.view).toBe("b"); 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)", () => { 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")); const { result } = renderHook(() => useHistorySubview("root"));
expect(result.current.view).toBe("fromState"); expect(result.current.view).toBe("fromState");
// A URL param with no url config must NOT leak in. // 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", () => { describe("useHistorySubview popstate", () => {
it("restores the sub-view from state._sub on Back/Forward", () => { it("restores the sub-view from state._sub on Back/Forward", () => {
const { result } = renderHook(() => useHistorySubview("root", url)); 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 // 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 // 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 // sub-view. back() then left the document for an older one (measured 2026-10-07: its restored Plex
+189 -33
View File
@@ -6,10 +6,11 @@
// a module (e.g. the sign-in card's "forgot password" → sign-in) returns to the parent view first. // 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 // - 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 // 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. // 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 { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
import { urlWithParams } from "./urlState"; import { urlWithParams } from "./urlState";
import { useLatest } from "./useLatest"; 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 /** Call right before a pushState that opens a new view over the current entry (a page, a sub-view, a
* channel, a tab). Returns the current state WITHOUT the stamp: a push that spreads the current state * channel, a tab). Returns the current state WITHOUT the stamp: a push that spreads the current state
* must spread this, so the new entry does not inherit a position it was never left from. */ * must spread this, so the new entry does not inherit a position it was never left from — nor an
* overlay's marks, which belong to the entry being left (an entry a reload under an open dialog
* started from carries them, and the first overlay over a view pushed from it took that view's entry
* over for its own). */
export function rememberNavPosition(): Record<string, unknown> { export function rememberNavPosition(): Record<string, unknown> {
// A restore still waiting for the last landing's lists belongs to the page being left: left // A restore still waiting for the last landing's lists belongs to the page being left: left
// running, it would focus "list N, item M" of whatever page this push opens. // running, it would focus "list N, item M" of whatever page this push opens.
@@ -94,7 +98,8 @@ export function rememberNavPosition(): Record<string, unknown> {
const { sfNav: _stale, ...st } = (window.history.state ?? {}) as Record<string, unknown>; const { sfNav: _stale, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
const pos = navPosition(); const pos = navPosition();
window.history.replaceState(pos ? { ...st, sfNav: pos } : st, ""); window.history.replaceState(pos ? { ...st, sfNav: pos } : st, "");
return st; const { _ov: _overlay, _ovd: _depth, ...view } = st;
return view;
} }
// --- Is the entry a back() lands on still ours? -------------------------------------------- // --- Is the entry a back() lands on still ours? --------------------------------------------
@@ -110,12 +115,12 @@ interface NavigationLike {
entries: () => readonly { sameDocument: boolean }[]; entries: () => readonly { sameDocument: boolean }[];
} }
/** Would one more history.back() still land in this document? */ /** Would `steps` more steps back (one, a history.back()) still land in this document? */
export function backStaysInDocument(): boolean { export function backStaysInDocument(steps = 1): boolean {
const nav = (window as { navigation?: NavigationLike }).navigation; const nav = (window as { navigation?: NavigationLike }).navigation;
const at = nav?.currentEntry?.index; const at = nav?.currentEntry?.index;
if (nav == null || at == null) return true; if (nav == null || at == null) return true;
return nav.entries()[at - pendingProgrammatic - 1]?.sameDocument === true; return nav.entries()[at - pendingSteps - steps]?.sameDocument === true;
} }
// --- Sub-views (value carried in history.state._sub) --------------------------------------- // --- Sub-views (value carried in history.state._sub) ---------------------------------------
@@ -134,6 +139,9 @@ export interface SubviewUrl<T> {
entry?: (view: T) => 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>( export function useHistorySubview<T>(
root: T, root: T,
url?: SubviewUrl<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 * continuation of the navigation that just arrived here, not a step the user took inside the
* module. See the `jumpTo` hand-off in PlexBrowse. */ * module. See the `jumpTo` hand-off in PlexBrowse. */
open: (next: T, opts?: { replace?: boolean }) => void; 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; back: () => void;
} { } {
// Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the // 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 // ⚠️ 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 // 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. // 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 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 (fromState != null) return fromState;
if (url) { if (decoded != null) return decoded;
const raw = new URLSearchParams(window.location.search).get(url.key);
const decoded = raw != null ? url.decode(raw) : null;
if (decoded != null) return decoded;
}
return root; return root;
}; };
const [view, setView] = useState<T>(readView); 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(() => { useEffect(() => {
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL). // Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
const onPop = () => setView(readLandedView()); const onPop = () => setView(readLandedView());
@@ -182,20 +210,19 @@ export function useHistorySubview<T>(
const go = useCallback( const go = useCallback(
(next: T, opts?: { replace?: boolean }) => { (next: T, opts?: { replace?: boolean }) => {
setView(next); setView(next);
const kept = url?.entry ? url.entry(next) : next; // A navigation says where the view is now, on the entry it lands on or rewrites.
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; followedRef.current = null;
if (opts?.replace) { if (opts?.replace) {
// Same ENTRY, so it keeps its sfScroll id: PageScroller keys offsets per (entry, view), and writeSubEntry(next, url);
// 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);
notifyNav(); notifyNav();
return; 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 // 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. // restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
window.history.pushState( window.history.pushState(
{ ...rememberNavPosition(), _sub: kept, sfScroll: nextNavId() }, { ...rememberNavPosition(), _sub: kept, _subOf: subOwner(url), sfScroll: nextNavId() },
"", "",
href, href,
); );
@@ -218,9 +245,26 @@ export function useHistorySubview<T>(
}), }),
[go, rootRef], [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" // 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. // 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) ------------------------------- // --- 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 // 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 // last two measured in Chrome). With nothing ever buried, the stack and the history agree by
// construction. // 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[] = []; 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; let overlayCounter = 0;
// A plain function, not `++overlayCounter` inline: the React Compiler cannot lower an update of a // 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). // module variable inside a hook, and skipped the whole hook for it (react-hooks/todo).
const nextOverlayToken = () => ++overlayCounter; const nextOverlayToken = () => ++overlayCounter;
// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length. // Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
let historyDepth = 0; 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 pendingProgrammatic = 0;
let pendingSteps = 0;
const pendingStepCounts: number[] = [];
function stepBack(steps: number): void {
pendingProgrammatic++;
pendingSteps += steps;
pendingStepCounts.push(steps);
if (steps === 1) window.history.back();
else window.history.go(-steps);
}
function landedStepBack(): void {
pendingProgrammatic--;
pendingSteps -= pendingStepCounts.shift() ?? 1;
}
// The overlay at the bottom of the stack closing: back over its entry and any dead ones under it.
function stepsToClose(): number {
const steps = historyDepth === 0 ? 1 + deadBelow : 1;
if (historyDepth === 0) deadBelow = 0;
return steps;
}
let reconcileScheduled = false; let reconcileScheduled = false;
let listenerInstalled = false; let listenerInstalled = false;
// Re-checked after every reconcile — see `afterClosing`. // Re-checked after every reconcile — see `afterClosing`.
@@ -270,22 +367,59 @@ function reconcileHistory(): void {
const want = overlayStack.length; const want = overlayStack.length;
while (historyDepth < want && pendingProgrammatic === 0) { while (historyDepth < want && pendingProgrammatic === 0) {
historyDepth++; 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) { while (historyDepth > want) {
historyDepth--; historyDepth--;
if (backStaysInDocument()) { const steps = stepsToClose();
pendingProgrammatic++; if (backStaysInDocument(steps)) stepBack(steps);
window.history.back(); else if (steps > 1 && backStaysInDocument()) stepBack(1);
} else { else {
// Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked. // Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked.
const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record<string, unknown>; const {
_ov: _drop,
_ovd: _depth,
...st
} = (window.history.state ?? {}) as Record<string, unknown>;
window.history.replaceState(st, ""); window.history.replaceState(st, "");
} }
} }
nameOverlaysHere();
syncWaiters.forEach((cb) => cb()); 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 { function scheduleReconcile(): void {
if (reconcileScheduled) return; if (reconcileScheduled) return;
reconcileScheduled = true; reconcileScheduled = true;
@@ -296,15 +430,21 @@ function ensureOverlayListener(): void {
if (listenerInstalled) return; if (listenerInstalled) return;
listenerInstalled = true; listenerInstalled = true;
window.addEventListener("popstate", () => { window.addEventListener("popstate", () => {
reloadedOverlayEntry = null;
if (pendingProgrammatic > 0) { if (pendingProgrammatic > 0) {
pendingProgrammatic--; // our own back(): consume and ignore landedStepBack(); // our own back(): consume and ignore
// The last one landed: an overlay that opened while it was in flight still owes its entry. // The last one landed: an overlay that opened while it was in flight still owes its entry.
if (pendingProgrammatic === 0 && historyDepth < overlayStack.length) scheduleReconcile(); if (pendingProgrammatic === 0 && historyDepth < overlayStack.length) scheduleReconcile();
else nameOverlaysHere();
return; return;
} }
// Genuine user Back: the browser already dropped one overlay entry, so close the topmost // Genuine user Back: the browser already dropped one overlay entry, so close the topmost
// overlay to match. Its unmount cleanup finds itself already off the stack and only re-syncs. // overlay to match. Its unmount cleanup finds itself already off the stack and only re-syncs.
// The last one closing over dead entries (see `deadBelow`) steps over them too.
if (historyDepth > 0) historyDepth--; if (historyDepth > 0) historyDepth--;
const dead = historyDepth === 0 ? deadBelow : 0;
if (historyDepth === 0) deadBelow = 0;
if (dead > 0 && backStaysInDocument(dead)) stepBack(dead);
const top = overlayStack.pop(); const top = overlayStack.pop();
if (top) top.cb(); if (top) top.cb();
scheduleReconcile(); scheduleReconcile();
@@ -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 /** 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. */ * `onClose` instead of navigating away. Mount the component only while the overlay is open.
export function useBackToClose(onClose: () => void): void { * `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 cb = useEffectEvent(onClose);
const named = useEffectEvent(() => url);
const entryRef = useRef<Overlay | null>(null);
useEffect(() => { useEffect(() => {
ensureOverlayListener(); ensureOverlayListener();
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() }; const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
entryRef.current = entry;
overlayStack.push(entry); overlayStack.push(entry);
scheduleReconcile(); scheduleReconcile();
return () => { return () => {
entryRef.current = null;
const idx = overlayStack.indexOf(entry); const idx = overlayStack.indexOf(entry);
// idx === -1 means the shared handler already removed us on a real user Back; either way, // 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. // 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(); 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 /** A Back-closable layer that exists only WHILE `active`. Stacks on top of `useBackToClose` and any
+20
View File
@@ -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 };
}
+16 -2
View File
@@ -10,9 +10,12 @@ import { installClientLog } from "./logHooks";
const appCopy = (text: string | null) => const appCopy = (text: string | null) =>
["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null; ["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null;
// The pages' typed keys, as main.tsx hands them over from the module definitions.
const TYPED = ["q", "fq", "yt"];
beforeAll(() => { beforeAll(() => {
installClientLog(appCopy); installClientLog(appCopy, TYPED);
installClientLog(appCopy); // a hot reload's second call must not wrap History twice installClientLog(appCopy, TYPED); // a hot reload's second call must not wrap History twice
expect(events().map((e) => e.event)).toEqual(["app.start", "nav.view"]); expect(events().map((e) => e.event)).toEqual(["app.start", "nav.view"]);
clear(); clear();
}); });
@@ -56,6 +59,17 @@ describe("client log hooks", () => {
}); });
}); });
it("keeps a page's own search box out below TRACE too", () => {
setLevel("DEBUG");
window.history.pushState({ sfPage: "files" }, "", "/?folder=all&fq=private+words&yt=lofi");
expect(views()[0]!.attrs!.values).toEqual({ folder: "all" });
expect(JSON.stringify(views())).not.toContain("private");
setLevel("TRACE");
window.history.pushState({ sfPage: "files" }, "", "/?folder=all&fq=private+words");
expect(views()[1]!.attrs!.values).toEqual({ folder: "all", fq: "private words" });
});
it("records an overlay opening as a view of its own, and Back as a pop", () => { it("records an overlay opening as a view of its own, and Back as a pop", () => {
window.history.pushState({ sfPage: "feed", _ov: 1 }, ""); window.history.pushState({ sfPage: "feed", _ov: 1 }, "");
// What Back does: the browser restores the previous entry's state (the unwrapped prototype // What Back does: the browser restores the previous entry's state (the unwrapped prototype
+9 -4
View File
@@ -13,9 +13,10 @@ import { FRONTEND_SHA, FRONTEND_VERSION } from "./version";
import { enabled, errorAttrs, log } from "./log"; import { enabled, errorAttrs, log } from "./log";
import { pathTemplate } from "./http"; import { pathTemplate } from "./http";
// URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes"). // URL parameters that carry what the user TYPED — a search, a column's text filter. Logged only at
// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG. // TRACE ("Teljes"). Every other parameter is filter/sort/view state: its NAME is kept at INFO, its
const TYPED_PARAMS = new Set(["q", "keywords", "search"]); // 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"; type Via = "start" | "push" | "replace" | "pop";
@@ -168,9 +169,13 @@ let installed = false;
/** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice. /** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice.
* `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this * `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this
* module stays free of the i18n runtime. */ * module stays free of the i18n runtime. */
export function installClientLog(appCopy: AppCopy = () => null): void { export function installClientLog(
appCopy: AppCopy = () => null,
typedParams: readonly string[] = [],
): void {
if (installed) return; if (installed) return;
installed = true; installed = true;
for (const k of typedParams) TYPED_PARAMS.add(k);
log.info("app.start", "app loaded", { log.info("app.start", "app loaded", {
version: FRONTEND_VERSION, version: FRONTEND_VERSION,
+14
View File
@@ -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 // 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). // the order the stores nest in does not matter (moduleStores.contract.test).
Store?: ComponentType<{ children: ReactNode }>; 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;
}; };
+42
View File
@@ -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));
}
+52
View File
@@ -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",
};
}
+28 -7
View File
@@ -48,15 +48,36 @@ const BASE: PlexUrlState = {
}; };
describe("plexParamUpdates", () => { 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({ expect(plexParamUpdates(BASE)).toEqual({
pscope: null, pscope: "both",
psort: null, psort: "title:asc",
pshow: null, pshow: "all",
pf: null, 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)", () => { it("clears every key when passed null (off-plex page)", () => {
expect(plexParamUpdates(null)).toEqual({ expect(plexParamUpdates(null)).toEqual({
pscope: null, pscope: null,
@@ -66,7 +87,7 @@ describe("plexParamUpdates", () => {
}); });
}); });
it("emits only non-default keys", () => { it("writes the view picked", () => {
const u = plexParamUpdates({ const u = plexParamUpdates({
scope: "movie", scope: "movie",
sort: { key: "year", dir: "desc" }, sort: { key: "year", dir: "desc" },
@@ -76,7 +97,7 @@ describe("plexParamUpdates", () => {
expect(u.pscope).toBe("movie"); expect(u.pscope).toBe("movie");
expect(u.psort).toBe("year:desc"); expect(u.psort).toBe("year:desc");
expect(u.pshow).toBe("in_progress"); expect(u.pshow).toBe("in_progress");
expect(u.pf).toBeNull(); expect(u.pf).toBe("");
}); });
}); });
+42 -20
View File
@@ -3,11 +3,12 @@
// urlState. Keys are NAMESPACED (`pscope`/`psort`/`pshow`/`pf`) rather than reusing the feed's bare // 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 // `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 // 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- // keys are Plex-unique (like `?v=`/`?pl`), so no other provider ever reads or clears them. Every key is
// default values are emitted, so URLs stay clean. // 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 { parsePlexFilters, plexFiltersToParams, type PlexFilters } from "./api";
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort"; import { decodeSortParamOrNone, encodeSortParamOrNone, type SortState } from "./columnSort";
import { PLEX_SORT_FALLBACK } from "./plexSort"; import { parsePlexField, type PlexSearchField } from "./plexSearch";
// The content state this mirrors. `sort` is the RAW provider SortState (scope-healed only at the point of // 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. // use), so the user's real preference round-trips through the URL.
@@ -18,29 +19,29 @@ export interface PlexUrlState {
filters: PlexFilters; 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 SCOPES = ["movie", "show", "both"];
const SHOWS = ["all", "unwatched", "in_progress", "watched"]; 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`) /** The address-bar delta for the Plex content state: each namespaced key mapped to its value (sort as
// emits nothing. * `key:dir`, the shared columnSort codec; an empty filter set as `pf=`). Pass `null` to clear ALL Plex
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
* keys — used when the app is off the Plex page (localStorage still holds the state; it re-mirrors on * 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(). */ * return). Feed the result into urlWithParams(). */
export function plexParamUpdates(state: PlexUrlState | null): Record<string, string | null> { export function plexParamUpdates(state: PlexUrlState | null): Record<string, string | null> {
if (!state) return { pscope: null, psort: null, pshow: null, pf: null }; if (!state) return { pscope: null, psort: null, pshow: null, pf: null };
const pf = plexFiltersToParams(state.filters).toString(); const pf = plexFiltersToParams(state.filters).toString();
return { return {
pscope: state.scope && state.scope !== "both" ? state.scope : null, pscope: state.scope || "both",
psort: encodeSort(state.sort), psort: encodeSortParamOrNone(state.sort),
pshow: state.show && state.show !== "all" ? state.show : null, pshow: state.show || "all",
pf: pf || null, pf,
}; };
} }
@@ -54,8 +55,8 @@ export function hasPlexParams(params: URLSearchParams): boolean {
export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): PlexUrlState { export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): PlexUrlState {
const scope = params.get("pscope"); const scope = params.get("pscope");
const show = params.get("pshow"); const show = params.get("pshow");
const psort = params.get("psort"); const linkedSort = decodeSortParamOrNone(params.get("psort"));
const sort = psort != null ? (decodeSortParam(psort) ?? base.sort) : base.sort; const sort = linkedSort === undefined ? base.sort : linkedSort;
return { return {
scope: scope && SCOPES.includes(scope) ? scope : base.scope, scope: scope && SCOPES.includes(scope) ? scope : base.scope,
sort, sort,
@@ -65,3 +66,24 @@ export function plexParamsToState(params: URLSearchParams, base: PlexUrlState):
: base.filters, : 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) }),
};
}
+1
View File
@@ -33,6 +33,7 @@ describe("qk — single-id and scalar keys", () => {
it("returns [root, id]", () => { it("returns [root, id]", () => {
expect(qk.channel("c1")).toEqual(["channel", "c1"]); expect(qk.channel("c1")).toEqual(["channel", "c1"]);
expect(qk.videoDetail("v1")).toEqual(["video-detail", "v1"]); 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.playlistMembership("v1")).toEqual(["playlist-membership", "v1"]);
expect(qk.plexItem("i1")).toEqual(["plex-item", "i1"]); expect(qk.plexItem("i1")).toEqual(["plex-item", "i1"]);
expect(qk.plexPlaylist(9)).toEqual(["plex-playlist", 9]); expect(qk.plexPlaylist(9)).toEqual(["plex-playlist", 9]);
+1
View File
@@ -32,6 +32,7 @@ export const qk = {
ytSearch: (q?: string | null, count?: number) => ytSearch: (q?: string | null, count?: number) =>
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const), q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
videoDetail: (id: string) => ["video-detail", id] as const, videoDetail: (id: string) => ["video-detail", id] as const,
videoRow: (id: string) => ["video-row", id] as const,
related: (id?: string, region?: string) => related: (id?: string, region?: string) =>
id !== undefined ? (["related", id, region] as const) : (["related"] as const), id !== undefined ? (["related", id, region] as const) : (["related"] as const),
// This session's confirmed marks per video (lib/progressCache `markVideo`): no fetch behind it. // This session's confirmed marks per video (lib/progressCache `markVideo`): no fetch behind it.
+10 -2
View File
@@ -1,8 +1,16 @@
import { LS, setTabRaw } from "./storage"; import { LS, setTabRaw } from "./storage";
// The Settings page's tabs, and a helper to pre-select one before navigating there (a notice's link, // 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 // a return from Google). useTabPersistedState reads the key when the page mounts, and the page on
// fresh on navigation. Kept out of SettingsPanel.tsx so a caller does not pull the page in with it. // 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 = export type SettingsTabId =
| "appearance" | "appearance"
| "notifications" | "notifications"
+36 -8
View File
@@ -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 // 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 // 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 --------------------------------------------------------- // --- 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 // Which sub-tab a page was left on (Settings, Configuration, Users) is kept for THIS browser tab only —
// tab only — sessionStorage, the account's (see ownKey): it survives a reload and a trip to another page, and a new // 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 // 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. */ /** The sub-tab stored for this browser tab, or null. */
function getTabRaw(base: string): string | 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). */ function writeTabRaw(base: string, value: string): void {
export function setTabRaw(base: string, value: string): void {
const k = ownKey(base); const k = ownKey(base);
if (!k) return; if (!k) return;
try { 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( export function useTabPersistedState(
base: string, base: string,
fallback = "", fallback = "",
urlKey?: string,
): [string, (value: string) => void] { ): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => { const [value, setValue] = useState<string>(() => {
// The position used to live in localStorage; a value left there would never be read again. // The position used to live in localStorage; a value left there would never be read again.
@@ -348,12 +361,27 @@ export function useTabPersistedState(
/* ignore */ /* 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; 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( const set = useCallback(
(next: string) => { (next: string) => {
setValue(next); setValue(next);
setTabRaw(base, next); writeTabRaw(base, next);
}, },
[base], [base],
); );
+17
View File
@@ -41,4 +41,21 @@ describe("a page's sub-tab, per browser tab", () => {
const { result } = renderHook(() => useTabPersistedState("siftlode.adminUsersTab", "roles")); const { result } = renderHook(() => useTabPersistedState("siftlode.adminUsersTab", "roles"));
expect(result.current[0]).toBe("access"); 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, "", "/");
});
}); });
+2 -2
View File
@@ -127,13 +127,13 @@ describe("a page opened at a path that starts with //", () => {
}); });
describe("filterParamUpdates", () => { describe("filterParamUpdates", () => {
it("maps non-default filters to values and defaults to null (delete)", () => { it("maps non-default filters to values and other defaults to null (delete)", () => {
const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" }; const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" };
const upd = filterParamUpdates(f); const upd = filterParamUpdates(f);
expect(upd.q).toBe("react"); expect(upd.q).toBe("react");
expect(upd.tags).toBe("3,7"); expect(upd.tags).toBe("3,7");
expect(upd.show).toBe("watched"); expect(upd.show).toBe("watched");
expect(upd.sort).toBeNull(); // default "newest" is not emitted → cleared expect(upd.sort).toBe("newest"); // the view and the order are always named, defaults too
expect(upd.scope).toBeNull(); expect(upd.scope).toBeNull();
}); });
+58 -13
View File
@@ -4,9 +4,11 @@
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is // 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 // 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. // 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", "q",
"show", "show",
"sort", "sort",
@@ -29,12 +31,32 @@ const KEYS = [
"mindur", "mindur",
"maxdur", "maxdur",
] as const; ] 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 { export function filtersToParams(f: FeedFilters): URLSearchParams {
const p = new URLSearchParams(); const p = new URLSearchParams();
if (f.q) p.set("q", f.q); if (f.q) p.set("q", f.q);
if (f.show && f.show !== "unwatched") p.set("show", f.show); if (f.show && f.show !== FEED_DEFAULT_SHOW) p.set("show", f.show);
if (f.sort && f.sort !== "newest") p.set("sort", f.sort); if (f.sort && f.sort !== FEED_DEFAULT_SORT) p.set("sort", f.sort);
if (f.scope === "all") p.set("scope", "all"); if (f.scope === "all") p.set("scope", "all");
// Library provenance filter (only meaningful in "all" scope); "organic" is the default. // Library provenance filter (only meaningful in "all" scope); "organic" is the default.
if (f.scope === "all" && f.librarySource && f.librarySource !== "organic") 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 /** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted
* value, or null to delete it (so switching a filter back to its default clears the key). Pass `null` * value, or null to delete it (so switching a filter back to its default clears the key) — except the
* to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them. * view and the order, always written (defaults too): a feed address then always names its view, so a
* Feed the result into urlWithParams(). */ * bookmark of the default feed opens the default feed, not whatever view the account stored since
* (an address naming none falls back to it). Pass `null` to clear ALL filter keys — used when leaving
* the feed, where the localStorage copy still holds them. Feed the result into urlWithParams(). */
export function filterParamUpdates(f: FeedFilters | null): Record<string, string | null> { export function filterParamUpdates(f: FeedFilters | null): Record<string, string | null> {
const p = f ? filtersToParams(f) : new URLSearchParams(); const p = f ? filtersToParams(f) : new URLSearchParams();
const out: Record<string, string | null> = {}; const out: Record<string, string | null> = {};
for (const k of KEYS) out[k] = p.get(k); for (const k of KEYS) out[k] = p.get(k);
if (f) {
out.show = f.show || FEED_DEFAULT_SHOW;
out.sort = f.sort || FEED_DEFAULT_SORT;
}
return out; 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("q")) f.q = params.get("q") ?? "";
if (params.has("show")) f.show = params.get("show") || "unwatched"; if (params.has("show")) f.show = params.get("show") || FEED_DEFAULT_SHOW;
if (params.has("sort")) f.sort = params.get("sort") || "newest"; 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("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
if (params.has("source")) { if (params.has("source")) {
const s = params.get("source"); const s = params.get("source");
@@ -211,6 +239,22 @@ export function urlWithParams(updates: Record<string, string | null>): string {
return hrefHere(params); 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 /** 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: * 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 * 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}`; return url.pathname.startsWith("//") ? url.href : `${url.pathname}${url.search}${url.hash}`;
} }
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify, /** Take `keys` off the current entry's address, WITHOUT touching the nav/filter params that live
* ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now * there: TRANSIENT params once read (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
* live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now * ?reset), and what a link named that the page could not show (a file or a video that is gone, said
* that the query is the source of truth for navigation. No-op when none of the keys are present. */ * once) — left, it would be said again on every reload, or shared. Preserves history.state. No-op when
* none of the keys are present. */
export function clearParams(...keys: string[]): void { export function clearParams(...keys: string[]): void {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
let changed = false; let changed = false;
+7 -7
View File
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; 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 { tagPickLabel } from "./tagPicks";
import { useChannelNames } from "./useChannelNames"; import { useChannelNames } from "./useChannelNames";
import { parseSort } from "./feedSort"; import { parseSort } from "./feedSort";
@@ -20,11 +20,11 @@ const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tagModes: DEFAULT_TAG_MODES, tagModes: DEFAULT_TAG_MODES,
untagged: false, untagged: false,
channelIds: [], channelIds: [],
sort: "newest", sort: FEED_DEFAULT_SORT,
includeNormal: true, includeNormal: true,
includeShorts: false, includeShorts: false,
includeLive: 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({ out.push({
key: "show", key: "show",
label: t("sidebar.show." + filters.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({ out.push({
key: "sort", key: "sort",
label: t("feed.sortKey." + parseSort(filters.sort).key), label: t("feed.sortKey." + parseSort(filters.sort).key),
remove: () => setFilters({ ...filters, sort: "newest", seed: undefined }), remove: () => setFilters({ ...filters, sort: FEED_DEFAULT_SORT, seed: undefined }),
}); });
} }
+40 -2
View File
@@ -5,7 +5,7 @@
// so an inline object literal (new identity every render) only fires when a param actually changes; a // so an inline object literal (new identity every render) only fires when a param actually changes; a
// null value clears a key; and every other param on the URL is preserved. // null value clears a key; and every other param on the URL is preserved.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook } from "@testing-library/react"; import { act, cleanup, renderHook } from "@testing-library/react";
import { useUrlMirror } from "./useUrlMirror"; import { useUrlMirror } from "./useUrlMirror";
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/"); const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
@@ -15,7 +15,10 @@ beforeEach(() => {
setUrl("/"); setUrl("/");
spy = vi.spyOn(window.history, "replaceState"); spy = vi.spyOn(window.history, "replaceState");
}); });
afterEach(() => spy.mockRestore()); afterEach(() => {
cleanup();
spy.mockRestore();
});
describe("useUrlMirror", () => { describe("useUrlMirror", () => {
it("writes the param onto the current entry on mount", () => { it("writes the param onto the current entry on mount", () => {
@@ -49,6 +52,41 @@ describe("useUrlMirror", () => {
expect(window.location.search).toBe("?page=playlists&pl=5"); expect(window.location.search).toBe("?page=playlists&pl=5");
}); });
// An entry above the view's own (a dialog's, a drawer's, a sub-view's) took a change the entry
// under it never saw: Back restores that entry's older address, and a reload would bring the old
// view back over the one on screen.
it("names the view again on the entry Back lands on", async () => {
setUrl("?page=plex&pf=");
const { rerender } = renderHook((p: Record<string, string | null>) => useUrlMirror(p), {
initialProps: { pf: "" } as Record<string, string | null>,
});
window.history.pushState({ _ov: 1 }, "", window.location.href);
rerender({ pf: "genres=Drama" });
expect(window.location.search).toBe("?page=plex&pf=genres%3DDrama");
await act(async () => {
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
window.history.back();
await landed;
await new Promise((res) => setTimeout(res, 0));
});
expect(window.history.state).toBeNull();
expect(window.location.search).toBe("?page=plex&pf=genres%3DDrama");
});
it("writes nothing on a landing that already names the view", async () => {
setUrl("?pl=5");
renderHook(() => useUrlMirror({ pl: "5" }));
window.history.pushState({ _ov: 1 }, "", window.location.href);
spy.mockClear();
await act(async () => {
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
window.history.back();
await landed;
await new Promise((res) => setTimeout(res, 0));
});
expect(spy).not.toHaveBeenCalled();
});
it("leaves an unconsumed auth token in the fragment alone", () => { it("leaves an unconsumed auth token in the fragment alone", () => {
// This hook's mount effect is what ran before <App> (and therefore before the auth card) had even // This hook's mount effect is what ran before <App> (and therefore before the auth card) had even
// loaded, so whatever it writes to the address bar is what the card later reads. It must not eat // loaded, so whatever it writes to the address bar is what the card later reads. It must not eat
@@ -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);
});
});
+51 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useEffectEvent } from "react"; import { useEffect, useEffectEvent, useRef, useState } from "react";
import { replaceView } from "./history";
import { urlWithParams } from "./urlState"; import { urlWithParams } from "./urlState";
// Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new // Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new
@@ -6,17 +7,61 @@ import { urlWithParams } from "./urlState";
// value clears that key, and urlWithParams preserves every other param (so providers don't clobber each // value clears that key, and urlWithParams preserves every other param (so providers don't clobber each
// other). Extracted from the identical replaceState/urlWithParams blocks FeedFiltersProvider and // other). Extracted from the identical replaceState/urlWithParams blocks FeedFiltersProvider and
// PlaylistsProvider used to hand-roll, so the mirroring contract lives in one place. // PlaylistsProvider used to hand-roll, so the mirroring contract lives in one place.
//
// Also whenever Back/Forward makes another entry current: the browser restores that entry's address,
// which says what the view was when it was left — a change made since on an entry above it (a dialog's
// or a drawer's, a sub-view's) is not in it, and a reload would bring the old view back over the one
// on screen. (One landed on by an overlay's step back that another step will leave is written too,
// and harmlessly: the next landing writes the entry that stays.)
// 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 { export function useUrlMirror(updates: Record<string, string | null>): void {
// The caller typically passes an inline object literal (new identity every render); key on its // The caller typically passes an inline object literal (new identity every render); key on its
// serialized value so the effect only fires when a param actually changes, not every render. // serialized value so the effect only fires when a param actually changes, not every render.
const dep = JSON.stringify(updates); const dep = JSON.stringify(updates);
// The object itself is read through an effect event, so the effect's one reactive input is the // The object itself is read through an effect event, so the effect's reactive inputs are the
// serialized value — which is exactly what the dep list says: an effect event for the identity that // serialized value and the landing count — which is exactly what the dep list says: an effect event
// churns, a primitive for the trigger. // for the identity that churns, primitives for the triggers.
// 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(() => { 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(() => { useEffect(() => {
mirror(); mirror();
}, [dep]); }, [dep, landings]);
} }
+31
View File
@@ -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]);
});
});
+32
View File
@@ -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,
};
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { ConfirmProvider } from "./components/ui/ConfirmProvider";
import BootGate, { BootScreen } from "./shell/BootGate"; import BootGate, { BootScreen } from "./shell/BootGate";
import { NavigationProvider } from "./features/navigation/NavigationProvider"; import { NavigationProvider } from "./features/navigation/NavigationProvider";
import { ModuleStores } from "./features/navigation/ModuleStores"; import { ModuleStores } from "./features/navigation/ModuleStores";
import { typedUrlKeys } from "./features/navigation/moduleRegistry";
import { PrefsProvider } from "./features/prefs/PrefsProvider"; import { PrefsProvider } from "./features/prefs/PrefsProvider";
import { WizardProvider } from "./features/wizard/WizardProvider"; import { WizardProvider } from "./features/wizard/WizardProvider";
import "./i18n"; import "./i18n";
@@ -19,7 +20,7 @@ import { installStaleBuildReload } from "./lib/staleBuild";
import { appCopyOf } from "./i18n/appCopy"; import { appCopyOf } from "./i18n/appCopy";
// First, before any render: a crash or a navigation during boot belongs in the log too. // First, before any render: a crash or a navigation during boot belongs in the log too.
installClientLog(appCopyOf); installClientLog(appCopyOf, typedUrlKeys());
// A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk. // A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk.
installStaleBuildReload(); installStaleBuildReload();
+126
View File
@@ -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);
});
});
+516
View File
@@ -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 () => { it("Back/Forward on the server log reads only the filters of the entry it lands on", async () => {
// A browser runs the microtasks between two popstate listeners, so each listener's update // A browser runs the microtasks between two popstate listeners, so each listener's update
// renders on its own; a test dispatch does not. Each listener runs in its own act here. // renders on its own; a test dispatch does not. Each listener runs in its own act here — a
// synchronous one: a browser runs no TASK between them (an async act let a timer set by one
// listener fire before the next).
const listeners: EventListener[] = []; const listeners: EventListener[] = [];
const addOther = window.addEventListener.bind(window); const addOther = window.addEventListener.bind(window);
const removeOther = window.removeEventListener.bind(window); const removeOther = window.removeEventListener.bind(window);
@@ -316,7 +318,7 @@ describe("Activity", () => {
// Forward onto a pivot's entry: one run's lines over the log's whole retention. // Forward onto a pivot's entry: one run's lines over the log's whole retention.
window.history.pushState(null, "", "/?page=activity&at=logs&aw=90d&lrun=41"); window.history.pushState(null, "", "/?page=activity&at=logs&aw=90d&lrun=41");
for (const l of [...listeners]) { for (const l of [...listeners]) {
await act(async () => l(new PopStateEvent("popstate"))); act(() => l(new PopStateEvent("popstate")));
} }
await waitFor(() => await waitFor(() =>
expect(logsMock).toHaveBeenLastCalledWith(expect.objectContaining({ run_id: 41 }), null), expect(logsMock).toHaveBeenLastCalledWith(expect.objectContaining({ run_id: 41 }), null),
+3
View File
@@ -1,6 +1,7 @@
import { ScrollText } from "lucide-react"; import { ScrollText } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload"; import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef"; 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. // This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
export const activityModule: ModuleDef = { export const activityModule: ModuleDef = {
@@ -12,4 +13,6 @@ export const activityModule: ModuleDef = {
system: true, system: true,
gate: (me) => me.role === "admin", gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./Activity")), Page: lazyWithPreload(() => import("./Activity")),
urlKeys: ACTIVITY_KEYS,
typedUrlKeys: ["aq"],
}; };
@@ -106,13 +106,13 @@ describe("ChannelsProvider — shareable URL", () => {
expect(result.current.filters).toEqual({ sync: "needsFull" }); 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(); const { result } = render();
act(() => result.current.setView("blocked")); act(() => result.current.setView("blocked"));
expect(new URLSearchParams(window.location.search).get("ctab")).toBe("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")); 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", () => { 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 { usePref } from "../../lib/usePref";
import { useFilterState } from "../../lib/useFilterState"; import { useFilterState } from "../../lib/useFilterState";
import { useUrlMirror } from "../../lib/useUrlMirror"; import { useUrlMirror } from "../../lib/useUrlMirror";
import { useDebounced } from "../../lib/useDebounced";
import { rememberNavPosition } from "../../lib/history"; import { rememberNavPosition } from "../../lib/history";
import { import {
channelsParamsToState, channelsParamsToState,
@@ -24,6 +25,7 @@ import {
} from "../../lib/channelsUrlState"; } from "../../lib/channelsUrlState";
import type { FilterMap } from "../../lib/tableColumns"; import type { FilterMap } from "../../lib/tableColumns";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import { import {
ChannelLayoutContext, ChannelLayoutContext,
ChannelsActionsContext, ChannelsActionsContext,
@@ -42,7 +44,8 @@ import {
// //
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and // 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 // 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. // 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. // 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)), view: clampView(getAccountRaw(LS.channelsView)),
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null), sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
filters: readStoredFilters(), filters: readStoredFilters(),
search: "",
}); });
} }
export function ChannelsProvider({ children }: { children: ReactNode }) { export function ChannelsProvider({ children }: { children: ReactNode }) {
const { page } = useNavigation(); const nav = useNavigation();
const { page } = nav;
const { setPage } = useNavigationActions(); 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 // 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. // chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip); 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 [focusNameRaw, setFocusName] = useState<string | null>(null);
const [focusToken, setFocusToken] = useState(0); 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 // 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 // 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. // 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. // column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
setFilters({ sync: "needsFull" }); setFilters({ sync: "needsFull" });
setView("subscribed"); // the sync filter applies to the subscriptions tab 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"); setPage("channels");
}, [setFilters, setView, setPage]); }, [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 // Mirror the content state (tab + sort + column filters + the name search) into the address bar under
// keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared // the namespaced keys so a manager view is shareable. Gated on urlPage===channels: off the manager (or
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a // under a channel page) the keys are cleared (localStorage still holds the rest; it re-mirrors on
// deliberately transient rail-search, like the feed's and plex's. // return; the search is this visit's). The search is written once the typing pauses: one address-bar
useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null)); // 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 ( return (
<ChannelsActionsContext.Provider value={actions}> <ChannelsActionsContext.Provider value={actions}>
+3
View File
@@ -1,6 +1,7 @@
import { Tv } from "lucide-react"; import { Tv } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload"; import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef"; import type { ModuleDef } from "../../lib/moduleDef";
import { CHANNELS_KEYS } from "../../lib/channelsUrlState";
import { ChannelsProvider } from "./ChannelsProvider"; import { ChannelsProvider } from "./ChannelsProvider";
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily. // 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")), Page: lazyWithPreload(() => import("./Channels")),
maxWidth: "96rem", maxWidth: "96rem",
Store: ChannelsProvider, Store: ChannelsProvider,
urlKeys: CHANNELS_KEYS,
typedUrlKeys: ["cq", "cf"],
}; };
@@ -55,6 +55,8 @@ import ConfigPanel from "./ConfigPanel";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
localStorage.clear(); localStorage.clear();
sessionStorage.clear();
window.history.replaceState(null, "", "/");
}); });
const renderPanel = () => const renderPanel = () =>
@@ -90,3 +92,24 @@ describe("Configuration sections", () => {
expect(screen.queryByText("config.fields.ka.label")).toBeNull(); 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");
});
});
+14 -8
View File
@@ -16,6 +16,8 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle";
import FilterSelect from "../../components/ui/FilterSelect"; import FilterSelect from "../../components/ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "../../components/ui/DraftSaveBar"; import { DraftSaveBar, type SaveState } from "../../components/ui/DraftSaveBar";
import { LS, useTabPersistedState } from "../../lib/storage"; import { LS, useTabPersistedState } from "../../lib/storage";
import { CONFIG_TAB_KEY, configTabParams } from "../../lib/configTab";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import { CheckRow } from "../../components/ui/NativeControls"; import { CheckRow } from "../../components/ui/NativeControls";
@@ -95,7 +97,7 @@ export default function ConfigPanel() {
const [reseedToken, setReseedToken] = useState(0); const [reseedToken, setReseedToken] = useState(0);
// One tab per config group (persisted). Called before the loading guard so hook order is // 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. // 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 // 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 // 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(",") })); 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) { if (q.isError && !data) {
return ( return (
<StateMessage tone="error" onRetry={() => q.refetch()}> <StateMessage tone="error" onRetry={() => q.refetch()}>
@@ -192,13 +205,6 @@ export default function ConfigPanel() {
return <LoadingState label={t("config.loading")} />; 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 dirtyByGroup = new Set(dirtyItems.map((i) => i.group).filter(Boolean));
const groupTabs = data.sections.flatMap((s) => const groupTabs = data.sections.flatMap((s) =>
s.groups.map((g) => ({ s.groups.map((g) => ({
+2
View File
@@ -1,6 +1,7 @@
import { SlidersHorizontal } from "lucide-react"; import { SlidersHorizontal } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload"; import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef"; 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. // This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
export const configModule: ModuleDef = { export const configModule: ModuleDef = {
@@ -12,4 +13,5 @@ export const configModule: ModuleDef = {
system: true, system: true,
gate: (me) => me.role === "admin", gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./ConfigPanel")), Page: lazyWithPreload(() => import("./ConfigPanel")),
urlKeys: [CONFIG_TAB_KEY],
}; };
+36 -6
View File
@@ -34,6 +34,12 @@ import { useFeedView, useFeedViewActions } from "./feedViewContext";
import { useShowChannelInFeed } from "./useFeedPageHooks"; import { useShowChannelInFeed } from "./useFeedPageHooks";
import { SHOW_IDS } from "./feedFiltersInit"; import { SHOW_IDS } from "./feedFiltersInit";
import { usePref } from "../../lib/usePref"; import { usePref } from "../../lib/usePref";
import { useUrlMirror } from "../../lib/useUrlMirror";
import {
channelPageParamUpdates,
channelPageViewFrom,
type ChannelTab,
} from "../../lib/channelPageUrlState";
import { LS } from "../../lib/storage"; import { LS } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { notify } from "../../lib/notifications"; 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 // 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. // land you on an empty page.
const onShowInFeed = useShowChannelInFeed(channelId); 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(); const panel = useTabPanel();
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's // 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 // 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: [], tags: [],
tagModes: DEFAULT_TAG_MODES, tagModes: DEFAULT_TAG_MODES,
q: "", q: "",
sort: "newest", sort: opened.sort,
scope: "all", scope: "all",
librarySource: "all", librarySource: "all",
// Content-type prefs inherited from the global feed (seeded once) — no longer forced. // Content-type prefs inherited from the global feed (seeded once) — no longer forced.
includeNormal: globalFilters.includeNormal, ...opened.types,
includeShorts: globalFilters.includeShorts,
includeLive: globalFilters.includeLive,
show: "unwatched", show: "unwatched",
channelIds: [channelId], channelIds: [channelId],
})); }));
const filters: FeedFilters = { ...ownFilters, show }; 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) => { const setFilters = (next: FeedFilters) => {
if (next.show !== show) setShow(next.show); if (next.show !== show) setShow(next.show);
setOwnFilters(next); setOwnFilters(next);
@@ -436,7 +466,7 @@ export default function ChannelPage({
idBase={panel.idBase} idBase={panel.idBase}
className="flex gap-1" className="flex gap-1"
active={tab} active={tab}
onChange={(id) => setTab(id as "videos" | "about")} onChange={(id) => setTab(id as ChannelTab)}
tabs={[ tabs={[
{ id: "videos", label: t("channel.tabVideos") }, { id: "videos", label: t("channel.tabVideos") },
{ id: "about", label: t("channel.tabAbout") }, { 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 { return {
default: function Player({ default: function Player({
video, video,
queue,
onState, onState,
onWrite, onWrite,
onClose, onClose,
}: { }: {
video: { id: string }; video: { id: string };
queue?: { id: string }[];
onState: (id: string, status: string, v?: unknown) => void; onState: (id: string, status: string, v?: unknown) => void;
onWrite?: (write: Promise<unknown>) => void; onWrite?: (write: Promise<unknown>) => void;
onClose: () => void; onClose: () => void;
@@ -71,6 +73,9 @@ vi.mock("../../features/player/PlayerModal", async () => {
); );
return ( return (
<div> <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={() => onState(video.id, "watched")}>mark</button>
<button <button
onClick={() => onClick={() =>
@@ -116,6 +121,7 @@ afterEach(() => {
player.watchOnUnmount = false; player.watchOnUnmount = false;
player.flush = null; player.flush = null;
clearAll(); clearAll();
window.history.replaceState(null, "", "/");
}); });
// A cached related list (the player's, kept for the session) holding these videos. // A cached related list (the player's, kept for the session) holding these videos.
@@ -190,6 +196,26 @@ function mount({
return { qc, feed, summary }; 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", () => { 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 () => { it("stays out of an Unwatched view when the player writes its position into the cache", async () => {
const { qc } = mount(); const { qc } = mount();
+62 -37
View File
@@ -24,6 +24,7 @@ import {
import { YouTubeIcon } from "../../lib/brandIcons"; import { YouTubeIcon } from "../../lib/brandIcons";
import { import {
api, api,
FEED_DEFAULT_SORT,
HttpError, HttpError,
refusalReason, refusalReason,
type FeedFilters, type FeedFilters,
@@ -40,6 +41,8 @@ import {
} from "../../features/video/videoWrites"; } from "../../features/video/videoWrites";
import { notify } from "../../lib/notifications"; import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { YT_COUNTS } from "../../lib/ytSearchUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { useDebounced } from "../../lib/useDebounced"; import { useDebounced } from "../../lib/useDebounced";
import { import {
markedRows, markedRows,
@@ -80,6 +83,8 @@ import ViewSwitcher from "../../components/ui/ViewSwitcher";
import VirtualFeed from "./VirtualFeed"; import VirtualFeed from "./VirtualFeed";
import SegmentedToggle from "../../components/ui/SegmentedToggle"; import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { IconButton } from "../../components/ui/IconButton"; 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. // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("../../features/player/PlayerModal")); 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; 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 // 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. // shared budget spent (ours, or YouTube's own) — tomorrow or the admin does.
const SEARCH_FINAL_REASONS = new Set([ 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 // 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. // before the server took the reset puts the old position back: written again on its landing.
const resets = useRef(new Map<string, number>()); const resets = useRef(new Map<string, number>());
// The open player: which video and where to start (null = resume from saved position). // The open player: which video and where to start (null = resume from saved position). `alone`:
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>( // opened from an address naming a video this list has not loaded, so it plays without the queue.
null, const [activeVideo, setActiveVideo] = useState<{
); video: Video;
startAt: number | null;
alone?: boolean;
} | null>(null);
const qc = useQueryClient(); const qc = useQueryClient();
const videoMarks = useVideoMarks(); const videoMarks = useVideoMarks();
// Live search turned off on this instance: the empty feed offers no search that would be refused. // 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 // 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 // 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; const ytActive = !!ytSearch;
// How many live-search results to gather (the count selector replaces a manual "load more"; // 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). // the free scrape source pages through continuations until this many are collected). Named in the
const [ytCount, setYtCount] = useState(20); // 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 // 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 // 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. // 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)), stampFetch(() => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)),
initialPageParam: null as string | null, initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined, getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: ytActive, enabled: ytRuns,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
retry: false, 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 // 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 // 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 // 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(); const qEmpty = !filters.q.trim();
useEffect(() => { useEffect(() => {
if (qEmpty && parseSort(filters.sort).key === "relevance") { 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 // 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 // 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]; const saved = savedOverrides[v.id];
return saved && !(live && saved.answered) ? { ...v, saved: saved.value } : v; return saved && !(live && saved.answered) ? { ...v, saved: saved.value } : v;
}); });
// The live search's cards, in the order picked for them, and the player's queue over them.
const ytShown = (rows: Video[]): Video[] =>
sortYtResults(withOverrides(rows, true), ytSort.key as SortKey, ytSort.dir, lang);
const ytQueueOf = (shown: Video[]): Video[] => shown.filter((v) => v.status !== "hidden");
const merged: Video[] = withOverrides(loaded); const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show)); const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the // The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
@@ -509,6 +515,20 @@ export default function Feed({
// sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that // sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that
// goes hidden still drops out ("visible affects"). // goes hidden still drops out ("visible affects").
const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden"); const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden");
// A reload or a bookmark of the player reopens it once this list is in, the list as its queue again
// (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 // 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. // 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 // A separate data source rendered in the same cards/player. No watch-state view filter (the
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota). // user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
if (ytActive) { if (ytActive) {
const ytItems: Video[] = sortYtResults( const ytItems: Video[] = ytShown(ytLoaded);
withOverrides(ytLoaded, true), const ytPlayerQueue: Video[] = ytQueueOf(ytItems);
ytSort.key as SortKey,
ytSort.dir,
lang,
);
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
const ytError = const ytError =
ytQuery.error instanceof HttpError ytQuery.error instanceof HttpError
? ytQuery.error.detail || t("feed.yt.error") ? ytQuery.error.detail || t("feed.yt.error")
@@ -539,7 +554,8 @@ export default function Feed({
const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? ""); const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? "");
// The backend reports which source served the search; scrape spends no search quota, so we // 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. // 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 ( return (
<div className="p-4"> <div className="p-4">
@@ -609,7 +625,7 @@ export default function Feed({
.catch((e: unknown) => .catch((e: unknown) =>
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")), notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
); );
setFilters({ ...filters, librarySource: "organic", sort: "newest" }); setFilters({ ...filters, librarySource: "organic", sort: FEED_DEFAULT_SORT });
onExitYtSearch(); onExitYtSearch();
dropFeedCaches(); dropFeedCaches();
qc.removeQueries({ queryKey: qk.ytSearch() }); qc.removeQueries({ queryKey: qk.ytSearch() });
@@ -621,11 +637,20 @@ export default function Feed({
)} )}
</div> </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> <div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError && !ytItems.length ? ( ) : ytError && !ytItems.length ? (
// A first page that failed is said as the error it is, with Retry where trying again can // 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. // failure from the cache and sends nothing.
<StateMessage <StateMessage
tone="error" tone="error"
@@ -681,7 +706,7 @@ export default function Feed({
<PlayerModal <PlayerModal
video={activeVideo.video} video={activeVideo.video}
startAt={activeVideo.startAt} startAt={activeVideo.startAt}
queue={ytPlayerQueue} queue={activeVideo.alone ? undefined : ytPlayerQueue}
onClose={closePlayer} onClose={closePlayer}
onState={onState} onState={onState}
onWrite={trackSessionWrite} onWrite={trackSessionWrite}
@@ -1006,7 +1031,7 @@ export default function Feed({
<PlayerModal <PlayerModal
video={activeVideo.video} video={activeVideo.video}
startAt={activeVideo.startAt} startAt={activeVideo.startAt}
queue={playerQueue} queue={activeVideo.alone ? undefined : playerQueue}
onClose={closePlayer} onClose={closePlayer}
onState={onState} onState={onState}
onWrite={trackSessionWrite} 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", () => { 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 () => { it("offers Retry where trying again can help, and reads the page on it", async () => {
const search = vi const search = vi
.spyOn(api, "searchYoutube") .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 { type FeedFilters } from "../../lib/api";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../../lib/urlState"; import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../../lib/urlState";
import { useUrlMirror } from "../../lib/useUrlMirror"; import { useUrlMirror } from "../../lib/useUrlMirror";
import { useDebounced } from "../../lib/useDebounced";
import { ownKey, writeJSON } from "../../lib/storage"; import { ownKey, writeJSON } from "../../lib/storage";
import { logFilterChange } from "../../lib/filterLog"; import { logFilterChange } from "../../lib/filterLog";
import { useNavigation } from "../../features/navigation/navigationContext"; import { useNavigation } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import { import {
FeedFiltersActionsContext, FeedFiltersActionsContext,
FeedFiltersStateContext, FeedFiltersStateContext,
@@ -50,13 +52,16 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
if (key) writeJSON(key, next); if (key) writeJSON(key, next);
}, []); }, []);
// Mirror the feed filters into the address bar so every feed view is bookmarkable. Gated on // Mirror the feed filters into the address bar so every feed view is bookmarkable. Gated on the
// page===feed: on other pages the filter keys are cleared from the URL (localStorage still holds // feed being what the address shows (urlPage): on other pages, and under a channel page, the filter
// them; they re-mirror on return to the feed). The channel detail page keeps its own isolated // keys are cleared from the URL (localStorage still holds them; they re-mirror on return to the
// filters, so those never reach here. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the // feed). The channel page keeps its own isolated filters, in keys of its own. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
// other providers own. // other providers own.
const { page } = useNavigation(); // The search text once the typing pauses, as the other pages' searches: one address-bar write per
useUrlMirror(filterParamUpdates(page === "feed" ? filters : null)); // 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 // A share link's filters (captured at mount) win over the account's stored view and are persisted
// to it once, so the view the link opened also survives a later plain reload. // to it once, so the view the link opened also survives a later plain reload.
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
//
// The feed's address names its view. The view and the order always (defaults too): an address naming
// none falls back to the view the account stored, so a bookmark of the default feed reopened whatever
// view was stored since. The search text once the typing pauses, as the other pages' searches.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { useEffect } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NavigationProvider } from "../../features/navigation/NavigationProvider";
import { FeedFiltersProvider } from "./FeedFiltersProvider";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { qk } from "../../lib/queryKeys";
import { claimLocalData, LS } from "../../lib/storage";
import type { FeedFilters } from "../../lib/api";
const seen: { filters: FeedFilters | null; set: ((f: FeedFilters) => void) | null } = {
filters: null,
set: null,
};
function Probe() {
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
useEffect(() => {
seen.filters = filters;
seen.set = setFilters;
});
return null;
}
function mount() {
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 5, preferences: {} });
render(
<QueryClientProvider client={qc}>
<NavigationProvider>
<FeedFiltersProvider>
<Probe />
</FeedFiltersProvider>
</NavigationProvider>
</QueryClientProvider>,
);
}
const search = () => new URLSearchParams(window.location.search);
beforeEach(() => {
localStorage.clear();
claimLocalData(5);
});
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
describe("FeedFiltersProvider · the address", () => {
it("names the default view and order", () => {
window.history.replaceState(null, "", "/?page=feed");
mount();
expect([search().get("show"), search().get("sort")]).toEqual(["unwatched", "newest"]);
});
it("a bookmark of the default feed opens it, not the view the account stored since", () => {
localStorage.setItem(LS.filters, JSON.stringify({ show: "all", includeShorts: true }));
window.history.replaceState(null, "", "/?page=feed&show=unwatched&sort=newest");
mount();
expect(seen.filters!.show).toBe("unwatched");
expect(seen.filters!.includeShorts).toBe(false);
});
it("names the search once the typing pauses", async () => {
window.history.replaceState(null, "", "/?page=feed");
mount();
act(() => seen.set!({ ...seen.filters!, q: "lo" }));
act(() => seen.set!({ ...seen.filters!, q: "lofi" }));
expect(search().get("q")).toBeNull();
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
expect(search().get("q")).toBe("lofi");
});
});
+3 -2
View File
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FEED_DEFAULT_SORT } from "../../lib/api";
import { YouTubeIcon } from "../../lib/brandIcons"; import { YouTubeIcon } from "../../lib/brandIcons";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { PageHeader } from "../../components/ui/PageShell"; import { PageHeader } from "../../components/ui/PageShell";
@@ -25,9 +26,9 @@ export default function FeedHeader() {
placeholder={t("header.searchPlaceholder")} placeholder={t("header.searchPlaceholder")}
onChange={(q) => { onChange={(q) => {
// When a search first appears, rank the feed by relevance — set atomically with the // 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. // 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" } : {}) }); setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
}} }}
// Turned off on this instance, the box only filters: no Go that could only be refused. // Turned off on this instance, the box only filters: no Go that could only be refused.
+10 -3
View File
@@ -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"; import { LS, ownKey, readJSON } from "../../lib/storage";
// The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider // 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, untagged: false,
channelIds: [], channelIds: [],
q: "", q: "",
sort: "newest", sort: FEED_DEFAULT_SORT,
scope: "my", scope: "my",
includeNormal: true, includeNormal: true,
includeShorts: false, includeShorts: false,
includeLive: false, includeLive: false,
show: "unwatched", show: FEED_DEFAULT_SHOW,
}; };
/** The watch-state choices, in the order the filter offers them. */ /** The watch-state choices, in the order the filter offers them. */
+6
View File
@@ -1,6 +1,8 @@
import { YouTubeIcon } from "../../lib/brandIcons"; import { YouTubeIcon } from "../../lib/brandIcons";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef"; import type { ModuleDef } from "../../lib/moduleDef";
import { FEED_KEYS } from "../../lib/urlState";
import { YT_SEARCH_KEYS } from "../../lib/ytSearchUrlState";
import { FeedStore } from "./FeedStore"; import { FeedStore } from "./FeedStore";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
@@ -15,6 +17,10 @@ export const feedModule: ModuleDef = {
Rail, Rail,
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail), Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
Store: FeedStore, 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 // 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