fix(nav): the address follows the view through overlays, reloads and Back (review round 1)
- A URL mirror names the view again on every entry Back or Forward lands on, so a change made under a dialog, a drawer or a sub-view is not undone by the next reload. - A view pushed from the entry a reload under a dialog started from no longer inherits its overlay mark; a reload under two overlays closes onto the page in one step. - The linked player reopens over a live search, gives itself up when its list moves first, and plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve. - The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and Playlists searches end with their page; the feed always names its view and debounces its search. - Search-box keys are declared per module and kept out of the client log below TRACE. - Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the Files sort and viewer added).
This commit is contained in:
@@ -503,6 +503,10 @@ export default function Feed({
|
||||
const saved = savedOverrides[v.id];
|
||||
return saved && !(live && saved.answered) ? { ...v, saved: saved.value } : v;
|
||||
});
|
||||
// The live search's cards, in the order picked for them, and the player's queue over them.
|
||||
const ytShown = (rows: Video[]): Video[] =>
|
||||
sortYtResults(withOverrides(rows, true), ytSort.key as SortKey, ytSort.dir, lang);
|
||||
const ytQueue = (rows: Video[]): Video[] => ytShown(rows).filter((v) => v.status !== "hidden");
|
||||
const merged: Video[] = withOverrides(loaded);
|
||||
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
|
||||
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
|
||||
@@ -511,16 +515,18 @@ export default function Feed({
|
||||
// goes hidden still drops out ("visible affects").
|
||||
const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden");
|
||||
// A reload or a bookmark of the player reopens it once this list is in, the list as its queue again
|
||||
// (the feed's filters are in the address under it). Not over a live search: that is not in the
|
||||
// address, and a reload leaves it.
|
||||
// (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 && !query.isLoading,
|
||||
(id) => playerQueue.find((v) => v.id === id),
|
||||
ytActive ? ytQuery.data !== undefined || ytQuery.isError || !ytRuns : !query.isLoading,
|
||||
(id) => (ytActive ? ytQueue(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
|
||||
@@ -534,12 +540,7 @@ export default function Feed({
|
||||
// A separate data source rendered in the same cards/player. No watch-state view filter (the
|
||||
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
|
||||
if (ytActive) {
|
||||
const ytItems: Video[] = sortYtResults(
|
||||
withOverrides(ytLoaded, true),
|
||||
ytSort.key as SortKey,
|
||||
ytSort.dir,
|
||||
lang,
|
||||
);
|
||||
const ytItems: Video[] = ytShown(ytLoaded);
|
||||
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
|
||||
const ytError =
|
||||
ytQuery.error instanceof HttpError
|
||||
@@ -704,7 +705,7 @@ export default function Feed({
|
||||
<PlayerModal
|
||||
video={activeVideo.video}
|
||||
startAt={activeVideo.startAt}
|
||||
queue={ytPlayerQueue}
|
||||
queue={activeVideo.alone ? undefined : ytPlayerQueue}
|
||||
onClose={closePlayer}
|
||||
onState={onState}
|
||||
onWrite={trackSessionWrite}
|
||||
|
||||
@@ -24,6 +24,14 @@ vi.mock("./VirtualFeed", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../features/player/PlayerModal", () => ({
|
||||
default: ({ video, queue }: { video: { id: string }; queue?: { id: string }[] }) => (
|
||||
<p>
|
||||
playing {video.id} in {queue ? queue.map((v) => v.id).join(",") : "no list"}
|
||||
</p>
|
||||
),
|
||||
}));
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
@@ -48,6 +56,7 @@ beforeAll(() => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
@@ -106,14 +115,14 @@ function mount({
|
||||
search_source: source,
|
||||
search_enabled: enabled,
|
||||
});
|
||||
const tree = (count: number) => (
|
||||
const tree = (count: number, ytSearch: string | null = "lofi") => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<NavigationActionsContext.Provider value={nav}>
|
||||
<NavigationStateContext.Provider
|
||||
value={{
|
||||
page: "feed",
|
||||
channelView: null,
|
||||
ytSearch: "lofi",
|
||||
ytSearch,
|
||||
ytCount: count,
|
||||
ytSearchAsked: asked,
|
||||
}}
|
||||
@@ -124,7 +133,7 @@ function mount({
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch="lofi"
|
||||
ytSearch={ytSearch}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
@@ -133,7 +142,10 @@ function mount({
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const view = render(tree(20));
|
||||
return { showing: (count: number) => view.rerender(tree(count)) };
|
||||
return {
|
||||
showing: (count: number) => view.rerender(tree(count)),
|
||||
leave: () => view.rerender(tree(20, null)),
|
||||
};
|
||||
}
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 30));
|
||||
@@ -216,3 +228,41 @@ describe("Feed — a live search the address reopened", () => {
|
||||
expect(screen.queryByRole("button", { name: "feed.yt.runAgain" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// A player opened over the search names itself in the address too: a reload or a bookmark opens it
|
||||
// again over the search it was opened from — not later, over a list nobody linked.
|
||||
describe("Feed — a player the address names over a live search", () => {
|
||||
it("reopens over the results, them as its queue", async () => {
|
||||
window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b");
|
||||
vi.spyOn(api, "searchYoutube").mockResolvedValue({
|
||||
items: [hit("a"), hit("b")],
|
||||
next_cursor: null,
|
||||
source: "scrape",
|
||||
} as never);
|
||||
const row = vi.spyOn(api, "videoRow");
|
||||
mount({ source: "scrape", asked: false });
|
||||
expect(await screen.findByText("playing b in a,b")).toBeTruthy();
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("plays alone over a search that waits for its press", async () => {
|
||||
window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b");
|
||||
vi.spyOn(api, "videoRow").mockResolvedValue(hit("b"));
|
||||
mount({ source: "api", asked: false });
|
||||
expect(await screen.findByText("playing b in no list")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("is given up when the search is left before it opened, and leaves the address", async () => {
|
||||
window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b");
|
||||
vi.spyOn(api, "searchYoutube").mockReturnValue(new Promise(() => {}));
|
||||
vi.spyOn(api, "feed").mockResolvedValue({ items: [hit("b")], next_cursor: null } as never);
|
||||
const row = vi.spyOn(api, "videoRow").mockResolvedValue(hit("b"));
|
||||
const { leave } = mount({ source: "scrape", asked: false });
|
||||
leave();
|
||||
await settle();
|
||||
await settle();
|
||||
expect(screen.queryByText(/^playing/)).toBeNull();
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
expect(new URLSearchParams(window.location.search).get("watch")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
|
||||
import { type FeedFilters } from "../../lib/api";
|
||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../../lib/urlState";
|
||||
import { useUrlMirror } from "../../lib/useUrlMirror";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { ownKey, writeJSON } from "../../lib/storage";
|
||||
import { logFilterChange } from "../../lib/filterLog";
|
||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||
@@ -56,8 +57,11 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||
// keys are cleared from the URL (localStorage still holds them; they re-mirror on return to the
|
||||
// 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.
|
||||
// The search text once the typing pauses, as the other pages' searches: one address-bar write per
|
||||
// keystroke is churn a browser throttles.
|
||||
const nav = useNavigation();
|
||||
useUrlMirror(filterParamUpdates(urlPage(nav) === "feed" ? filters : null));
|
||||
const typedQ = useDebounced(filters.q ?? "", 300);
|
||||
useUrlMirror(filterParamUpdates(urlPage(nav) === "feed" ? { ...filters, q: typedQ } : null));
|
||||
|
||||
// A share link's filters (captured at mount) win over the account's stored view and are persisted
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export const feedModule: ModuleDef = {
|
||||
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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user