refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
//
// The channel page's video grid follows a running full-history backfill (bug hunt 2026-09, item 6).
// The rule itself — when a poll reading should refresh the grid — is pinned on the hook in
// lib/channelDetailPoll.backfill.dom.test.tsx. This pins that the PAGE runs it: deleting the hook's
// one call site left every lane green, knip included (the hook test's import kept the export "used").
//
// The page's own poll is what feeds the hook; a newer reading is handed to it here through the same
// query key the poll writes, rather than waiting out the 4 s interval. The Feed itself is stubbed —
// what matters is whether its queries are told they are stale.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, waitFor, cleanup, act } 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/navigation/navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
import ChannelPage from "./ChannelPage";
import { api, type ChannelDetail, type FeedFilters, 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();
});
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
const reading = (stored_videos: number): ChannelDetail =>
({
id: CH,
title: "LEMMiNO",
subscribed: true,
deep_requested: true,
backfill_done: false,
stored_videos,
}) as unknown as ChannelDetail;
const feedKey = qk.feed({ q: "", channel: CH } as unknown as FeedFilters);
describe("ChannelPage — the grid follows a running backfill", () => {
it("marks the channel's video grid stale when the poll sees more stored videos", async () => {
vi.spyOn(api, "channelDetail").mockResolvedValue(reading(19));
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(feedKey, { pages: [{ items: [], next_cursor: null }], pageParams: [null] });
render(
<QueryClientProvider client={qc}>
<ChannelPage
channelId={CH}
initialName="LEMMiNO"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
await waitFor(() => expect(api.channelDetail).toHaveBeenCalled());
await waitFor(() => expect(qc.getQueryData(qk.channel(CH))).toBeTruthy());
expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(false);
// The next poll tick: the background job has stored 60 more.
act(() => {
qc.setQueryData(qk.channel(CH), reading(79));
});
await waitFor(() => expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(true));
});
});
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
//
// The channel banner is served from Google's image CDN (yt3.googleusercontent.com), which refuses a
// request that carries a Referer from our origin — every time, not only under load (measured
// 2026-09-26: 6/6 refused with the page's referrer or its bare origin, 8/8 served without one). So the
// banner <img> must send none, as ui/Avatar does for the same CDN; with a referrer the banner never
// showed and the page fell back to the blank bar.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, 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: (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 }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
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";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
describe("ChannelPage — the banner", () => {
it("is requested without a referrer, which Google's image CDN refuses", async () => {
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "StarTalk",
banner_url: "https://yt3.googleusercontent.com/banner",
subscribed: true,
blocked: false,
hidden: false,
deep_requested: false,
backfill_done: true,
stored_videos: 10,
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const { container } = render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage
channelId={CH}
initialName="StarTalk"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
const banner = await waitFor(() => {
const img = container.querySelector<HTMLImageElement>('img[src^="https://yt3."]');
expect(img).not.toBeNull();
return img!;
});
expect(banner.getAttribute("referrerpolicy")).toBe("no-referrer");
});
});
@@ -0,0 +1,147 @@
// @vitest-environment jsdom
//
// The channel page's Block and Hide toggles and its Subscribe / Unsubscribe button (the same row,
// missed by the first fix) while their request runs. A
// focused button that turns natively `disabled` drops the keyboard to <body> in Chrome (measured for
// ui/Button), so a busy toggle blocks the click with aria-disabled instead — focusable, and
// still pressed only once.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach, onTestFinished } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } 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/navigation/navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
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";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
describe("ChannelPage — a busy toggle keeps the keyboard", () => {
it.each([
["channel.block", "blockChannel", true],
["channel.hide", "updateChannel", true],
["channel.subscribe", "subscribeChannel", false],
["channel.subscribedState", "unsubscribeChannel", true],
] as const)(
"%s stays focusable and fires once while its request runs",
async (label, call, subscribed) => {
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "LEMMiNO",
subscribed,
blocked: false,
hidden: false,
deep_requested: false,
backfill_done: true,
stored_videos: 10,
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const pending = vi.spyOn(api, call).mockReturnValue(new Promise(() => {}) as never);
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage
channelId={CH}
initialName="LEMMiNO"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
const button = await screen.findByRole("button", { name: label });
act(() => button.focus());
fireEvent.click(button);
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
expect(button.hasAttribute("disabled")).toBe(false);
expect(document.activeElement).toBe(button);
fireEvent.click(button);
expect(pending).toHaveBeenCalledTimes(1);
},
);
});
// The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the
// next waits, a visible <img> showed the browser's broken-image glyph (seen live). While a retry
// waits there is no <img> at all — the bar's own ground — and the retry asks past the cache.
describe("ChannelPage — the banner", () => {
it("leaves the bar empty while a failed attempt waits, then asks again past the cache", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
onTestFinished(() => {
vi.useRealTimers();
});
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "LEMMiNO",
subscribed: true,
blocked: false,
hidden: false,
deep_requested: false,
backfill_done: true,
stored_videos: 10,
banner_url: "https://yt3.example/banner",
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const { container } = render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage
channelId={CH}
initialName="LEMMiNO"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
const banner = () =>
container.querySelector<HTMLImageElement>('img[src^="https://yt3.example/banner"]');
await waitFor(() => expect(banner()).toBeTruthy());
expect(banner()!.getAttribute("referrerpolicy")).toBe("no-referrer");
fireEvent.error(banner()!);
expect(banner()).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(banner()!.getAttribute("src")).toContain("?r=1");
});
});
@@ -0,0 +1,132 @@
// @vitest-environment jsdom
//
// The owner's pick: the channel page's chrome is fixed, and its banner + identity block kept ~40% of a
// 133% screen before the first video for as long as the page was open. Once the grid scrolls they fold
// away and the name rides in the tab row; back at the top they return. The Back button is one element in
// both states, so a focused Back keeps its focus through the fold.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, screen, act, 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: (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 }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../channels/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 { CONTENT_ID } from "../../components/ui/landmarks";
import { api, type ChannelDetail, type Me } from "../../lib/api";
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();
document.getElementById(CONTENT_ID)?.remove();
vi.restoreAllMocks();
});
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
const BANNER = 'img[src^="https://yt3."]';
// The page's one scroller (PageScroller's <main>), with a scroll position jsdom does not lay out.
function scroller() {
const main = document.createElement("main");
main.id = CONTENT_ID;
let top = 0;
Object.defineProperty(main, "scrollTop", { get: () => top, configurable: true });
Object.defineProperty(main, "scrollHeight", { value: 6000, configurable: true });
Object.defineProperty(main, "clientHeight", { value: 600, configurable: true });
document.body.appendChild(main);
return (to: number) =>
act(() => {
top = to;
main.dispatchEvent(new Event("scroll"));
});
}
// Renders the page and waits for the detail to land (the banner shows).
async function renderPage() {
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "StarTalk",
banner_url: "https://yt3.googleusercontent.com/banner",
subscribed: true,
blocked: false,
hidden: false,
deep_requested: false,
backfill_done: true,
stored_videos: 10,
tag_ids: [],
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const view = render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage
channelId={CH}
initialName="StarTalk"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
await waitFor(() => expect(view.container.querySelector(BANNER)).not.toBeNull());
return view;
}
describe("ChannelPage — the header folds while the grid scrolls", () => {
it("drops the banner and puts the name in the tab row once scrolled, and brings them back at the top", async () => {
const scrollTo = scroller();
const { container } = await renderPage();
const banner = () => container.querySelector(BANNER);
expect(banner()).not.toBeNull();
expect(screen.queryByTestId("channel-folded")).toBeNull();
scrollTo(400);
expect(banner()).toBeNull();
expect(screen.getByTestId("channel-folded").textContent).toContain("StarTalk");
scrollTo(10);
expect(screen.queryByTestId("channel-folded")).not.toBeNull();
scrollTo(0);
expect(banner()).not.toBeNull();
expect(screen.queryByTestId("channel-folded")).toBeNull();
});
it("keeps a focused Back focused through the fold", async () => {
const scrollTo = scroller();
await renderPage();
const back = screen.getByTestId("channel-back");
back.focus();
scrollTo(400);
expect(screen.getByTestId("channel-folded")).toBeTruthy();
expect(document.activeElement).toBe(back);
});
});
+626
View File
@@ -0,0 +1,626 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useLiveQuery } from "../../lib/useLiveQuery";
import { channelDetailPollMs, useBackfillFeedRefresh } from "../../lib/channelDetailPoll";
import { qk } from "../../lib/queryKeys";
import { invalidateSubscription } from "../../lib/subscriptionCache";
import { useNavigationActions } from "../../features/navigation/navigationContext";
import { PriorityStepper } from "../../components/ui/PriorityStepper";
import { useChannelPriority } from "../../lib/useChannelPriority";
import {
ArrowLeft,
Ban,
Check,
ExternalLink,
Eye,
EyeOff,
History,
Loader2,
Plus,
RefreshCw,
SlidersHorizontal,
} from "lucide-react";
import Avatar from "../../components/ui/Avatar";
import Tabs from "../../components/ui/Tabs";
import { useTabPanel } from "../../components/ui/tabPanel";
import { useScrollFold } from "../../components/ui/scrollFold";
import Feed from "./Feed";
import ChannelAboutContent from "../channels/ChannelAbout";
import { PageToolbar } from "../../components/ui/PageShell";
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
import { useFeedFilters } from "./feedFiltersContext";
import { SHOW_IDS } from "./feedFiltersInit";
import { usePref } from "../../lib/usePref";
import { LS } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext";
import { notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { notifyActionError } from "../../lib/actionErrors";
import {
api,
DEFAULT_TAG_MODES,
type FeedFilters,
type Me,
type ChannelDetail,
} from "../../lib/api";
import type { FeedView } from "../../lib/feedView";
import { channelYouTubeUrl, formatDate, formatCompact } from "../../lib/format";
import { ChannelTagsControl } from "../channels/ChannelTags";
import { useChannelTagActions } from "../../lib/useChannelTagActions";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { IconLink } from "../../components/ui/LinkButton";
import { PageHeading } from "../../components/ui/Headings";
import { useTags } from "../../lib/useTags";
import RemoteImage from "../../components/ui/RemoteImage";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
// auto-ingests recent uploads in the background ("explore") so they're browsable immediately,
// and offers "load more" to page deeper — without permanently polluting everyone's library
// (the backend keeps explored videos private to this user until they subscribe).
export default function ChannelPage({
channelId,
initialName,
me,
view,
setView,
onShowInFeed,
}: {
channelId: string;
initialName?: string;
me: Me;
// The feed's view pref, passed straight through to the Feed this page renders — so its
// toolbar carries the same switcher and a change here is the same account-wide choice.
view: FeedView;
setView: (v: FeedView) => void;
// Narrow the real feed to this channel and go there. Offered only for a channel you're
// subscribed 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.
onShowInFeed: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { closeChannel } = useNavigationActions();
const qc = useQueryClient();
const confirm = useConfirm();
// Seed the channel view's content-type prefs from the global feed (read once at mount). The
// channel page is its own filter scope, but WHICH content types the user wants to see (normal /
// shorts / live-upcoming) is a genuine preference, not part of "this channel's catalog" — so we
// inherit it instead of forcing Live/Upcoming on. The channel toolbar can still toggle these.
const globalFilters = useFeedFilters();
const [tab, setTab] = useState<"videos" | "about">("videos");
const panel = useTabPanel();
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
// fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is
// where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
// The banner and the identity block fold away once the grid scrolls (owner's pick): the chrome
// is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row.
const foldable = useRef<HTMLDivElement>(null);
const folded = useScrollFold(foldable);
// A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and
// the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the
// scheduler tops up any tail on its ~10-minute ticks — then stop polling once the back-catalogue is
// in. Same queryKey as before, so mutation invalidations still hit.
const detail = useLiveQuery(qk.channel(channelId), () => api.channelDetail(channelId), {
intervalMs: channelDetailPollMs,
});
const ch = detail.data;
// The poll above is also the ONLY signal that the running backfill has written videos, so it
// drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on
// the PATCH response, before the background job has stored anything).
useBackfillFeedRefresh(ch, qc);
// Background auto-ingest ("explore") of an un-subscribed channel's uploads.
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
const [exploring, setExploring] = useState(false);
const autoTried = useRef<string | null>(null);
const runExplore = useCallback(
(token?: string | null) => {
setExploring(true);
return (
api
.exploreChannel(channelId, token ?? undefined)
.then((r) => {
setNextToken(r.next_page_token);
qc.invalidateQueries({ queryKey: qk.feed() });
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
// first visit (the GET that drove this render predated the explore that just ran).
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
})
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty
// channel page that looked like a channel with no videos.
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel")))
.finally(() => setExploring(false))
);
},
[channelId, qc, t],
);
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped when videos are
// already present (a revisit, or a subscribed channel whose uploads the scheduler backfills).
useEffect(() => {
if (!ch) return;
if (autoTried.current === channelId) return;
if (ch.subscribed || ch.blocked) return;
if (ch.explored && ch.stored_videos > 0) return;
autoTried.current = channelId;
// eslint-disable-next-line react-hooks/set-state-in-effect -- a one-off INGEST of an unsubscribed channel's uploads on first visit — a network side effect, not state. The autoTried ref keyed on the channel id is what makes it once per channel.
void runExplore();
}, [ch, channelId, runExplore]);
const block = useMutation({
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
},
// No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide
// reporter names the failure the error dialog does not cover (a 404 for a channel gone).
meta: { failTitle: "errors.writeFailed.blockChannel" },
});
// "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}).
// Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore.
const toggleHidden = useMutation({
mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }),
meta: { failTitle: "errors.writeFailed.hideChannel" },
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
},
});
const subscribe = useMutation({
mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => {
invalidateSubscription(qc, channelId);
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
},
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
// here, so no Connect button — but the user at least sees why it didn't work).
onError: (err) => notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed")),
});
const unsubscribe = useMutation({
mutationFn: () => api.unsubscribeChannel(channelId),
onSuccess: () => invalidateSubscription(qc, channelId),
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
});
// "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's
// uploads (video_count is YouTube's raw total), so flipping deep_requested queues the full
// back-catalog pager. Same trigger the channel manager uses.
const deepBackfill = useMutation({
mutationFn: () => api.updateChannel(channelId, { deep_requested: true }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "info", message: t("channel.fullHistoryQueued") });
},
onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")),
});
// Feed priority, editable here with the same stepper + debounced-flush the manager uses (shared
// hook so the two can't drift). Optimistic on this channel's detail cache; a success re-sorts the
// manager list + feed, a failure reverts by refetching the detail.
const { setPriority, bumpPriority } = useChannelPriority({
read: (id) => qc.getQueryData<ChannelDetail>(qk.channel(id))?.priority,
optimistic: (id, next) =>
qc.setQueryData<ChannelDetail>(qk.channel(id), (old) =>
old ? { ...old, priority: next } : old,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.feed() });
},
onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
});
// Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic
// on this channel's detail cache; a failed attach/detach reverts by refetching, and a success
// invalidates the manager list so a tag added here shows there too.
// Only fetch the tag catalog when the editor will actually render (subscribed) — an unsubscribed
// channel page never shows the control, so the request would be thrown away.
const tagsQuery = useTags({ enabled: !!ch?.subscribed });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const { toggleTag, createAndAttachTag } = useChannelTagActions({
optimisticToggle: (id, tagId, attach) =>
qc.setQueryData<ChannelDetail>(qk.channel(id), (old) =>
old
? {
...old,
tag_ids: attach ? [...old.tag_ids, tagId] : old.tag_ids.filter((x) => x !== tagId),
}
: old,
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.channels() }),
onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
});
const onUnsubscribe = async () => {
const ok = await confirm({
title: t("channel.unsubTitle"),
message: t("channel.unsubBody", { name: ch?.title ?? channelId }),
confirmLabel: t("channel.unsubscribe"),
danger: true,
});
if (ok) unsubscribe.mutate();
};
// Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so
// the explored, per-user-private videos show — the backend gates them to this explorer).
// The watch state is the one filter a channel page remembers (owner's call): a fresh page shows
// what is still unwatched, and a change sticks — across channels, reloads and devices.
const [show, setShow] = usePref<string>("channelShow", "unwatched", {
localKey: LS.channelShow,
parse: (v) => (typeof v === "string" && SHOW_IDS.includes(v) ? v : "unwatched"),
});
const [ownFilters, setOwnFilters] = useState<FeedFilters>(() => ({
tags: [],
tagModes: DEFAULT_TAG_MODES,
q: "",
sort: "newest",
scope: "all",
librarySource: "all",
// Content-type prefs inherited from the global feed (seeded once) — no longer forced.
includeNormal: globalFilters.includeNormal,
includeShorts: globalFilters.includeShorts,
includeLive: globalFilters.includeLive,
show: "unwatched",
channelIds: [channelId],
}));
const filters: FeedFilters = { ...ownFilters, show };
const setFilters = (next: FeedFilters) => {
if (next.show !== show) setShow(next.show);
setOwnFilters(next);
};
const name = ch?.title ?? initialName ?? channelId;
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
const joined = ch?.published_at ? formatDate(ch.published_at, i18n.language) : null;
// Handle + stats on one compact meta line under the name (saves the separate stats row).
const metaParts = [
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
ch?.subscriber_count != null
? t("channel.subscribers", { formatted: formatCompact(ch.subscriber_count, lang) })
: null,
// Show "stored / total" when we only hold a subset of a subscribed channel's uploads, so the
// gap between the browsable videos and YouTube's raw count is honest rather than looking like
// missing data (the "get full history" button pulls the rest).
ch?.video_count != null
? ch.subscribed && !ch.backfill_done && ch.stored_videos < ch.video_count
? t("channel.videoCountStored", { stored: ch.stored_videos, total: ch.video_count })
: t("channel.videoCount", { count: ch.video_count })
: null,
ch?.total_view_count != null
? t("channel.totalViews", { formatted: formatCompact(ch.total_view_count, lang) })
: null,
// Per-type breakdown (moved here off the manager table's old N/S/L column). Only meaningful
// once we actually hold some of the channel's videos.
ch && ch.stored_videos > 0
? t("channel.videoTypes", {
normal: ch.count_normal,
short: ch.count_short,
live: ch.count_live,
})
: null,
joined ? t("channel.joined", { date: joined }) : null,
].filter(Boolean) as string[];
// The page's name and its state, shown in the identity block or, folded, in the tab row.
const heading = (
<PageHeading
data-testid="channel-title"
className={`${folded ? "text-base" : "text-xl"} font-semibold truncate`}
>
{name}
</PageHeading>
);
const stateBadge = ch?.blocked ? (
<span className="shrink-0 text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
{t("channel.blockedBadge")}
</span>
) : (
ch?.explored &&
!ch?.subscribed && (
<span className="shrink-0 text-xs px-2 py-0.5 rounded-full bg-warn/15 text-warn">
{t("channel.exploringBadge")}
</span>
)
);
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
// tabs/toolbar stay put and only the video grid scrolls; the banner/identity fold away meanwhile.
const bannerSrc = ch?.banner_url ? `${ch.banner_url}=w1707` : null;
return (
<>
<PageToolbar>
<div className="relative">
{!folded && (
<div ref={foldable}>
{/* Banner — OPTION C: inset, rounded card (not full-bleed) */}
<div className="px-4 sm:px-6 pt-3">
{/* Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
low default size, so request a crisp wide version (=w1707) and object-cover the
desktop "safe area" — the centre 2560×423 (~6:1) band. With no banner (or a dead one)
the bar keeps the SAME height, so the overlapping avatar and the Back button keep
their room (a short bar let the -mt-8 avatar ride up into the Back button). */}
<div
className="w-full overflow-hidden rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
>
<RemoteImage
src={bannerSrc}
alt=""
className="w-full h-full object-cover object-center"
/>
</div>
</div>
<div className="px-4 sm:px-6">
{/* Identity. relative+z-base so the avatar that overlaps up into the banner paints
ABOVE it (the banner's positioned container would otherwise cover it). */}
<div className="relative z-base flex items-end gap-4 -mt-8">
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
/>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
{heading}
{stateBadge}
</div>
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
{metaParts.map((part, i) => (
<span key={i}>
{i > 0 && <span className="mr-1.5">·</span>}
{part}
</span>
))}
</div>
</div>
</div>
</div>
</div>
)}
<div className="px-4 sm:px-6">
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
</div>
)}
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
corner: there are only ever two tabs, so there's room, and it keeps the things you click
within a few pixels of each other instead of across the page. */}
{/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and
the whole page scrolled sideways. The tag lane takes what is left and folds into "+N". */}
<div
className={`flex flex-wrap items-center gap-2 border-b border-border pb-2 ${folded ? "pt-3" : "mt-3"}`}
>
{/* One Back button in both states, so a focused Back keeps its focus when the header folds:
over the banner's corner (absolute to the chrome) while unfolded, first in this row folded. */}
<Button
variant={folded ? "outline" : "media"}
data-testid="channel-back"
icon={<ArrowLeft />}
onClick={closeChannel}
className={folded ? "" : "absolute top-5 left-7 sm:left-9 z-base backdrop-blur-xs"}
>
{t("channel.back")}
</Button>
{folded && (
<div
className="flex min-w-0 max-w-[40%] items-center gap-2"
data-testid="channel-folded"
>
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
className="w-8 h-8 rounded-full shrink-0"
/>
{heading}
{stateBadge}
</div>
)}
<Tabs
idBase={panel.idBase}
className="flex gap-1"
active={tab}
onChange={(id) => setTab(id as "videos" | "about")}
tabs={[
{ id: "videos", label: t("channel.tabVideos") },
{ id: "about", label: t("channel.tabAbout") },
]}
/>
{/* Unfolded, the actions share the tabs' line and the tag lane gives way ("+N"). Folded, Back
and the name take that room, so at a tablet width the actions wrap onto a line of their
own rather than push the page sideways (min-w-0 let them overflow: 1072 px at 1024). */}
<div className={`ml-2 flex flex-1 items-center gap-2 ${folded ? "" : "min-w-0"}`}>
<IconLink
href={ytUrl}
target="_blank"
rel="noreferrer"
label={t("channels.row.openOnYouTube")}
size="md"
variant="outline"
>
<ExternalLink />
</IconLink>
{ch?.subscribed && (
<IconButton
label={t("channel.showInFeed")}
size="md"
variant="outline"
onClick={onShowInFeed}
>
<SlidersHorizontal className="w-4 h-4" />
</IconButton>
)}
{ch?.subscribed && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
<span className="text-xs text-muted">{t("channels.cols.priority")}</span>
<PriorityStepper
priority={ch.priority}
onPriority={(d) => bumpPriority(channelId, d)}
onSet={(v) => setPriority(channelId, v)}
size="md"
hint={t("channels.row.priorityHint")}
/>
</div>
)}
{/* Only the recent subset is stored → offer to pull the channel's full back-catalogue.
Once queued (deep_requested) it shows as loading; hidden once the backfill is done.
While loading, the label carries the live stored/total count (the detail poll refreshes
it) so a large channel's paced tail reads as visible progress rather than a stuck
spinner. */}
{ch?.subscribed && !ch.backfill_done && (
<Button
variant="outline"
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
disabled={ch.deep_requested || deepBackfill.isPending}
loading={ch.deep_requested}
icon={<History className="w-4 h-4" />}
title={
ch.deep_requested
? t("channel.fullHistoryLoadingHint")
: t("channel.fullHistory")
}
>
<span className="hidden sm:inline whitespace-nowrap">
{!ch.deep_requested
? t("channel.fullHistory")
: ch.video_count != null && ch.stored_videos < ch.video_count
? t("channel.fullHistoryProgress", {
stored: ch.stored_videos,
total: ch.video_count,
})
: t("channel.fullHistoryLoading")}
</span>
</Button>
)}
<IconButton
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
// light up on hover, as if a click would do something.
size="md"
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
onClick={() => block.mutate()}
loading={block.isPending}
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
>
<Ban />
</IconButton>
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && (
<IconButton
size="md"
variant={ch.hidden ? "outline-accent" : "outline"}
onClick={() => toggleHidden.mutate()}
loading={toggleHidden.isPending}
label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
>
{ch.hidden ? <EyeOff /> : <Eye />}
</IconButton>
)}
{!ch?.blocked &&
(ch?.subscribed ? (
<Button
variant="outline"
onClick={onUnsubscribe}
loading={unsubscribe.isPending}
title={t("channel.unsubscribe")}
icon={<Check className="w-4 h-4" />}
>
{t("channel.subscribedState")}
</Button>
) : (
<Button
variant="outline"
onClick={() => subscribe.mutate()}
loading={subscribe.isPending}
icon={<Plus className="w-4 h-4" />}
>
{t("channel.subscribe")}
</Button>
))}
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
otherwise), and personal tags are for your subscriptions — so only offer it on a
channel you actually follow. */}
{ch?.subscribed && (
<div className="min-w-0 flex-1">
<ChannelTagsControl
userTags={userTags}
attachedIds={ch.tag_ids}
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(channelId, name)}
// The header row's leftover width: tags inline, "+N" only when they do not fit.
bounded={false}
/>
</div>
)}
</div>
</div>
</div>
</div>
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome,
just under the tabs. */}
<div ref={setChromeSlot} />
</PageToolbar>
{/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override;
only the grid (and About) render here, in the scroller. */}
<PageToolbarSlot.Provider value={chromeSlot}>
<div {...panel.panelProps(tab)}>
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<Button
// Busy blocks the click, not the focus (see Block above).
variant="outline"
icon={<RefreshCw />}
loading={exploring}
onClick={() => runExplore(nextToken)}
>
{t("channel.loadMore")}
</Button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
</div>
)}
</div>
</PageToolbarSlot.Provider>
</>
);
}
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
//
// The owner's call: a freshly opened channel page shows what is still unwatched, and a watch state the
// viewer picks there sticks — a reload (a fresh mount, seeded from the stored preference) keeps it.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, act } 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 }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
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("../channels/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;
});
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
function mount(preferences: Record<string, unknown> = {}) {
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);
const save = 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}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
return save;
}
describe("ChannelPage — the watch state", () => {
it("shows only the unwatched videos on a freshly opened page", () => {
mount();
expect(feed.last?.filters.show).toBe("unwatched");
});
it("stores a picked watch state", () => {
const save = mount();
act(() => feed.last!.setFilters({ ...feed.last!.filters, show: "all" }));
expect(feed.last?.filters.show).toBe("all");
expect(save).toHaveBeenCalledWith({ channelShow: "all" });
});
it("keeps the stored watch state after a reload", () => {
mount({ channelShow: "watched" });
expect(feed.last?.filters.show).toBe("watched");
});
it("falls back to unwatched for a stored value it does not know", () => {
mount({ channelShow: "bogus" });
expect(feed.last?.filters.show).toBe("unwatched");
});
});
@@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import type { FeedFilters } from "../../lib/api";
import { useChannelNames } from "../../lib/useChannelNames";
import Combobox, { type ComboboxOption } from "../../components/ui/Combobox";
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
// the panel had a remove button for something nothing could apply.
//
// It owns its channel query rather than letting the Sidebar hold it: the Sidebar is mounted for the
// whole feed page, but SidePanel only renders its children once the panel is open, so keeping the
// fetch here means a subscription list of a few hundred loads when the picker is actually looked at.
export default function ChannelPicker({
filters,
setFilters,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const { query: channelsQuery, names, pending } = useChannelNames();
const all = channelsQuery.data ?? [];
// The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the
// options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later
// narrowing ever drops it from the visible rows, and names a reloaded filter before the list lands.
const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id }));
return (
<Combobox
label={t("sidebar.channelSearch")}
placeholder={t("sidebar.channelSearch")}
options={options}
selected={filters.channelIds}
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
loading={channelsQuery.isLoading}
error={channelsQuery.isError && !channelsQuery.data ? t("common.loadError") : undefined}
onRetry={() => void channelsQuery.refetch()}
emptyText={t("sidebar.noChannelMatch")}
selectedLabels={names}
labelsPending={pending}
/>
);
}
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
//
// A native date field reports a complete, valid date on every keystroke of its year, so
// typing 2026 applied 0002, 0020, 0202 and 2026 — four feed refetches for one year.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
import DateFilterSelect from "./DateFilterSelect";
import { type FeedFilters } from "../../lib/api";
afterEach(cleanup);
function mount(filters: Partial<FeedFilters>) {
const setFilters = vi.fn();
render(
<DateFilterSelect
filters={filters as FeedFilters}
setFilters={setFilters}
presets={[{ days: 7, key: "1week" }]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /sidebar\.datePresetLabel/ }));
const [from] = document.querySelectorAll<HTMLInputElement>('input[type="date"]');
return { setFilters, from: from! };
}
describe("a custom range bound", () => {
it("is applied once, when the typed year becomes a plausible upload year", () => {
const { setFilters, from } = mount({ dateTo: "2026-09-01" });
for (const day of ["0002-01-15", "0020-01-15", "0202-01-15", "2026-01-15"]) {
fireEvent.change(from, { target: { value: day } });
}
expect(setFilters).toHaveBeenCalledTimes(1);
expect(setFilters.mock.calls[0]![0]).toMatchObject({ dateFrom: "2026-01-15" });
});
it("is cleared when the field is left empty, and kept when it is left half-typed", () => {
const { setFilters, from } = mount({ dateFrom: "2026-01-15" });
fireEvent.change(from, { target: { value: "0202-01-15" } });
fireEvent.blur(from);
expect(setFilters).not.toHaveBeenCalled();
expect(from.value).toBe("2026-01-15");
fireEvent.change(from, { target: { value: "" } });
expect(setFilters).not.toHaveBeenCalled();
fireEvent.blur(from);
expect(setFilters).toHaveBeenCalledTimes(1);
expect(setFilters.mock.calls[0]![0]).toMatchObject({ dateFrom: undefined });
});
});
@@ -0,0 +1,212 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays } from "lucide-react";
import { MenuCheck } from "../../components/ui/MenuCheck";
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { type FeedFilters } from "../../lib/api";
import {
deriveDateKey,
isCommittableDay,
selectDateChip,
type DatePreset,
} from "../../lib/dateFilter";
import { formatDate } from "../../lib/format";
import { Button } from "../../components/ui/Button";
import { SelectTrigger } from "../../components/ui/SelectTrigger";
import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
// reveals the from/to fields in the popover footer (reveal-on-select) and the trigger then reads the
// chosen range; the presets and Any time close on pick. Selection logic is the pure lib/dateFilter so
// it stays testable without mounting this. The popover hosts free-text (date) inputs, so it opens as a
// `hostsInput` menu and the inputs stop non-Escape keys from reaching the list navigation.
// yyyy-mm-dd -> a local Date at midnight (split so it isn't parsed as UTC, which would shift a day).
function parseDay(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}
export default function DateFilterSelect({
filters,
setFilters,
presets,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
presets: readonly DatePreset[];
}) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const [customOpen, setCustomOpen] = useState<boolean>(!!(filters.dateFrom || filters.dateTo));
// If the range is cleared elsewhere (chip-row Clear all / removing the date chip) while this popover
// is closed, drop the custom reveal so the trigger reverts to "Any time" instead of lingering on
// "Custom range" with empty fields. Guarded on !open so it never closes a freshly-picked Custom the
// user hasn't filled in yet — that stays open until they close it (handled by onOpenChange below).
// useLayoutEffect (not useEffect) so the reset commits BEFORE paint — otherwise the trigger flashes
// one stale "Custom range" frame between the external clear and this reset.
useLayoutEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- the reveal is NOT derivable from the range — the user opens it on an empty range and fills it in afterwards. This undoes it only when the range is cleared from OUTSIDE while the popover is shut, and it is a layout effect so the trigger never paints a stale "Custom range".
if (!open && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
}, [open, customOpen, filters.dateFrom, filters.dateTo]);
const key = deriveDateKey(filters, customOpen, presets);
const fmt = (s: string) => {
return parseDay(s) ? formatDate(s, i18n.language) : s;
};
const rangeSummary = () => {
const { dateFrom: f, dateTo: to } = filters;
if (f && to) return `${fmt(f)} – ${fmt(to)}`;
if (f) return t("sidebar.date.fromShort", { date: fmt(f) });
if (to) return t("sidebar.date.untilShort", { date: fmt(to) });
return t("sidebar.date.customRange");
};
const summary =
key === ""
? t("sidebar.date.anyTime")
: key === "custom"
? rangeSummary()
: t("sidebar.datePreset." + presets.find((p) => String(p.days) === key)!.key);
const options = [
{ value: "any", label: t("sidebar.date.anyTime") },
...presets.map((p) => ({ value: String(p.days), label: t("sidebar.datePreset." + p.key) })),
{ value: "custom", label: t("sidebar.date.customRange") },
];
const pop = useAnchoredPopover({
open,
onOpenChange: (next) => {
setOpen(next);
// Opened Custom but committed no range → drop the reveal on close so it doesn't stick.
if (!next && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
},
mode: "menu",
placement: "bottom-start",
hostsInput: true,
});
const { setReference } = pop.refs;
const pick = (value: string) => {
const change = selectDateChip(value, filters);
setCustomOpen(change.customOpen);
setFilters(change.filters);
if (value !== "custom") setOpen(false); // Custom stays open to reveal the range fields
};
return (
<>
<SelectTrigger
context="form"
open={open}
icon={<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />}
ref={setReference}
{...pop.getReferenceProps()}
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
>
{summary}
</SelectTrigger>
<PopoverSurface
popover={pop}
ariaLabel={t("sidebar.datePresetLabel")}
className={`${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`}
>
<div role="group" aria-label={t("sidebar.datePresetLabel")}>
{options.map((o) => {
const on = o.value === key || (o.value === "any" && key === "");
return (
<PopoverItem
key={o.value}
role="menuitemradio"
checked={on}
onSelect={() => pick(o.value)}
label={o.label}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
<MenuCheck kind="one" on={on} />
<span className="flex-1 truncate">{o.label}</span>
</PopoverItem>
);
})}
</div>
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. */}
{key === "custom" && (
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.from")}</span>
<DayField
value={filters.dateFrom}
onCommit={(v) =>
setFilters({ ...filters, dateFrom: v, maxAgeDays: undefined, year: undefined })
}
/>
</label>
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.to")}</span>
<DayField
value={filters.dateTo}
onCommit={(v) =>
setFilters({ ...filters, dateTo: v, maxAgeDays: undefined, year: undefined })
}
/>
</label>
{(filters.dateFrom || filters.dateTo) && (
<Button
variant="link-muted"
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
className="text-2xs self-end"
>
{t("sidebar.clearDates")}
</Button>
)}
</div>
)}
</PopoverSurface>
</>
);
}
// One bound of the custom range. What the field shows is a draft while it has focus (`draft ?? value`):
// the bound is applied only once the draft is a plausible day (`isCommittableDay` says why), so typing a
// year no longer refetches the feed per digit; a date picked from the calendar applies at once. Leaving
// the field settles it: emptied, the bound is cleared; half-typed or implausible, it shows the bound again.
// It stops non-Escape keys so the date-segment arrows act on the field, not the menu's list navigation.
function DayField({
value,
onCommit,
}: {
value: string | undefined;
onCommit: (day: string | undefined) => void;
}) {
const [draft, setDraft] = useState<string>();
return (
<Input
size="sm"
type="date"
value={draft ?? value ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) => {
const day = e.target.value;
setDraft(day);
if (isCommittableDay(day) && day !== value) onCommit(day);
}}
onBlur={() => {
if (draft === "" && value) onCommit(undefined);
setDraft(undefined);
}}
/>
);
}
@@ -0,0 +1,122 @@
// @vitest-environment jsdom
//
// The count pill while a new search's count loads. keepPreviousData holds the last filter's count, and a
// broad word's count takes seconds ("a": ~3 s on the prod-sized library): the pill showed the old
// number as if it were the new search's. It now dims and is aria-busy until the new count lands.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, 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: () => null }));
vi.mock("../../features/filters/SelectedViewChip", () => ({ default: () => null }));
vi.mock("../../features/filters/ActiveFilterChips", () => ({ default: () => null }));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import type { ReactElement } from "react";
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
// The pill lives in the toolbar, which portals into the shell's band: give it one.
const slot = document.body.appendChild(document.createElement("div"));
const feed = (q: string, qc: QueryClient): ReactElement => (
<QueryClientProvider client={qc}>
<PageToolbarSlot.Provider value={slot}>
<Feed
filters={{ ...FILTERS, q }}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</PageToolbarSlot.Provider>
</QueryClientProvider>
);
describe("Feed — the count while a new search's count loads", () => {
it("dims the previous count and marks it busy until the new one lands", async () => {
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
let settle: (v: unknown) => void = () => {};
const summary = vi
.spyOn(api, "feedSummary")
.mockImplementation(((f: FeedFilters) =>
f.q === "kitchen"
? Promise.resolve({ count: 1937, facets: {} })
: new Promise((r) => (settle = r))) as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
const pill = () => screen.getByTestId("item-count");
const view = render(feed("kitchen", qc));
await waitFor(() => expect(pill().getAttribute("title")).toBe("1,937"));
expect(pill().getAttribute("aria-busy")).toBeNull();
view.rerender(feed("a", qc));
await waitFor(() => expect(pill().getAttribute("aria-busy")).toBe("true"));
expect(pill().getAttribute("title")).toBe("1,937"); // still the old figure, now marked as such
// The pill turns busy in the render that changes the search, and the count is asked for in an
// effect after it: answer only once it has been asked, or the answer goes to no one.
await waitFor(() => expect(summary).toHaveBeenCalledWith(expect.objectContaining({ q: "a" })));
settle({ count: 178991, facets: {} });
await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991"));
expect(pill().getAttribute("aria-busy")).toBeNull();
});
it("says the count failed instead of showing the empty box of a first load", async () => {
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
vi.spyOn(api, "feedSummary").mockImplementation(((f: FeedFilters) =>
f.q === "kitchen"
? Promise.resolve({ count: 1937, facets: {} })
: Promise.reject(new Error("boom"))) as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
const pill = () => screen.getByTestId("item-count");
const view = render(feed("kitchen", qc));
await waitFor(() => expect(pill().getAttribute("title")).toBe("1,937"));
view.rerender(feed("a", qc));
await waitFor(() => expect(pill().getAttribute("title")).toBe("common.countFailed"));
expect(pill().getAttribute("aria-busy")).toBeNull();
});
});
@@ -0,0 +1,116 @@
// @vitest-environment jsdom
//
// A next page that fails. Retry is off and the read-ahead re-runs whenever a fetch settles, so a failed
// page was requested again once a round-trip, forever, while the end of the list was in view, and
// nothing said it had failed. The read-ahead now stops at a failed page, and a row says
// so and retries on a click. VirtualFeed is replaced by what Feed hands it: the read-ahead's gate and a
// button standing in for the read-ahead itself.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent } 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: ({
hasNextPage,
fetchNextPage,
}: {
hasNextPage: boolean;
fetchNextPage: (o: { cancelRefetch: boolean }) => unknown;
}) => (
<div>
<span data-testid="has-next">{String(hasNextPage)}</span>
<button onClick={() => void fetchNextPage({ cancelRefetch: false })}>read ahead</button>
</div>
),
}));
vi.mock("../../features/filters/SelectedViewChip", () => ({ default: () => null }));
vi.mock("../../features/filters/ActiveFilterChips", () => ({ default: () => null }));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters } 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();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
describe("Feed — a next page that fails", () => {
it("stops the read-ahead, says so, and loads the page on a retry", async () => {
const page = (n: number, next: string | null) => ({
items: [
{ id: `v${n}`, title: `v${n}`, channel_id: "UCx", published_at: "2026-01-01T00:00:00Z" },
],
next_cursor: next,
});
let failNext = true;
const feed = vi
.spyOn(api, "feed")
.mockImplementation(((_f: FeedFilters, cursor: string | null) =>
cursor === null
? Promise.resolve(page(1, "c2"))
: failNext
? Promise.reject(new Error("boom"))
: Promise.resolve(page(2, null))) as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 2, facets: {} } as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
await waitFor(() => expect(screen.getByTestId("has-next").textContent).toBe("true"));
fireEvent.click(screen.getByText("read ahead"));
await waitFor(() => expect(screen.getByText("common.loadMoreFailed")).toBeTruthy());
expect(screen.getByTestId("has-next").textContent).toBe("false"); // the read-ahead stands down
const calls = feed.mock.calls.length;
failNext = false;
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await waitFor(() => expect(feed.mock.calls.length).toBe(calls + 1));
await waitFor(() => expect(screen.queryByText("common.loadMoreFailed")).toBeNull());
});
});
@@ -0,0 +1,219 @@
// @vitest-environment jsdom
//
// The feed's optimistic layer: a card shows what was just written to it until the server's own answer
// lands. Which answers count is the rule pinned here: a refetch that LANDS (for what was written
// before it set out), a filter change; never an append of the next page. A mark made while a refetch
// was in flight is newer than its answer, and dropping it put a card marked watched back into an
// Unwatched view. The grid is a stub that shows what Feed hands it.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } 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,
onState,
fetchNextPage,
}: {
items: { id: string; status: string }[];
onState: (id: string, status: string) => void;
fetchNextPage: (o: object) => unknown;
}) => (
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>
<span>{`${v.id}:${v.status}`}</span>
<button onClick={() => onState(v.id, "watched")}>watch {v.id}</button>
<button onClick={() => onState(v.id, "hidden")}>hide {v.id}</button>
</li>
))}
<button onClick={() => void fetchNextPage({})}>more</button>
</ol>
),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, 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;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "all",
};
const video = (id: string) =>
({
id,
title: id,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds: 0,
duration_seconds: 600,
published_at: "2026-01-01T00:00:00Z",
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
// The server never takes the mark in these tests: whatever a list shows as "watched" is the override.
const page = (ids: string[], next: string | null = null) =>
({ items: ids.map(video), next_cursor: next }) as never;
function mount(feed: ReturnType<typeof vi.fn>, setState?: () => Promise<unknown>) {
vi.spyOn(api, "feed").mockImplementation(feed as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
if (setState) vi.spyOn(api, "setState").mockImplementation(setState as never);
else vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
const ui = (filters: FeedFilters) => (
<QueryClientProvider client={qc}>
<Feed
filters={filters}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>
);
const r = render(ui(FILTERS));
return { qc, rerender: (f: FeedFilters) => r.rerender(ui(f)) };
}
const settle = () => act(() => new Promise((r) => setTimeout(r, 20)));
describe("Feed — optimistic overrides", () => {
it("give way to a refetch that lands after them", async () => {
let answer: (v: unknown) => void = () => {};
const feed = vi
.fn()
.mockResolvedValueOnce(page(["v1"]))
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
const { qc } = mount(feed);
fireEvent.click(await screen.findByText("watch v1"));
await screen.findByText("v1:watched");
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
await settle();
await act(async () => answer(page(["v1"])));
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
});
it("keep a mark made while the refetch was in flight", async () => {
let answer: (v: unknown) => void = () => {};
const feed = vi
.fn()
.mockResolvedValueOnce(page(["v1"]))
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
const { qc } = mount(feed);
await screen.findByText("v1:new");
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
await settle();
fireEvent.click(screen.getByText("watch v1"));
await screen.findByText("v1:watched");
// The answer the server gave before it heard of the mark.
await act(async () => answer(page(["v1"])));
await settle();
expect(screen.getByText("v1:watched")).toBeTruthy();
});
it("keep a mark sent before the refetch set out but not answered by then", async () => {
// The server may read the list before it commits the mark: that answer is older than the mark.
let answer: (v: unknown) => void = () => {};
let commit: (v: unknown) => void = () => {};
const feed = vi
.fn()
.mockResolvedValueOnce(page(["v1"]))
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
const { qc } = mount(feed, () => new Promise((r) => (commit = r)));
fireEvent.click(await screen.findByText("watch v1"));
await screen.findByText("v1:watched");
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
await settle();
await act(async () => answer(page(["v1"])));
await settle();
expect(screen.getByText("v1:watched")).toBeTruthy();
await act(async () => commit({ ok: true }));
});
it("are taken back by a failed write only while no later write stands", async () => {
// Marked watched, then hidden before the first answer came back: the refused mark must not take
// the hide with it and put the card back in the view.
const writes: { resolve: (v: unknown) => void; reject: (e: unknown) => void }[] = [];
mount(
vi.fn().mockResolvedValue(page(["v1", "v2"])),
() => new Promise((resolve, reject) => writes.push({ resolve, reject })),
);
fireEvent.click(await screen.findByText("watch v1"));
fireEvent.click(await screen.findByText("hide v1"));
await waitFor(() => expect(screen.queryByText(/^v1:/)).toBeNull());
await act(async () => writes[0]?.reject(new Error("refused")));
await settle();
expect(screen.queryByText(/^v1:/)).toBeNull();
await act(async () => writes[1]?.reject(new Error("refused")));
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
});
it("survive an append of the next page", async () => {
let answer: (v: unknown) => void = () => {};
const feed = vi
.fn()
.mockResolvedValueOnce(page(["v1"], "c1"))
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
mount(feed);
fireEvent.click(await screen.findByText("watch v1"));
await screen.findByText("v1:watched");
fireEvent.click(screen.getByText("more"));
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
await settle();
await act(async () => answer(page(["v2"])));
await screen.findByText("v2:new");
await settle();
expect(screen.getByText("v1:watched")).toBeTruthy();
});
it("are dropped by a change of the filters", async () => {
const { rerender } = mount(vi.fn().mockResolvedValue(page(["v1"])));
fireEvent.click(await screen.findByText("watch v1"));
await screen.findByText("v1:watched");
rerender({ ...FILTERS });
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
});
});
@@ -0,0 +1,255 @@
// @vitest-environment jsdom
//
// A video marked watched in the player (by hand, or by the player at its end) left an Unwatched view
// at once, then came back as "new" when the player closed, until a reload (owner, on a channel page).
// The player's closing position patch writes into the cached feed, which bumped `dataUpdatedAt`; the
// feed took that for fresh server data and dropped its optimistic overrides, and nothing refetched.
// The grid and the player are stubs that show what Feed hands them.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } 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() }),
}));
// What the grid was handed for its card menu's channel-filter row.
const grid: { channelFilter?: { ids: string[]; set: (ids: string[]) => void } } = {};
vi.mock("./VirtualFeed", () => ({
default: ({
items,
onOpen,
channelFilter,
}: {
items: { id: string }[];
onOpen: (v: unknown) => void;
channelFilter?: { ids: string[]; set: (ids: string[]) => void };
}) => (
(grid.channelFilter = channelFilter),
(
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>
{v.id}
<button onClick={() => onOpen(v)}>open {v.id}</button>
</li>
))}
</ol>
)
),
}));
// The player's progress hook marks a clip closed near its end watched from its unmount cleanup:
// `watchOnUnmount` stands in for that, and `flush` for its closing checkpoint, which it tracks.
const player = vi.hoisted(() => ({
watchOnUnmount: false,
flush: null as Promise<unknown> | null,
}));
vi.mock("../../features/player/PlayerModal", async () => {
const { useEffect } = await import("react");
const { trackProgressFlush } = await import("../../lib/progressCache");
return {
default: function Player({
video,
onState,
onClose,
}: {
video: { id: string };
onState: (id: string, status: string) => void;
onClose: () => void;
}) {
useEffect(
() => () => {
if (player.watchOnUnmount) onState(video.id, "watched");
if (player.flush) trackProgressFlush(player.flush);
},
[video.id, onState],
);
return (
<div>
<button onClick={() => onState(video.id, "watched")}>mark</button>
<button onClick={onClose}>close</button>
</div>
);
},
};
});
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { patchCachedProgress } from "../../lib/progressCache";
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();
player.watchOnUnmount = false;
player.flush = null;
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: ["UCx"],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const video = (status: string, position = 0) =>
({
id: "v1",
title: "v1",
channel_id: "UCx",
channel_title: "Channel",
status,
position_seconds: position,
duration_seconds: 600,
published_at: "2026-01-01T00:00:00Z",
watch_url: "https://www.youtube.com/watch?v=v1",
}) as unknown as Video;
function mount({
channelScoped = true,
setFilters = () => {},
}: { channelScoped?: boolean; setFilters?: (f: FeedFilters) => void } = {}) {
const feed = vi
.spyOn(api, "feed")
.mockResolvedValueOnce({ items: [video("new")], next_cursor: null } as never)
// The server after the mark: watched, so an Unwatched view leaves it out.
.mockResolvedValue({ items: [], next_cursor: null } as never);
const summary = vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={setFilters}
view="cards"
setView={() => {}}
canRead
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped={channelScoped}
/>
</QueryClientProvider>,
);
return { qc, feed, summary };
}
describe("Feed — a video marked watched in the player", () => {
it("stays out of an Unwatched view when the player writes its position into the cache", async () => {
const { qc } = mount();
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark"));
await waitFor(() => expect(screen.queryByText("open v1")).toBeNull());
// What the player does as it closes: the server's stored position, written into the cached list.
act(() => patchCachedProgress(qc, new Map([["v1", 10]])));
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByText("open v1")).toBeNull();
});
it("refetches the list and its count by the view's filters when the player closes", async () => {
const { feed, summary } = mount();
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark"));
const before = { feed: feed.mock.calls.length, summary: summary.mock.calls.length };
fireEvent.click(screen.getByText("close"));
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before.feed));
await waitFor(() => expect(summary.mock.calls.length).toBeGreaterThan(before.summary));
// The refetch is the channel's Unwatched list, and the server's answer keeps the card out.
expect(feed.mock.calls[feed.mock.calls.length - 1]?.[0]).toMatchObject({
channelIds: ["UCx"],
show: "unwatched",
});
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByText("open v1")).toBeNull();
});
it("refetches when the player marks the clip watched as it closes", async () => {
const { feed } = mount();
fireEvent.click(await screen.findByText("open v1"));
player.watchOnUnmount = true;
const before = feed.mock.calls.length;
fireEvent.click(await screen.findByText("close"));
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("waits for the session's writes to land before it refetches", async () => {
const { feed } = mount();
let land: (v: unknown) => void = () => {};
vi.mocked(api.setState).mockReturnValue(new Promise((r) => (land = r)) as never);
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark"));
const before = feed.mock.calls.length;
fireEvent.click(screen.getByText("close"));
await act(() => new Promise((r) => setTimeout(r, 30)));
// A refetch now would ask a server that has not taken the mark yet.
expect(feed.mock.calls.length).toBe(before);
await act(async () => land({ ok: true }));
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("waits for the player's closing checkpoint before it refetches", async () => {
// Refetched before that write landed, the list read the older position and, landing after the
// player's patch, put it back: a reopen resumed early.
const { feed } = mount();
let land: (v: unknown) => void = () => {};
player.flush = new Promise((r) => (land = r));
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark"));
const before = feed.mock.calls.length;
fireEvent.click(screen.getByText("close"));
await act(() => new Promise((r) => setTimeout(r, 30)));
expect(feed.mock.calls.length).toBe(before);
await act(async () => land(undefined));
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("does not refetch on a close with nothing changed", async () => {
const { feed } = mount();
fireEvent.click(await screen.findByText("open v1"));
const before = feed.mock.calls.length;
fireEvent.click(await screen.findByText("close"));
await new Promise((r) => setTimeout(r, 30));
expect(feed.mock.calls.length).toBe(before);
});
});
describe("Feed — the card menu's channel filter", () => {
it("is offered on the feed, as its own channel list, and sets it", async () => {
const setFilters = vi.fn();
mount({ channelScoped: false, setFilters });
await screen.findByText("open v1");
expect(grid.channelFilter?.ids).toEqual(["UCx"]);
grid.channelFilter?.set(["UCx", "UCy"]);
expect(setFilters).toHaveBeenCalledWith({ ...FILTERS, channelIds: ["UCx", "UCy"] });
});
it("is not offered on a channel page, whose view is that channel already", async () => {
mount({ channelScoped: true });
await screen.findByText("open v1");
expect(grid.channelFilter).toBeUndefined();
});
});
@@ -0,0 +1,149 @@
// @vitest-environment jsdom
//
// "Reset — clear watch progress/status" on a half-watched video. The optimistic layer overrode only
// the STATUS, so the card kept its resume position — its progress bar and its Continue button — until
// the next refetch (found in a UAT). The grid is replaced by a list that shows what Feed
// hands it: each video's position, and a button that fires the reset.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } 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,
onResetState,
}: {
items: { id: string; position_seconds: number }[];
onResetState: (id: string) => void;
}) => (
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>
{v.id}@{v.position_seconds}
<button onClick={() => onResetState(v.id)}>reset {v.id}</button>
</li>
))}
</ol>
),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { patchCachedProgress } from "../../lib/progressCache";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const halfWatched = {
id: "v1",
title: "v1",
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds: 95,
duration_seconds: 600,
published_at: "2026-01-01T00:00:00Z",
watch_url: "https://www.youtube.com/watch?v=v1",
} as unknown as Video;
function mount(clear: () => Promise<unknown>) {
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
vi.spyOn(api, "clearState").mockImplementation(clear as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
return qc;
}
describe("Feed — resetting a half-watched video", () => {
it("clears its resume position at once, not only its status", async () => {
// Never settles: what the card shows before the server answers is the optimistic layer alone.
mount(() => new Promise(() => {}));
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
fireEvent.click(screen.getByText("reset v1"));
await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy());
});
it("shows the position a replay after it saved, so a reopen resumes there", async () => {
// Reset, played to 40 s and closed: the player writes what the server stored into the cache.
// A reset mask over the cache kept showing 0, a reopen resumed at 0, and its first checkpoint
// wrote over the 40.
const qc = mount(() => Promise.resolve({ ok: true }));
fireEvent.click(await screen.findByText("reset v1"));
await screen.findByText("v1@0");
act(() => patchCachedProgress(qc, new Map([["v1", 40]])));
await waitFor(() => expect(screen.getByText("v1@40")).toBeTruthy());
});
it("keeps the position at 0 through a refetch that set out before the reset", async () => {
// That refetch read the row the reset deletes: its answer put the old position back under a
// card the reset had made new, and a reopen resumed there.
let answer: (v: unknown) => void = () => {};
const qc = mount(() => new Promise(() => {}));
await screen.findByText("v1@95");
vi.mocked(api.feed).mockImplementationOnce(() => new Promise((r) => (answer = r)) as never);
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
await waitFor(() => expect(api.feed).toHaveBeenCalledTimes(2));
await act(() => new Promise((r) => setTimeout(r, 20)));
fireEvent.click(screen.getByText("reset v1"));
await screen.findByText("v1@0");
await act(async () => answer({ items: [halfWatched], next_cursor: null }));
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(screen.getByText("v1@0")).toBeTruthy();
});
it("puts the position back when the server refuses the reset", async () => {
mount(() => Promise.reject(new Error("down")));
fireEvent.click(await screen.findByText("reset v1"));
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
});
});
+991
View File
@@ -0,0 +1,991 @@
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { nextPageOnClick } from "../../lib/nextPage";
import {
ArrowLeft,
Columns2,
Grid3x3,
Info,
LayoutGrid,
Library,
List,
RefreshCw,
Rows3,
Trash2,
type LucideIcon,
} from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../../lib/api";
import i18n from "../../i18n";
import { notifyActionError } from "../../lib/actionErrors";
import { notify, resolveVideo } from "../../lib/notifications";
import { useDebounced } from "../../lib/useDebounced";
import { patchCachedProgress, progressFlushes } from "../../lib/progressCache";
import {
buildSort,
feedSortOptions,
parseSort,
SORT_KEYS,
sortYtResults,
YT_SORT_KEYS,
type SortKey,
} from "../../lib/feedSort";
import { type SortOption, type SortState } from "../../lib/columnSort";
import { usePref } from "../../lib/usePref";
import { SHOW_IDS } from "./feedFiltersInit";
import { FEED_VIEWS, type FeedView } from "../../lib/feedView";
import { clearedFilters, useActiveFilters } from "../../lib/useActiveFilters";
import ActiveFilterChips from "../../features/filters/ActiveFilterChips";
import SelectedViewChip from "../../features/filters/SelectedViewChip";
import { Button } from "../../components/ui/Button";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { PageToolbar } from "../../components/ui/PageShell";
import { PageMenu } from "../../components/ui/PageMenu";
import { menuGroups } from "../../components/ui/menuGroups";
import { choiceSubmenu, multiSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
import { useWizard } from "../../features/wizard/wizardContext";
import SortControl from "../../components/ui/SortControl";
import FilterSelect from "../../components/ui/FilterSelect";
import ItemCount from "../../components/ui/ItemCount";
import { FilterFoldButton } from "../../components/ui/FilterFold";
import { useFilterFold } from "../../lib/useFilterFold";
import ViewSwitcher from "../../components/ui/ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { IconButton } from "../../components/ui/IconButton";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
const PAGE = 60;
// The "Show" view filter, content-type filter and ordering live in the feed toolbar
// (above the cards), not the filter sidebar.
const CONTENT = [
{ key: "includeNormal", label: "sidebar.content.normal" },
{ key: "includeShorts", label: "sidebar.content.shorts" },
{ key: "includeLive", label: "sidebar.content.live" },
] as const;
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
// Icon per view mode; the labels are i18n'd at render (see feed.view.*).
const VIEW_ICON: Record<FeedView, LucideIcon> = {
cards: LayoutGrid,
cardsSmall: Grid3x3,
rows: Rows3,
rowsCompact: List,
tiles: Columns2,
};
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
// (which encode both). One entry per concept; a single arrow flips the direction.
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
function matchesView(status: VideoStatus, show: string): boolean {
switch (show) {
case "hidden":
return status === "hidden";
case "watched":
return status === "watched";
case "unwatched":
case "in_progress":
// (in_progress is further narrowed server-side by resume position; here we only
// need to drop a card once it's optimistically marked watched/hidden.)
return status !== "watched" && status !== "hidden";
default:
return status !== "hidden"; // all
}
}
// An optimistic value and the write that made it. A refetch that lands is the server's word only on
// the writes it ANSWERED before that refetch set out: a mark made while it was in flight is newer
// than its answer, and so is one sent just before it whose answer had not come back (the server may
// read the list first). Dropping either put a card marked watched back into an Unwatched view.
type Stamped<T> = { value: T; seq: number };
type Overrides<T> = Record<string, Stamped<T>>;
const unconfirmed = <T,>(o: Overrides<T>, answered: ReadonlySet<number>): Overrides<T> =>
Object.fromEntries(Object.entries(o).filter(([, s]) => !answered.has(s.seq)));
// A failed write takes back only its OWN value: a later write for the same video stands.
const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
if (o[id]?.seq !== seq) return o;
const next = { ...o };
delete next[id];
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;
export default function Feed({
filters,
setFilters,
view,
setView,
canRead,
ytSearch,
onYtSearch,
onExitYtSearch,
channelScoped,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
// The view mode is an account pref, not a filter: it persists on pick, so it isn't part of
// FeedFilters (no share-link param, no saved view, no active-filter chip). Provided by the caller
// (FeedViewProvider on the feed page; ChannelPage threads its own).
view: FeedView;
setView: (v: FeedView) => void;
canRead: boolean;
// Live YouTube search: the active search term (null = normal feed). Entering a search and
// leaving it both step browser history (the search is a feed sub-view with its own history
// entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via
// onExitYtSearch.
ytSearch: string | null;
onYtSearch: (q: string) => void;
onExitYtSearch: () => void;
// True when this feed is scoped to a single channel (the channel page). Drops sort options
// that are constant within one channel (subscribers, priority) — they'd be meaningless.
channelScoped?: boolean;
}) {
const { t, i18n: ui } = useTranslation();
const lang = ui.language;
// "Connect YouTube" onboarding lives in WizardProvider (the empty-state CTA when not connected).
const { openWizard: onOpenWizard } = useWizard();
// Same list the panel's badge counts — see lib/useActiveFilters.
const activeFilters = useActiveFilters(filters, setFilters);
// B4: on a short window the filter row folds into one "Filters · n" button (ui/FilterFold).
const fold = useFilterFold();
const sortKeys = channelScoped
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
: SORT_KEYS;
const [overrides, setOverrides] = useState<Overrides<VideoStatus>>({});
const [savedOverrides, setSavedOverrides] = useState<Overrides<boolean>>({});
// The write order the stamps above count in, the writes the server has answered, and which of
// them it had answered when the running refetch set out.
const writeSeq = useRef(0);
const answered = useRef(new Set<number>());
const answeredAtRefetch = useRef<ReadonlySet<number>>(new Set());
// A reset's position 0 lives in the cached lists (see onResetState), where a refetch that set out
// before the server took the reset puts the old position back: written again on its landing.
const resets = useRef(new Map<string, number>());
// The open player: which video and where to start (null = resume from saved position).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
null,
);
const qc = useQueryClient();
// 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
// the end) a card leaves an Unwatched view through the override at once, but the list, its count
// and the cache stayed the pre-session server's until a reload (owner).
const playerSession = useRef({
open: false,
changed: false,
pending: new Set<Promise<unknown>>(),
});
const openVideo = useCallback((video: Video, startAt: number | null = null) => {
playerSession.current.open = true;
// Its player writes the next position: a reset no longer speaks for it.
resets.current.delete(video.id);
setActiveVideo({ video, startAt });
}, []);
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect
// depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
// re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
// debounced volume write on each pass.
const closePlayer = useCallback(() => setActiveVideo(null), []);
// The session ends once the player has UNMOUNTED, not on the close click: its progress hook's
// cleanup marks a clip closed near its end watched, a write that lands after the click. React runs
// every unmount cleanup before any effect's setup, so here that write is already in the session,
// and so is the player's closing checkpoint (`progressFlushes`): refetched before it lands, the
// list read the older position and, landing after the player's patch, put it back.
const playerOpen = activeVideo !== null;
useEffect(() => {
const session = playerSession.current;
if (playerOpen || !session.open) return;
session.open = false;
if (!session.changed) return;
session.changed = false;
void Promise.allSettled([...session.pending, ...progressFlushes()]).then(() => {
void qc.invalidateQueries({ queryKey: qk.feed() });
void qc.invalidateQueries({ queryKey: qk.feedSummary() });
});
}, [playerOpen, qc]);
const ytActive = !!ytSearch;
// How many live-search results to gather (the count selector replaces a manual "load more";
// the free scrape source pages through continuations until this many are collected).
const [ytCount, setYtCount] = useState(20);
// How to order the loaded results. Its OWN pref, not `filters.sort`: sorting a live-search page
// must not silently re-sort the library you go back to. Default `relevance` — the provider's
// ranking, which is what the view always showed before it had a control.
const [ytSort, setYtSort] = usePref<NonNullable<SortState>>("ytSearchSort", {
key: "relevance",
dir: "desc",
});
// Debounce only the search term feeding the queries: the input still updates instantly (it
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
// and re-fetched pages keep the previous results on screen, so the feed never blanks/flickers.
const debouncedQ = useDebounced(filters.q, 300);
const queryFilters: FeedFilters = { ...filters, q: debouncedQ };
const query = useInfiniteQuery({
queryKey: qk.feed(queryFilters),
queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: !ytActive, // don't load the normal feed while showing live YouTube results
placeholderData: keepPreviousData,
});
// Live YouTube search results. We never auto-paginate on scroll — the user pulls more with an
// explicit button (each api-source page spends 100 quota units; scrape-source pages are free).
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
// revisited search from re-spending.
const ytQuery = useInfiniteQuery({
queryKey: qk.ytSearch(ytSearch, ytCount),
queryFn: ({ pageParam }) =>
api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: ytActive,
staleTime: 5 * 60_000,
retry: false,
});
// Remember (in history.state) that this live search was served by the zero-quota scrape source,
// so a reload restores the results instead of dropping to the feed — re-fetching scrape pages
// costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units).
useEffect(() => {
if (!ytActive) return;
const st = window.history.state;
if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry
if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) {
window.history.replaceState({ ...st, _ytScrape: true }, "");
}
}, [ytActive, ytSearch, ytQuery.data]);
// Switching to the relevance sort when a search starts happens atomically in the header's
// input onChange (race-free with the query update). Here we only handle the reverse: when
// the term is cleared, fall back to the default sort — relevance has no dropdown option and
// doesn't rank without a query.
const qEmpty = !filters.q.trim();
useEffect(() => {
if (qEmpty && parseSort(filters.sort).key === "relevance") {
setFilters({ ...filters, sort: buildSort("date", "desc") });
}
// 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
// guarding against a loop that the guard already prevents.
}, [qEmpty, filters, setFilters]);
// Drop optimistic status overrides when filters change OR fresh server data
// arrives — after a refetch the server is authoritative, so a stale override
// (e.g. a video reverted to "new" from the notification center) won't keep it
// filtered out of the current view.
const [overridesFor, setOverridesFor] = useState(filters);
if (overridesFor !== filters) {
setOverridesFor(filters);
setOverrides({});
setSavedOverrides({});
}
// ...but only when a refetch LANDS, the server's own answer. Not on an infinite-scroll append (its
// page 1 rows are unchanged, so a just-hidden card would flash back), and not on a local write into
// the cache: the player's closing position patch bumped `dataUpdatedAt` too, cleared the overrides,
// and put a card marked watched back into an Unwatched view as "new", with nothing to refetch it.
// And only the overrides whose write it had answered when it set out (`Stamped`).
const refetching = query.isFetching && !query.isFetchingNextPage;
const wasRefetching = useRef(false);
useEffect(() => {
const started = !wasRefetching.current && refetching;
const landed = wasRefetching.current && !refetching && query.status === "success";
wasRefetching.current = refetching;
if (started) answeredAtRefetch.current = new Set(answered.current);
if (!landed) return;
const known = answeredAtRefetch.current;
setOverrides((o) => unconfirmed(o, known));
setSavedOverrides((o) => unconfirmed(o, known));
for (const seq of known) answered.current.delete(seq);
const zero = new Map<string, number>();
for (const [id, seq] of resets.current) {
if (known.has(seq)) resets.current.delete(id);
else zero.set(id, 0);
}
patchCachedProgress(qc, zero);
}, [refetching, query.status, qc]);
// One shared query with the sidebar (same key + filters -> React Query dedupes to a single request):
// the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets).
const summaryQuery = useQuery({
queryKey: qk.feedSummary(queryFilters),
queryFn: () => api.feedSummary(queryFilters),
staleTime: 30_000,
enabled: !ytActive,
placeholderData: keepPreviousData,
});
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
// Keep the loaded videos in a ref so onState can stay referentially stable
// (it needs the list only to look up a title for the notification). A stable
// onState lets memo(VideoCard) skip re-rendering existing cards on page append.
const loadedRef = useRef<Video[]>([]);
// The toast's Undo re-enters onState, a forward reference: through this ref (set below).
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
const onState = useCallback(
(id: string, status: VideoStatus) => {
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
const write = api.setState(id, status);
const session = playerSession.current;
if (session.open) {
session.changed = true;
session.pending.add(write);
const settled = () => void session.pending.delete(write);
write.then(settled, settled);
}
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
// would falsely report a change that the .catch below is about to roll back (e.g. when
// the API is unreachable).
write
.then(() => {
// Mark stale WITHOUT refetching: the optimistic `overrides` layer + the matchesView filter
// already show the correct state (a hidden/watched card drops out immediately), so refetching
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
message: v?.title
? i18n.t("feed.hiddenNamed", { title: v.title })
: i18n.t("feed.hidden"),
action: { label: i18n.t("feed.undo"), onClick: () => onStateRef.current(id, "new") },
meta: {
kind: "video-hidden",
videoId: id,
title: v?.title ?? i18n.t("feed.thisVideo"),
channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "",
},
});
} else if (status === "watched") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
message: v?.title
? i18n.t("feed.markedWatchedNamed", { title: v.title })
: i18n.t("feed.markedWatched"),
action: {
label: i18n.t("feed.unwatch"),
onClick: () => onStateRef.current(id, "new"),
},
meta: {
kind: "video-watched",
videoId: id,
title: v?.title ?? i18n.t("feed.thisVideo"),
channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "",
},
});
} else if (status === "new") {
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve
// any stale hide/watch notice for this video so it doesn't linger with a dead action.
resolveVideo(id);
}
})
.catch((e: unknown) => {
// Server rejected the change — drop the optimistic override so the card
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
// a card that silently snaps back reads as a misclick.
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
setOverrides((o) => withoutWrite(o, id, seq));
});
},
[qc],
);
const onResetState = useCallback(
(id: string) => {
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
// un-watch toggle can't clear an in-progress position, this can.
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: "new", seq } }));
// The position goes into the cached lists, where the player's own closing patch writes the
// next one: as a mask over them it outlived a replay, so a reopen resumed at 0 and its first
// checkpoint wrote over the progress just made.
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
patchCachedProgress(qc, new Map([[id, 0]]));
resets.current.set(id, seq);
api
.clearState(id)
// Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => {
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
})
.catch((e: unknown) => {
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
setOverrides((o) => withoutWrite(o, id, seq));
if (resets.current.get(id) !== seq) return;
resets.current.delete(id);
if (before !== undefined) patchCachedProgress(qc, new Map([[id, before]]));
});
},
[qc],
);
const onToggleSave = useCallback(
(id: string, saved: boolean) => {
const seq = ++writeSeq.current;
setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
.then(() => {
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
})
.catch((e: unknown) => {
notifyActionError(
e,
i18n.t(
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
),
);
setSavedOverrides((o) => withoutWrite(o, id, seq));
});
},
[qc],
);
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
// onState's refs follow each commit: itself, and what the cards show (live search results if on).
useLayoutEffect(() => {
onStateRef.current = onState;
loadedRef.current = ytActive ? ytLoaded : loaded;
});
const withOverrides = (list: Video[]): Video[] =>
list
.map((v) => {
const status = overrides[v.id]; // read once so the narrowing sticks
return status ? { ...v, status: status.value } : v;
})
.map((v) => {
const saved = savedOverrides[v.id];
return saved ? { ...v, saved: saved.value } : v;
});
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
// watch-state view filter is NOT applied — so marking the current video watched keeps it in the
// sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that
// goes hidden still drops out ("visible affects").
const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden");
// A 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.
const dropFeedCaches = () => {
qc.removeQueries({ queryKey: qk.feed() });
qc.removeQueries({ queryKey: qk.feedSummary() });
};
// --- Live YouTube search mode -------------------------------------------------------------
// 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),
ytSort.key as SortKey,
ytSort.dir,
lang,
);
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
const ytError =
ytQuery.error instanceof HttpError
? ytQuery.error.detail || t("feed.yt.error")
: ytQuery.isError
? t("feed.yt.error")
: null;
// The backend reports which source served the search; scrape spends no search quota, so we
// drop the quota warning + "uses quota" wording in that mode.
const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
return (
<div className="p-4">
<div className="pb-3 mb-1 border-b border-border">
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="ghost"
size="sm"
icon={<ArrowLeft />}
onClick={() => {
// Surface the just-searched videos in the feed you return to: switch the Source
// filter to "search" (your own search finds) and rank them by relevance.
setFilters({ ...filters, librarySource: "search", sort: "relevance" });
onExitYtSearch();
dropFeedCaches();
}}
>
{t("feed.yt.back")}
</Button>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<YouTubeIcon className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-semibold truncate">
{t("feed.yt.resultsFor", { query: ytSearch })}
</span>
<span className="text-xs text-muted">
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
</span>
{/* Four fixed counts: the house's segmented control, not a native <select> (owner's
pick, 2026-09-19). The caption is the group's name, the hint its description. */}
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
<span aria-hidden="true">{t("feed.yt.show")}</span>
<SegmentedToggle
size="md"
ariaLabel={t("feed.yt.show")}
title={t("feed.yt.showHint")}
value={String(ytCount)}
onChange={(v) => setYtCount(Number(v))}
options={YT_COUNTS.map((n) => ({ value: String(n), label: String(n) }))}
/>
</div>
{/* Sorts the results already on screen (see sortYtResults) — it never re-queries, so it
costs nothing and works the same on both search sources. */}
<SortControl
options={feedSortOptions(YT_SORT_KEYS, (k) => t("feed.sortKey." + k))}
sort={ytSort}
onChange={(next) => setYtSort(next ?? { key: "relevance", dir: "desc" })}
label={t("feed.sortLabel")}
ascLabel={t("feed.dirAsc")}
descLabel={t("feed.dirDesc")}
/>
</div>
{ytItems.length > 0 && (
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
<Info className="w-3.5 h-3.5 shrink-0" />
<span>{t("feed.yt.ephemeralNote")}</span>
<Button
variant="outline-danger"
size="xs"
icon={<Trash2 />}
onClick={() => {
const ids = ytItems.map((v) => v.id);
api
.clearSearch(ids)
.then((r) =>
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
)
.catch((e: unknown) =>
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
);
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch();
dropFeedCaches();
qc.removeQueries({ queryKey: qk.ytSearch() });
}}
>
{t("feed.yt.clearNow")}
</Button>
</div>
)}
</div>
{ytQuery.isLoading ? (
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError ? (
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
) : ytItems.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
) : (
<>
<VirtualFeed
items={ytItems}
view={view}
onState={onState}
onToggleSave={onToggleSave}
onResetState={onResetState}
onOpen={openVideo}
hasNextPage={false}
isFetching={false}
fetchNextPage={() => {}}
/>
{ytQuery.hasNextPage && (
<div className="text-center pt-4">
<Button
variant="outline"
onClick={() =>
void ytQuery.fetchNextPage(nextPageOnClick(ytQuery.isFetchingNextPage))
}
loading={ytQuery.isFetchingNextPage}
>
{ytQuery.isFetchingNextPage
? t("feed.loadingMore")
: zeroQuota
? t("feed.yt.loadMoreFree")
: t("feed.yt.loadMore")}
</Button>
</div>
)}
</>
)}
{activeVideo && (
<Suspense fallback={null}>
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
queue={ytPlayerQueue}
onClose={closePlayer}
onState={onState}
/>
</Suspense>
)}
</div>
);
}
if (query.isLoading) return <LoadingState shape="grid" label={t("feed.loading")} />;
if (query.isError && !query.data)
return (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("feed.loadError")}
</StateMessage>
);
// Empty "my" feed with no YouTube access: the onboarding prompt, plus a way into the shared
// library. No toolbar (sort/type are meaningless).
if (items.length === 0 && !canRead && filters.scope === "my")
return (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
<Button variant="primary" size="lg" onClick={onOpenWizard}>
{t("feed.setUp")}
</Button>
<Button
variant="link-muted"
className="mx-auto mt-3 flex text-sm"
onClick={() => setFilters({ ...filters, scope: "all" })}
>
{t("feed.browseShared")}
</Button>
</div>
</div>
);
const { key: sortKey, dir: sortDir } = parseSort(filters.sort);
// The feed's sort vocabulary as source-agnostic options for the shared SortControl. Relevance only
// ranks meaningfully with a search term (offered then); shuffle/relevance are directionless.
const sortOptions: SortOption[] = feedSortOptions(
filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys,
(k) => t("feed.sortKey." + k),
);
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
// same controls render on both the feed and channel-scoped rows.
// One setter per toolbar control, shared with the page's background menu (the same choices, the
// same effect, two doors).
const setSort = (next: SortState) => {
if (!next) return; // the feed is always sorted — no "no sort" option is offered
setFilters({
...filters,
sort: buildSort(next.key as SortKey, next.dir),
seed: next.key === "shuffle" ? rollSeed() : undefined,
});
};
const reshuffle = () => setFilters({ ...filters, seed: rollSeed() });
const showOptions = SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }));
const setShow = (show: string) => setFilters({ ...filters, show: show as FeedFilters["show"] });
const contentOptions = CONTENT.map((c) => ({ value: c.key, label: t(c.label) }));
const contentSelected = CONTENT.filter((c) => filters[c.key]).map((c) => c.key);
const setContent = (next: string[]) => {
const patch = { ...filters };
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
setFilters(patch);
};
const sourceOptions = [
{ value: "organic", label: t("feed.source.organic") },
{ value: "all", label: t("feed.source.all") },
{ value: "search", label: t("feed.source.only") },
];
const setSource = (source: string) =>
setFilters({ ...filters, librarySource: source as FeedFilters["librarySource"] });
const viewOptions = FEED_VIEWS.map((id) => ({
id,
label: t("feed.view." + id),
icon: VIEW_ICON[id],
}));
const sortView = (
<>
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
onChange={setSort}
label={t("feed.sortLabel")}
ascLabel={t("feed.dirAsc")}
descLabel={t("feed.dirDesc")}
trailing={
sortKey === "shuffle" ? (
<IconButton variant="outline" onClick={reshuffle} label={t("sidebar.reshuffle")}>
<RefreshCw />
</IconButton>
) : undefined
}
/>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={viewOptions}
onChange={setView}
label={t("feed.viewLabel")}
// The mode's name only earns its place once the window is wide.
labelClassName="hidden xl:inline"
/>
</>
);
const toolbar = (
<div className="pb-1.5">
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
never shoves the controls beside it (see ItemCount). */}
<ItemCount
count={summaryQuery.data?.count}
stale={summaryQuery.isPlaceholderData}
failed={summaryQuery.isError && !summaryQuery.data}
/>
<FilterFoldButton
fold={fold}
label={t("sidebar.filters")}
count={channelScoped ? 0 : activeFilters.length}
/>
{!fold.folded && (
<>
{/* Watch state is single-select (always exactly one); content types are an
independent multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
<FilterSelect
label={t("feed.showLabel")}
testId="feed-show"
options={showOptions}
selected={[filters.show]}
onChange={(next) => next[0] && setShow(next[0])}
/>
<FilterSelect
label={t("feed.contentLabel")}
testId="feed-content"
multiple
options={contentOptions}
selected={contentSelected}
onChange={setContent}
/>
{/* The Source filter (organic / include-search / search-only) is a global-catalog
concept — it selects how videos ENTERED the library. On a channel-scoped view we
already show all of this one channel's videos (librarySource is pinned to "all"), so
the selector would only offer a near-empty, confusing slice — hide it here. */}
{!channelScoped && (
<FilterSelect
label={t("feed.source.label")}
testId="feed-source"
options={sourceOptions}
selected={[filters.librarySource ?? "organic"]}
onChange={(next) => next[0] && setSource(next[0])}
/>
)}
</>
)}
{/* Folded, the band is one row: Sort + View join it on the right. */}
{fold.folded && (
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
)}
</div>
</div>
);
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
const pageMenuItems = menuGroups(
[
sortSubmenu({
id: "sort",
label: t("feed.sortLabel"),
options: sortOptions,
sort: { key: sortKey, dir: sortDir },
onChange: setSort,
ascLabel: t("feed.dirAsc"),
descLabel: t("feed.dirDesc"),
}),
...(sortKey === "shuffle"
? [{ id: "reshuffle", label: t("sidebar.reshuffle"), onSelect: reshuffle }]
: []),
choiceSubmenu({
id: "view",
label: t("feed.viewLabel"),
options: viewOptions.map((o) => ({ value: o.id, label: o.label })),
selected: view,
onPick: setView,
}),
],
[
choiceSubmenu({
id: "show",
label: t("feed.showLabel"),
options: showOptions,
selected: filters.show,
onPick: setShow,
}),
multiSubmenu({
id: "content",
label: t("feed.contentLabel"),
options: contentOptions,
selected: contentSelected,
onChange: setContent,
}),
...(channelScoped
? []
: [
choiceSubmenu({
id: "source",
label: t("feed.source.label"),
options: sourceOptions,
selected: filters.librarySource ?? "organic",
onPick: setSource,
}),
]),
],
!channelScoped && activeFilters.length > 0
? [
{
id: "clear",
label: t("sidebar.clearAll"),
onSelect: () => setFilters(clearedFilters(filters)),
},
]
: [],
);
return (
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN
baseline (channel-scoped, whole-catalog, the watch state the channel page remembers), so
each would read as a "filter you applied" when it's just the page being itself. */}
<PageToolbar>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
{toolbar}
{/* The controls row sits directly above the grid: the applied-filter chips flow from the left
(overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay
pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
applied"), but still get the right-pinned Sort/View. */}
{/* Wraps on a phone: the chips and Sort + View did not fit one line, and the row pushed the
page 21px wide (prod UAT 2026-09-19). Sort + View then take a line of their own. */}
{!fold.folded && (
<div className="flex flex-wrap items-center gap-2 pb-2">
{!channelScoped && (
<>
{/* The active personal view (if any) leads the applied-filter row. */}
<SelectedViewChip />
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
className="flex-1"
/>
</>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
)}
</div>
</PageToolbar>
{items.length === 0 ? (
<div className="py-16 text-center text-muted">
<p>{t("feed.noMatches")}</p>
{/* A channel filter under the "Mine" scope returns nothing when that channel is hidden from
your subscriptions (or you don't subscribe to it) — its videos only live in the shared
Library. Point there rather than leaving a bare "no matches". */}
{filters.channelIds.length > 0 && filters.scope === "my" && (
<div className="mt-4">
<p className="text-sm">{t("feed.channelScopeHint")}</p>
<Button
className="mt-3"
icon={<Library />}
onClick={() => setFilters({ ...filters, scope: "all" })}
>
{t("feed.viewInLibrary")}
</Button>
</div>
)}
{filters.q.trim() && (
<Button
className="mt-4"
icon={<YouTubeIcon />}
onClick={() => onYtSearch(filters.q.trim())}
>
{t("feed.yt.searchFor", { query: filters.q.trim() })}
</Button>
)}
</div>
) : (
<VirtualFeed
items={items}
view={view}
onState={onState}
onToggleSave={onToggleSave}
onResetState={onResetState}
onOpen={openVideo}
// A channel page's view is the channel already: no row there to narrow it to one.
channelFilter={
channelScoped
? undefined
: {
ids: filters.channelIds,
set: (channelIds) => setFilters({ ...filters, channelIds }),
}
}
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
// re-request it once a round-trip for as long as the end is in view. The row
// below says so and retries on a click.
hasNextPage={!!hasNextPage && !query.isFetchNextPageError}
isFetching={query.isFetching}
fetchNextPage={fetchNextPage}
/>
)}
{activeVideo && (
<Suspense fallback={null}>
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
queue={playerQueue}
onClose={closePlayer}
onState={onState}
/>
</Suspense>
)}
{isFetchingNextPage && (
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
)}
{query.isFetchNextPageError && !isFetchingNextPage && (
<StateMessage
tone="error"
compact
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
>
{t("common.loadMoreFailed")}
</StateMessage>
)}
</div>
);
}
@@ -0,0 +1,120 @@
// @vitest-environment jsdom
//
// The live-YouTube results' Sort control, wired end to end: the pref it writes, the key and
// direction Feed hands to sortYtResults, and the order that reaches the grid. sortYtResults itself
// is pinned in feedSort.yt.test.ts; a review during the 2026-09 bug hunt found that the CALL SITE
// could ignore the chosen direction (or skip the sort entirely) with every lane green.
//
// The virtualised grid lays out nothing in jsdom, so it is replaced by a plain list that shows the
// order Feed gave it — which is exactly the thing under test.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("../../features/wizard/wizardContext", () => ({
useWizard: () => ({ openWizard: vi.fn() }),
}));
vi.mock("./VirtualFeed", () => ({
default: ({ items }: { items: { id: string; title: string }[] }) => (
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>{v.title}</li>
))}
</ol>
),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, 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;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const hit = (id: string, duration_seconds: number): Video =>
({
id,
title: `hit ${id}`,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds: 0,
duration_seconds,
published_at: "2026-01-01T00:00:00Z",
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
const order = () =>
Array.from(screen.getByRole("list", { name: "results" }).querySelectorAll("li")).map(
(li) => li.textContent,
);
describe("Feed — sorting the live YouTube results", () => {
it("hands the grid the results in the key AND direction the viewer picked", async () => {
vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
vi.spyOn(api, "searchYoutube").mockResolvedValue({
items: [hit("a", 300), hit("b", 100), hit("c", 200)],
next_cursor: null,
source: "scrape",
} as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch="lemmino"
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
// Relevance is the provider's own ranking — the order the results arrived in.
await waitFor(() => expect(order()).toEqual(["hit a", "hit b", "hit c"]));
fireEvent.click(screen.getByRole("button", { name: /^feed\.sortLabel:/ }));
const duration = (await screen.findAllByRole("menuitemradio", { hidden: true })).find((o) =>
o.textContent?.includes("feed.sortKey.duration"),
);
fireEvent.click(duration!);
fireEvent.click(await screen.findByText("feed.dirAsc"));
await waitFor(() => expect(order()).toEqual(["hit b", "hit c", "hit a"]));
fireEvent.click(screen.getByText("feed.dirDesc"));
await waitFor(() => expect(order()).toEqual(["hit a", "hit c", "hit b"]));
});
});
@@ -0,0 +1,38 @@
// @vitest-environment jsdom
//
// loadAccountFilters is the localStorage seed path for the feed filters. It must migrate a persisted
// legacy global `tagMode` (older builds wrote it) into the per-category `tagModes` — the same coercion
// the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost.
import { describe, it, expect, beforeEach } from "vitest";
import { loadAccountFilters } from "./feedFiltersInit";
import { claimLocalData, LS } from "../../lib/storage";
beforeEach(() => {
localStorage.clear();
claimLocalData(7);
});
describe("loadAccountFilters — legacy tagMode migration on seed", () => {
it("migrates a persisted legacy tagMode:'and' from the last-session blob into topic", () => {
localStorage.setItem(LS.filters, JSON.stringify({ q: "hi", tagMode: "and" }));
const f = loadAccountFilters();
expect(f.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
expect(f.q).toBe("hi"); // other fields preserved through the merge
});
it("leaves tagModes at defaults when nothing is stored", () => {
expect(loadAccountFilters().tagModes).toEqual({ language: "or", topic: "or", other: "or" });
});
it("preserves an already-migrated per-category tagModes blob", () => {
localStorage.setItem(
LS.filters,
JSON.stringify({ tagModes: { language: "and", topic: "or", other: "and" } }),
);
expect(loadAccountFilters().tagModes).toEqual({
language: "and",
topic: "or",
other: "and",
});
});
});
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
//
// The feed's filters are seeded at mount from the account's stored view. A share link's filters are
// written to the account once at mount too, so the view the link opened survives a later plain reload (the state itself is seeded from the URL, so only storage shows it).
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NavigationProvider } from "../../features/navigation/NavigationProvider";
import { FeedFiltersProvider } from "./FeedFiltersProvider";
import { useFeedFilters } from "./feedFiltersContext";
import { qk } from "../../lib/queryKeys";
import type { Me } from "../../lib/api";
import { claimLocalData } from "../../lib/storage";
const scopes: string[] = [];
function Probe() {
scopes.push(useFeedFilters().scope);
return null;
}
function mount(me: Partial<Me>) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 5, preferences: {}, ...me });
render(
<QueryClientProvider client={qc}>
<NavigationProvider>
<FeedFiltersProvider>
<Probe />
</FeedFiltersProvider>
</NavigationProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
localStorage.clear();
claimLocalData(5);
scopes.length = 0;
window.history.replaceState(null, "", "/");
});
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
describe("FeedFiltersProvider seed", () => {
it("opens an account on its own scope", () => {
mount({});
expect(scopes[0]).toBe("my");
});
it("keeps a share link's filters for the account, so a plain reload opens the same view", () => {
window.history.replaceState(null, "", "/?scope=all");
mount({});
expect(scopes[0]).toBe("all");
expect(JSON.parse(localStorage.getItem("siftlode.filters") ?? "{}").scope).toBe("all");
});
});
@@ -0,0 +1,83 @@
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 { ownKey, writeJSON } from "../../lib/storage";
import { logFilterChange } from "../../lib/filterLog";
import { useNavigation } from "../../features/navigation/navigationContext";
import {
FeedFiltersActionsContext,
FeedFiltersStateContext,
type FeedFiltersActions,
} from "./feedFiltersContext";
import { DEFAULT_FILTERS, FILTERS_KEY, loadAccountFilters } from "./feedFiltersInit";
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
// god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters`
// through the tree. Mirrors NavigationProvider (split contexts + one-line hooks); composed in
// main.tsx inside NavigationProvider, around <App/>.
//
// SCOPE NOTE: this owns the FEED's filters only. The channel detail page keeps its OWN isolated
// filter state (ChannelPage) — it renders a channel-scoped catalog, not the global feed — and the
// reusable <Feed> is parameterized by a `filters` prop so it can render either. Because the two live
// in structurally separate cells, opening a channel can never leak into the global feed (bug 2).
//
// The contexts + hooks live in feedFiltersContext.ts; the defaults and the per-account seed in
// feedFiltersInit.ts.
// URL wins over localStorage so a pasted link reproduces the exact view.
function loadInitialFilters(): FeedFilters {
const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
return loadAccountFilters();
}
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
// Seeded for the account this tab is — BootGate mounts this provider only once the account is
// claimed, so this is the account's own view from the first render.
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
// once at mount — such filters win over the account's stored view and are persisted to the account.
const [initialHadUrlFilters] = useState(() =>
hasFilterParams(new URLSearchParams(window.location.search)),
);
const setFilters = useCallback((next: FeedFilters) => {
setFiltersState(next);
logFilterChange("feed", next, next.q);
// Persist as the account's own view.
const key = ownKey(FILTERS_KEY);
if (key) writeJSON(key, next);
}, []);
// Mirror the feed filters into the address bar so every feed view is bookmarkable. Gated on
// page===feed: on other pages the filter keys are cleared from the URL (localStorage still holds
// them; they re-mirror on return to the feed). The channel detail page keeps its own isolated
// filters, so those never reach here. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
// other providers own.
const { page } = useNavigation();
useUrlMirror(filterParamUpdates(page === "feed" ? filters : 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.
//
// The mount-time filters are FROZEN into their own state rather than left to an empty dep list:
// `filters` changes on every edit, so listing it honestly would re-persist — and setFilters
// setting the state it depends on would spin. Frozen, all three deps are stable and the effect
// means exactly what it says: write what the URL brought, once.
const [urlSeededFilters] = useState(filters);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- setFilters also WRITES to per-account localStorage, which is the point here — persisting what a share link brought. That is a side effect, not a value to derive, and the frozen seed above makes it happen exactly once.
if (initialHadUrlFilters) setFilters(urlSeededFilters);
}, [initialHadUrlFilters, urlSeededFilters, setFilters]);
const actions = useMemo<FeedFiltersActions>(() => ({ setFilters }), [setFilters]);
return (
<FeedFiltersActionsContext.Provider value={actions}>
<FeedFiltersStateContext.Provider value={filters}>
{children}
</FeedFiltersStateContext.Provider>
</FeedFiltersActionsContext.Provider>
);
}
@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import { Library, User } from "lucide-react";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
// search box — mirroring PlexScopeToggle's placement, so the primary "what am I browsing" control is
// always in reach instead of buried in the (possibly collapsed) filter rail. Same `filters.scope`
// field, so behaviour is unchanged. The `pointer-events-auto`/`shrink-0` wrapper matches the other
// header pills: the header band is `pointer-events-none`, and the pill must not collapse in the flex row.
export default function FeedScopeToggle() {
const { t } = useTranslation();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
return (
<div className="pointer-events-auto shrink-0">
<SegmentedToggle
size="md"
ariaLabel={t("header.scope.label")}
options={[
{
value: "my",
label: t("header.scope.my"),
icon: <User className="w-3.5 h-3.5" />,
title: t("header.scope.myTip"),
},
{
value: "all",
label: t("header.scope.all"),
icon: <Library className="w-3.5 h-3.5" />,
title: t("header.scope.allTip"),
},
]}
value={filters.scope}
onChange={(scope) => setFilters({ ...filters, scope })}
/>
</div>
);
}
@@ -0,0 +1,35 @@
import { useMemo, type ReactNode } from "react";
import { parseFeedView, type FeedView } from "../../lib/feedView";
import { LS } from "../../lib/storage";
import { usePref } from "../../lib/usePref";
import {
FeedViewActionsContext,
FeedViewStateContext,
type FeedViewActions,
} from "./feedViewContext";
// Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around <App/>.
//
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and follows
// the account across devices. usePref carries that server-sync (seeded from the server pref, with the
// localStorage mirror as the fallback) so this provider is just the two contexts over it.
//
// The contexts + hooks live in feedViewContext.ts.
export function FeedViewProvider({ children }: { children: ReactNode }) {
// parseFeedView also maps the pre-0.45 "grid"/"list" values forward (idempotent for current values).
const [view, setView] = usePref<FeedView>("view", "cards", {
localKey: LS.feedView,
parse: parseFeedView,
});
const actions = useMemo<FeedViewActions>(() => ({ setView }), [setView]);
return (
<FeedViewActionsContext.Provider value={actions}>
<FeedViewStateContext.Provider value={view}>{children}</FeedViewStateContext.Provider>
</FeedViewActionsContext.Provider>
);
}
@@ -0,0 +1,210 @@
// @vitest-environment jsdom
//
// The single default-application path. On the saved-views query's FIRST resolve, SavedViewsProvider
// seeds the starred default view's filters into a genuinely fresh module (no stored filters), for BOTH
// the feed and Plex — but leaves them alone once the account has its own stored filters. This is the
// fix for the old feed-only LS mirror that raced FeedFiltersProvider's login effect and never applied a
// Plex default at all. The sub-providers are mocked so we can assert exactly which setFilters is called;
// api.savedViews is stubbed and driven through a real QueryClient.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
page: "feed" as "feed" | "plex",
setFeedFilters: vi.fn(),
setPlexFilters: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../../features/navigation/navigationContext", () => ({
useNavigation: () => ({ page: h.page }),
}));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({
tags: [],
channelIds: [],
tagModes: { language: "or", topic: "or", other: "or" },
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
}),
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
}));
vi.mock("../plex/plexContext", () => ({
usePlex: () => ({ filters: {} }),
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
import { SavedViewsProvider } from "./SavedViewsProvider";
import { useSavedViews } from "./savedViewsContext";
import { api, type SavedView } from "../../lib/api";
import { claimLocalData, LS } from "../../lib/storage";
import { qk } from "../../lib/queryKeys";
const ID = 7;
const view = (over: Partial<SavedView>): SavedView => ({
id: 1,
name: "v",
filters: {},
position: 0,
is_default: false,
...over,
});
function Probe() {
const { loading, views } = useSavedViews();
return (
<div data-testid="loading" data-count={views.length}>
{String(loading)}
</div>
);
}
// The views query waits for a signed-in account in the cache (BootGate's `me`).
function signedInClient() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: ID, preferences: {} });
return qc;
}
async function mountResolved(views: SavedView[]) {
// Fresh array + object copies per call, like a real fetch — so a refetch changes viewsQuery.data's
// identity and re-runs the seed effect (which is what makes the per-source guard actually load-bearing
// in the re-seed test; a shared reference would skip the effect and hide a missing guard).
vi.spyOn(api, "savedViews").mockImplementation(async () => views.map((v) => ({ ...v })));
const qc = signedInClient();
render(
<QueryClientProvider client={qc}>
<SavedViewsProvider>
<Probe />
</SavedViewsProvider>
</QueryClientProvider>,
);
// Wait for the query to resolve so the first-resolve effect has run.
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
return qc;
}
beforeEach(() => {
localStorage.clear();
claimLocalData(ID);
// Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior
// case would suppress seeding in the others.
window.history.replaceState({}, "", "/");
h.page = "feed";
h.setFeedFilters.mockClear();
h.setPlexFilters.mockClear();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("SavedViewsProvider — default-view seeding on first resolve", () => {
it("feed: seeds the starred default into a fresh module (no stored filters)", async () => {
await mountResolved([
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
]);
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
});
it("feed: does NOT seed when the account already has stored filters", async () => {
localStorage.setItem(LS.filters, JSON.stringify({ scope: "my" }));
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
expect(h.setFeedFilters).not.toHaveBeenCalled();
});
it("feed: does NOT seed when no view is starred as default", async () => {
await mountResolved([view({ id: 2, is_default: false, filters: { scope: "all" } })]);
expect(h.setFeedFilters).not.toHaveBeenCalled();
});
it("feed: does NOT seed when the URL carries filter params (a shared/bookmarked view wins)", async () => {
// A link like ?sort=oldest is an explicit filter the user arrived on; the starred default must not
// stomp it. feedCanSeedDefault() bails on hasFilterParams — plex ignores the URL, so this is feed-only.
window.history.replaceState({}, "", "/?sort=oldest");
await mountResolved([
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
]);
expect(h.setFeedFilters).not.toHaveBeenCalled();
});
it("does NOT re-seed on a background refetch that returns a changed list (per-source one-shot guard)", async () => {
// The 60s refetch re-resolves the query; when it returns a CHANGED list (e.g. the user just added a
// view elsewhere) react-query hands down a new reference and the seed effect re-runs. seededRef must
// stop it re-applying the default over filters the user has since edited. (An identical refetch is
// deduped by structural sharing and never reaches the effect, so the changed list is the real test.)
const def = view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } });
let calls = 0;
vi.spyOn(api, "savedViews").mockImplementation(async () => {
calls += 1;
return calls === 1 ? [def] : [def, view({ id: 9, name: "added-later" })];
});
const qc = signedInClient();
render(
<QueryClientProvider client={qc}>
<SavedViewsProvider>
<Probe />
</SavedViewsProvider>
</QueryClientProvider>,
);
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
await act(async () => {
await qc.refetchQueries({ queryKey: qk.savedViews("feed") });
});
// Wait until the changed list has actually reached the component, so the seed effect has had its
// chance to (wrongly) re-fire — otherwise the assertion could win by racing ahead of the re-render.
await waitFor(() => expect(screen.getByTestId("loading").dataset.count).toBe("2"));
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
});
it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => {
h.page = "plex";
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
});
it("plex: does NOT seed when plex filters are already stored", async () => {
h.page = "plex";
localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] }));
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
expect(h.setPlexFilters).not.toHaveBeenCalled();
});
});
describe("SavedViewsProvider — signed-out tab", () => {
it("does NOT ask for saved views while no account is signed in (the landing logged a 401)", async () => {
const spy = vi.spyOn(api, "savedViews").mockResolvedValue([]);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), null); // BootGate's "nobody"
render(
<QueryClientProvider client={qc}>
<SavedViewsProvider>
<Probe />
</SavedViewsProvider>
</QueryClientProvider>,
);
// Let any fetch the mount would start get going before asserting it never happened.
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
expect(spy).not.toHaveBeenCalled();
// …and it asks as soon as an account signs in.
act(() => {
qc.setQueryData(qk.me(), { id: ID, preferences: {} });
});
await waitFor(() => expect(spy).toHaveBeenCalledWith("feed"));
});
});
@@ -0,0 +1,264 @@
import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import {
api,
coercePlexFilters,
plexFiltersSignature,
type FeedFilters,
type PlexFilters,
type SavedView,
} from "../../lib/api";
import { filtersToParams, hasFilterParams } from "../../lib/urlState";
import { pickDefaultToSeed, preferSticky } from "../../lib/savedViews";
import { notifyActionError } from "../../lib/actionErrors";
import { LS, ownKey } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext";
import { useNavigation } from "../../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { usePlex, usePlexActions } from "../plex/plexContext";
import {
asFilters,
SavedViewsCtx,
type SavedViewsContext,
type ViewSource,
} from "./savedViewsContext";
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
// switches which module it operates on by the active page (both filter providers are global, so it can
// read either), so the shared UI (popover / modal / chip) works on the feed page against feed views and
// on the plex page against plex views without a second provider or an App-layout change.
//
// The context, its value type and the hook live in savedViewsContext.ts.
// The per-source wiring the generic logic runs on: the live filters, how to apply a view's, a stable
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
interface ViewConfig {
source: ViewSource;
filters: Record<string, unknown>;
setFilters: (f: Record<string, unknown>) => void;
keyOf: (f: Record<string, unknown>) => string;
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
* stored filters for the active account, and — feed only — no URL filter override. */
canSeedDefault: () => boolean;
}
const feedKeyOf = (f: Record<string, unknown>) =>
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
// True once this account has its own persisted filters for `lsKey` — the signal that the last
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
// report "stored" so a default never seeds blind over an indeterminate state.
function storedFiltersPresent(lsKey: string): boolean {
const key = ownKey(lsKey);
if (!key) return true;
try {
return localStorage.getItem(key) != null;
} catch {
return true;
}
}
// Feed also yields to a URL that carries filter params (a shared/bookmarked view).
function feedCanSeedDefault(): boolean {
if (hasFilterParams(new URLSearchParams(window.location.search))) return false;
return !storedFiltersPresent(LS.filters);
}
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
function plexCanSeedDefault(): boolean {
return !storedFiltersPresent(LS.plexFilters);
}
export function SavedViewsProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const { page } = useNavigation();
// Both filter providers are global — read both, then use the one the active page owns.
const feedFilters = useFeedFilters();
const { setFilters: setFeedFilters } = useFeedFiltersActions();
const { filters: plexFilters } = usePlex();
const { setFilters: setPlexFilters } = usePlexActions();
const cfg: ViewConfig = useMemo(() => {
if (page === "plex") {
return {
source: "plex",
filters: plexFilters as unknown as Record<string, unknown>,
setFilters: (f) => setPlexFilters(f as unknown as PlexFilters),
keyOf: plexKeyOf,
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
stripForSave: (f) => f, // Plex has no transient field to drop
canSeedDefault: plexCanSeedDefault,
};
}
return {
source: "feed",
filters: feedFilters as unknown as Record<string, unknown>,
setFilters: (f) => setFeedFilters(f as unknown as FeedFilters),
keyOf: feedKeyOf,
asFilters: (raw) =>
asFilters(raw as unknown as FeedFilters) as unknown as Record<string, unknown>,
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
stripForSave: (f) => {
const { seed: _seed, ...rest } = f;
return rest;
},
canSeedDefault: feedCanSeedDefault,
};
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
const source = cfg.source;
// Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too
// (the public landing), and saved views are per-account, so asking for "nobody's" views only logs
// a 401 on the landing page. Enabled once a signed-in account is cached.
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const viewsQuery = useQuery({
queryKey: qk.savedViews(source),
queryFn: () => api.savedViews(source),
enabled: !!me,
});
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
const [stickyId, setStickyId] = useState<number | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex,
// and a feed modal shouldn't linger open on plex. Adjusted DURING RENDER against the previous
// source rather than from an effect, so the first plex render never commits carrying the feed's
// sticky id — which is exactly long enough for a stale view chip to paint.
const [prevSource, setPrevSource] = useState(source);
if (prevSource !== source) {
setPrevSource(source);
setStickyId(null);
setModalOpen(false);
}
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
// stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists
// it, so it happens once and the normal last-view-wins path takes over, exactly as the storage
// semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
// account's stored-filters key, which is always readable here: BootGate hands the browser's local
// state to the account (claimLocalData) before this provider mounts.
const seededRef = useRef<Set<ViewSource>>(new Set());
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: the seed must
// fire on the views query's first resolve for a source and never again, which `seededRef`
// enforces. Listing cfg would re-arm nothing (the guard still holds) but would re-run the effect
// on every keystroke in the filter bar.
const latestCfg = useEffectEvent(() => cfg);
useEffect(() => {
const data = viewsQuery.data;
if (!data || seededRef.current.has(source)) return;
seededRef.current.add(source);
const c = latestCfg();
const def = pickDefaultToSeed(data, c.canSeedDefault());
// The name follows by itself: `preferSticky` picks the starred default among same-filter views.
if (def) c.setFilters(c.asFilters(def.filters));
}, [viewsQuery.data, source]);
const currentKey = cfg.keyOf(cfg.filters);
const activeView = useMemo(
() =>
preferSticky(
views.filter((v) => cfg.keyOf(v.filters) === currentKey),
stickyId,
),
[views, currentKey, stickyId, cfg],
);
// The sticky id follows whichever view is active — also adjusted during render. It converges by
// construction: preferSticky returns the sticky match when there is one, so the very write below
// makes its own condition false on the next pass.
if (activeView && activeView.id !== stickyId) setStickyId(activeView.id);
const stickyView = useMemo(
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
[views, stickyId],
);
const displayView = activeView ?? stickyView;
const isDirty = displayView != null && activeView == null;
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
let ok = true;
try {
await fn();
} catch (e) {
notifyActionError(e, failMsg);
ok = false;
}
refresh();
return ok;
}
const value: SavedViewsContext = {
source,
views,
loading: viewsQuery.isLoading,
activeViewId: activeView?.id ?? null,
displayView,
isDirty,
modalOpen,
openModal: () => setModalOpen(true),
closeModal: () => setModalOpen(false),
apply: (v) => {
cfg.setFilters(cfg.asFilters(v.filters));
setStickyId(v.id);
},
clearActive: () => setStickyId(null),
create: async (name) => {
const clean = name.trim();
if (!clean) return false;
return runViewMutation(
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
t("views.actionFailed"),
);
},
rename: async (id, name) => {
const next = name.trim();
if (!next) return false;
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
},
updateFilters: (id, filters) =>
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
saveCurrent: async (id) => {
const filters = cfg.stripForSave(cfg.filters);
const ok = await runViewMutation(
() => api.updateView(id, { filters }),
t("views.actionFailed"),
);
return ok ? filters : null;
},
toggleDefault: (v) =>
runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }),
t("views.actionFailed"),
).then(() => undefined),
remove: async (v) => {
const ok = await confirm({
message: t("views.deleteConfirm", { name: v.name }),
confirmLabel: t("views.delete"),
danger: true,
});
if (!ok) return false;
if (stickyId === v.id) setStickyId(null);
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
},
reorder: async (nextIds) => {
qc.setQueryData<SavedView[]>(
qk.savedViews(source),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
);
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
},
};
return <SavedViewsCtx.Provider value={value}>{children}</SavedViewsCtx.Provider>;
}
+260
View File
@@ -0,0 +1,260 @@
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { SlidersHorizontal } from "lucide-react";
import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../../lib/api";
import { defaultLayout, type PanelLayout } from "../../lib/panelLayout";
import { useDebounced } from "../../lib/useDebounced";
import { useActiveFilters } from "../../lib/useActiveFilters";
import TagManager from "../channels/TagManager";
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
import { useSavedViews } from "./savedViewsContext";
import FilterRail from "../../components/ui/FilterRail";
import { type PanelItem } from "../../components/ui/PanelGroups";
import { type MediaSourceKind } from "../../lib/mediaSource";
import ChannelPicker from "./ChannelPicker";
import { facetOption } from "./facetOption";
import DateFilterSelect from "./DateFilterSelect";
import YearFilter from "./YearFilter";
import Combobox from "../../components/ui/Combobox";
import MatchToggle from "../../components/ui/MatchToggle";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useChannelsActions } from "../channels/channelsContext";
import { useTags } from "../../lib/useTags";
import { compareText } from "../../lib/collate";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const DATE_PRESETS: { days: number; key: string }[] = [
{ days: 1, key: "24h" },
{ days: 7, key: "1week" },
{ days: 14, key: "2weeks" },
{ days: 30, key: "1month" },
{ days: 90, key: "3months" },
{ days: 180, key: "6months" },
{ days: 365, key: "1year" },
{ days: 730, key: "2years" },
{ days: 1825, key: "5years" },
];
export default function Sidebar({
layout,
setLayout,
collapsed,
onToggleCollapse,
}: {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { openModal } = useSavedViews();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
const { focusChannel: onFocusChannel } = useChannelsActions();
const tagsQuery = useTags();
const tags = tagsQuery.data ?? [];
// Live per-tag channel counts for the current filter context. Debounce the search term so
// typing doesn't refire facets per keystroke, and keep previous counts during a refetch.
const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) };
// Shared with the feed's count (same key + filters -> React Query dedupes to one request): the feed
// reads `.count`, this reads `.facets` (PERF-13, replaces the separate facets query).
const summaryQuery = useQuery({
queryKey: qk.feedSummary(facetFilters),
queryFn: () => api.feedSummary(facetFilters),
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const facetsReady = !!summaryQuery.data;
const facetCounts = summaryQuery.data?.facets ?? {};
// Ordering falls back to the tag's whole-library count until the facets land; the number shown does
// not (facetOption).
const chipCount = (tag: Tag): number =>
facetsReady ? (facetCounts[String(tag.id)] ?? 0) : tag.channel_count;
const visibleChips = (list: Tag[]): Tag[] => {
const shown = facetsReady
? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id))
: list;
return [...shown].sort(
(a, b) => chipCount(b) - chipCount(a) || compareText(a.name, b.name, lang),
);
};
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
const userTags = tags.filter((t) => !t.system);
const [tagManagerOpen, setTagManagerOpen] = useState(false);
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
// the panel layout key. `FilterRail` owns the customize `editing` state now.
const source: MediaSourceKind = "feed";
// The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns
// only its own slice: its selection replaces the ids that belong to its list, leaving the others.
const tagOption = (tg: Tag) => facetOption(tg, summaryQuery.data?.facets);
const selectedIn = (list: Tag[]): string[] =>
filters.tags.filter((id) => list.some((tg) => tg.id === id)).map(String);
function setTagSelection(list: Tag[], next: string[]) {
const inList = new Set(list.map((tg) => tg.id));
setFilters({
...filters,
tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)],
});
}
// Each tag category (language/topic/other) has its OWN any/all match, set from the switch above its
// picker (ui/MatchToggle); the store keeps the older or/and words.
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
// The "Your tags" combobox carries the "No tag" sentinel (item B) at the top: selecting it filters to
// channels you HAVEN'T personally tagged in the channel manager (`filters.untagged`). It's mutually
// exclusive with picking real user tags — choosing it clears your tag picks, and picking a tag clears
// it. Language/Topic are auto/system tags, so "No tag" is meaningless there and only lives here.
function userTagsCombo(): ReactNode {
const noneOn = !!filters.untagged;
const inList = (id: number) => userTags.some((tg) => tg.id === id);
const onChange = (next: string[]) => {
if (next.includes(NO_TAG_SENTINEL) && !noneOn) {
setFilters({ ...filters, tags: filters.tags.filter((id) => !inList(id)), untagged: true });
} else {
const reals = next.filter((v) => v !== NO_TAG_SENTINEL).map(Number);
setFilters({
...filters,
tags: [...filters.tags.filter((id) => !inList(id)), ...reals],
untagged: false,
});
}
};
return (
<>
<MatchToggle
picked={selectedIn(userTags).length}
value={filters.tagModes.other === "and" ? "all" : "any"}
onChange={(m) => setTagMode("other", m === "all" ? "and" : "or")}
/>
<Combobox
label={t("sidebar.widget.tags")}
options={[
{ value: NO_TAG_SENTINEL, label: t("sidebar.noTagOption") },
...visibleChips(userTags).map(tagOption),
]}
selected={noneOn ? [NO_TAG_SENTINEL] : selectedIn(userTags)}
onChange={onChange}
emptyText={t("sidebar.noMatchingTags")}
/>
</>
);
}
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
// disagree about what's applied.
const activeCount = useActiveFilters(filters, setFilters).length;
const available: Record<string, boolean> = {
date: true,
year: true,
channel: true,
language: languages.length > 0,
topic: topics.length > 0,
tags: userTags.length > 0,
};
function widgetBody(id: string): ReactNode {
switch (id) {
case "channel":
return <ChannelPicker filters={filters} setFilters={setFilters} />;
case "date":
return (
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
);
case "year":
return <YearFilter filters={filters} setFilters={setFilters} />;
case "language":
return (
<>
<MatchToggle
picked={selectedIn(languages).length}
value={filters.tagModes.language === "and" ? "all" : "any"}
onChange={(m) => setTagMode("language", m === "all" ? "and" : "or")}
/>
<Combobox
label={t("sidebar.widget.language")}
options={visibleChips(languages).map(tagOption)}
selected={selectedIn(languages)}
onChange={(next) => setTagSelection(languages, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
</>
);
case "topic":
return (
<>
<MatchToggle
picked={selectedIn(topics).length}
value={filters.tagModes.topic === "and" ? "all" : "any"}
onChange={(m) => setTagMode("topic", m === "all" ? "and" : "or")}
/>
<Combobox
label={t("sidebar.widget.topic")}
options={visibleChips(topics).map(tagOption)}
selected={selectedIn(topics)}
onChange={(next) => setTagSelection(topics, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
</>
);
case "tags":
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
// body is just the picker — user tags plus the "No tag" sentinel.
return userTagsCombo();
default:
return null;
}
}
// Group ids come from the shared default order (panelLayout) so there's one source of truth;
// the actual render order is the user's saved layout (PanelGroups orders by it).
const items: PanelItem[] = defaultLayout(source)
.order.filter((id) => available[id])
.map((id) => ({
id,
title: t("sidebar.widget." + id),
body: widgetBody(id),
// The widgets aren't collapsible, so a widget's title can act as its own trigger: "Your tags"
// opens the tag manager (replacing the old inline Manage button).
onTitleClick: id === "tags" ? () => setTagManagerOpen(true) : undefined,
titleActionLabel: id === "tags" ? t("sidebar.manageTags") : undefined,
}));
return (
<FilterRail
kind={source}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
// The feed widgets are simple label + control rows, not collapsible islands.
collapsible={false}
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
// full manager modal. Feed rail only.
iconExpanded={
source === "feed" ? (
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
) : undefined
}
onTitleClick={source === "feed" ? openModal : undefined}
titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined}
>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
</FilterRail>
);
}
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
//
// The chip-overflow popovers are open/closed by a DERIVED flag (`openRaw && hidden > 0`), which is
// what keeps a menu from outliving the "+N" trigger it is anchored to for a frame. The trap the
// derivation sets is that it hides the surface without closing it: floating-ui only reports a close
// for a real event on a mounted surface, so when the lane stops overflowing, the trigger and the
// menu disappear while the raw flag stays true — and the next re-fit that overflows again brings
// back a menu nobody asked for. `useChipOverflowFit` re-decides from a ResizeObserver, so that
// second re-fit needs no user action at all.
//
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
// measurement can only ever produce one answer.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) => (o?.count != null ? `${k}:${o.count}` : k),
}),
};
});
const fit = { visible: 1 };
vi.mock("../../lib/useChipOverflowFit", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/useChipOverflowFit")>();
return { ...actual, useChipOverflowFit: () => fit.visible };
});
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
const chip = (key: string): FilterChip => ({ key, label: key, active: false, onToggle: () => {} });
const chips = [chip("alpha"), chip("beta"), chip("gamma")];
// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
// `hidden: true` query can see it. Presence is the whole question here, not visibility.
const menu = () => screen.queryByRole("menu", { hidden: true });
const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
afterEach(() => {
cleanup();
fit.visible = 1;
});
describe("TagFilterChips — the overflow menu", () => {
it("does not come back on its own once the lane stops overflowing", () => {
const { rerender } = render(<TagFilterChips chips={chips} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
// The lane widens (a resize, the text-size slider, the trailing Clear button unmounting) and
// nothing is collapsed any more: both trigger and menu go.
fit.visible = 3;
rerender(<TagFilterChips chips={chips} />);
expect(menu()).toBeNull();
expect(moreBtn()).toBeNull();
// …and it narrows again, with no user interaction in between. The menu must stay shut, and the
// returning trigger must not claim to be expanded.
fit.visible = 1;
rerender(<TagFilterChips chips={chips} />);
expect(menu()).toBeNull();
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
});
it("does not come back when the chip set SHRINKS under the open menu", () => {
// The nastier half of the same bug, and it needs the hook's real timing to reproduce.
// `useChipOverflowFit` keeps `visible` in state and only rewrites it from a LAYOUT EFFECT, so
// the render in which the set shrinks still carries the measurement taken for the PREVIOUS,
// longer set. `length - visible` is negative in that render, a `hidden === 0` guard reading it
// unclamped never fires, and the flag survives the one window it exists to close.
//
// So `fit.visible` is left ALONE across the shrink — that staleness is the precondition, not a
// convenience of the mock. Five chips of which three fit, then three of them are deleted in the
// tag manager — so the shrink render computes `chips.length - visible` = 2 - 3 = -1.
const five = ["alpha", "beta", "gamma", "delta", "epsilon"].map(chip);
fit.visible = 3;
const { rerender } = render(<TagFilterChips chips={five} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
rerender(<TagFilterChips chips={five.slice(0, 2)} />); // `visible` is still the stale 3
expect(menu()).toBeNull();
// …and now the layout effect lands: two long names, only one of them fits.
fit.visible = 1;
rerender(<TagFilterChips chips={five.slice(0, 2)} />);
expect(menu()).toBeNull();
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
});
it("still opens and closes on the trigger", () => {
render(<TagFilterChips chips={chips} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
fireEvent.click(moreBtn()!);
expect(menu()).toBeNull();
});
});
@@ -0,0 +1,147 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
import { Chip } from "../../components/ui/Chip";
import { chipClass } from "../../components/ui/chipStyles";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { SURFACE } from "../../components/ui/surfaces";
import { ChipLabelPending } from "../../components/ui/Skeleton";
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
// (ActiveFilterChips), sharing the same measure core (useChipOverflowFit + fitChipsWithPill). Used by
// the channel manager's tag-filter row so it never wraps to a second line and the trailing "…" actions
// stay pinned on the first line. Any fixed leading (the TAGS editor trigger) / trailing (Clear, the "…"
// menu) chrome lives OUTSIDE this lane in the caller, so only the chips themselves fold into "+N".
export interface FilterChip {
key: string;
label: string;
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
pending?: boolean;
active: boolean;
onToggle: () => void;
}
// A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror
// measures what shows (ActiveFilterChips' ChipText, for this row's chips).
function ChipText({ chip }: { chip: FilterChip }) {
return chip.pending ? <ChipLabelPending value={chip.label} /> : <>{chip.label}</>;
}
function TagChip({ chip }: { chip: FilterChip }) {
return (
<Chip onClick={chip.onToggle} pressed={chip.active}>
<ChipText chip={chip} />
</Chip>
);
}
export default function TagFilterChips({
chips,
className,
}: {
chips: FilterChip[];
className?: string;
}) {
const { t } = useTranslation();
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRefs = useRef<Array<HTMLElement | null>>([]);
const pillRef = useRef<HTMLSpanElement | null>(null);
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
const key = chips
.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
.join("|");
const visible = useChipOverflowFit<number>({
rowRef,
chipsRef: chipRefs,
pillRef,
deps: key,
initial: chips.length,
decide: (g) =>
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
});
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
// the flag survives exactly the window it exists to close, and the popover springs back on the
// next re-fit.
const hidden = Math.max(0, chips.length - visible);
// The popover can only be open while there is a "+N" trigger to anchor it. Derived rather than
// closed by an effect, so the frame in which the last overflowing chip fits again cannot also
// contain a menu hanging off a reference that is already gone.
//
// Deriving alone is NOT enough, and the missing half is the flag itself: floating-ui only reports
// a close for a real event on a mounted surface, so when the lane stops overflowing the trigger
// and the menu just disappear and `openRaw` stays true. The next re-fit that overflows again —
// a window resize, the text-size slider, or the trailing Clear button mounting as a tag is
// toggled; `useChipOverflowFit` re-decides from a ResizeObserver, with no user near this menu —
// would then re-open a menu nobody asked for, with its trigger already `aria-expanded`. So the
// flag is reset in RENDER against the same condition that hides it: convergent (the write
// falsifies it) and, unlike an effect, gone before the frame is painted.
const [openRaw, setOpen] = useState(false);
if (openRaw && hidden === 0) setOpen(false);
const open = openRaw && hidden > 0;
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-end",
});
const { setReference } = pop.refs;
const moreLabel = t("channels.tags.moreCount", { count: hidden });
return (
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
read each chip's right edge, the rem gap and the pill width off real boxes. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5 whitespace-nowrap"
>
{chips.map((c, i) => (
<span
key={c.key}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<span className={chipClass()}>
<ChipText chip={c} />
</span>
</span>
))}
<span ref={pillRef} className={chipClass()}>
+{chips.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => (
<TagChip key={c.key} chip={c} />
))}
{hidden > 0 && (
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
+{hidden}
</Chip>
)}
</div>
<PopoverSurface
popover={pop}
ariaLabel={moreLabel}
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
>
{chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} />
))}
</PopoverSurface>
</div>
);
}
@@ -0,0 +1,201 @@
// @vitest-environment jsdom
//
// A video's ONE action list. The right-click and the card's "…" open the same menu, which
// holds everything the card no longer shows (download, details, copy, hide, reset, pin) beside the
// everyday toggles, and each row fires the same handler the card's own buttons do.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Video } from "../../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/api")>();
return {
...actual,
api: {
...actual.api,
comfortPins: () => Promise.resolve({ items: [] }),
downloadIndex: () => Promise.resolve({}),
playlists: () => Promise.resolve([]),
},
};
});
const copyText = vi.fn((_: string) => Promise.resolve(true));
vi.mock("../../lib/clipboard", () => ({ copyText: (s: string) => copyText(s) }));
import { VideoMenu } from "./VideoMenu";
import { MoreButton } from "../../components/ui/MoreButton";
import { useContextMenu } from "../../components/ui/useContextMenu";
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.clearAllMocks();
});
// The surface is visibility:hidden in jsdom (floating-ui never positions), so an item has no
// accessible name there: matched on its text, as ContextMenu.dom.test.tsx does.
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
const item = (label: string) => {
const found = screen
.queryAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
return found;
};
const video = (extra: Partial<Video>): Video =>
({
id: "v1",
title: "A video",
channel_id: "c1",
channel_title: "A channel",
watch_url: "https://youtu.be/v1",
status: "new",
saved: false,
position_seconds: 0,
...extra,
}) as Video;
function Feed({
v,
handlers,
}: {
v: Video;
handlers: Parameters<typeof VideoMenu>[0]["handlers"];
}) {
const menu = useContextMenu<Video>();
return (
<>
<div {...menu.triggerProps(v)}>card</div>
<MoreButton
label="more"
expanded={menu.open && menu.target?.id === v.id}
menuProps={menu.buttonProps(v)}
/>
<VideoMenu menu={menu} handlers={handlers} />
</>
);
}
function setup(v: Video) {
const handlers = {
onState: vi.fn(),
onToggleSave: vi.fn(),
onResetState: vi.fn(),
onOpen: vi.fn(),
};
const qc = new QueryClient();
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<Feed v={v} handlers={handlers} />
</FloatingTree>
</QueryClientProvider>,
);
return { handlers };
}
const rightClick = () => fireEvent.contextMenu(screen.getByText("card"));
const more = () => screen.getByRole("button", { name: "more" });
describe("a video's menu", () => {
it("lists the whole action list for a fresh video, in its groups", () => {
setup(video({}));
rightClick();
expect(labels()).toEqual([
"card.play",
"card.menu.openChannel",
"card.markWatched",
"card.saveForLater",
"playlists.addToPlaylist",
"downloads.menu.download",
"card.info",
"common.copy.title",
"common.copy.link",
"card.hide",
"home.pin.add",
]);
});
it("offers Continue and the restart on a half-watched one, the way back and the reset", async () => {
const { handlers } = setup(video({ position_seconds: 30, saved: true }));
rightClick();
expect(labels()).toContain("card.restartTitle");
expect(labels()).toContain("card.menu.unsave");
expect(labels()).toContain("card.resetState");
fireEvent.click(item("card.restartTitle"));
await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenLastCalledWith(expect.anything(), 0));
});
it("each toggle fires the card's own handler", async () => {
const { handlers } = setup(video({ status: "watched" }));
rightClick();
fireEvent.click(item("card.menu.unmarkWatched"));
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new"));
rightClick();
fireEvent.click(item("card.hide"));
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "hidden"));
});
it("copies the link INSIDE the click, before the menu closes (Safari's gesture rule)", () => {
setup(video({}));
rightClick();
fireEvent.click(item("common.copy.link"));
// Synchronously: a row that went through closeThenAct would run a task later.
expect(copyText).toHaveBeenCalledWith("https://youtu.be/v1");
expect(labels()).toEqual([]);
});
it("opens the playlists panel from the menu, anchored where the menu came from", async () => {
setup(video({}));
rightClick();
fireEvent.click(item("playlists.addToPlaylist"));
expect(await screen.findByText("playlists.noneYet")).toBeTruthy();
});
it("the '…' opens the same menu, says so, and a second press closes it", () => {
setup(video({}));
expect(more().getAttribute("aria-haspopup")).toBe("menu");
expect(more().getAttribute("aria-expanded")).toBe("false");
fireEvent.click(more());
expect(labels()).toContain("card.hide");
expect(more().getAttribute("aria-expanded")).toBe("true");
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
// it must not open it again.
fireEvent.pointerDown(more(), { pointerType: "mouse" });
fireEvent.mouseDown(more());
fireEvent.click(more(), { detail: 1 });
expect(labels()).toEqual([]);
});
it("a keyboard click on the '…' always opens, whatever an earlier press left behind", () => {
setup(video({}));
fireEvent.click(more(), { detail: 1 });
// A press on the open row's "…" that never became a click (released elsewhere)…
fireEvent.pointerDown(more(), { pointerType: "mouse" });
fireEvent.keyDown(document, { key: "Escape" });
// …then Enter on the button: a click with no pointer (detail 0).
fireEvent.click(more(), { detail: 0 });
expect(labels()).toContain("card.hide");
});
});
+30
View File
@@ -0,0 +1,30 @@
import { useTranslation } from "react-i18next";
import { type Video } from "../../lib/api";
import { ContextMenu } from "../../components/ui/ContextMenu";
import { type ContextMenuController } from "../../components/ui/useContextMenu";
import { useVideoActions, type VideoActionHandlers } from "../../features/video/useVideoActions";
/** A video list's one menu (the feed grid and a channel page): every trigger — right-click,
* long-press, Shift+F10 and each card's "…" — opens it with the video's whole action list. Render
* ONE per list, outside the cards (see `ui/ContextMenu`). */
export function VideoMenu({
menu,
handlers,
}: {
menu: ContextMenuController<Video>;
handlers: VideoActionHandlers;
}) {
const { t } = useTranslation();
const actions = useVideoActions(handlers, menu.open);
const v = menu.target;
return (
<>
<ContextMenu
menu={menu}
ariaLabel={t("card.menu.label")}
items={v ? actions.items(v, menu.anchor) : []}
/>
{actions.panel}
</>
);
}
+99
View File
@@ -0,0 +1,99 @@
import type { Video, VideoStatus } from "../../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../../lib/feedView";
import type { NEXT_PAGE } from "../../lib/nextPage";
import VideoCard from "../../features/video/VideoCard";
import { VideoMenu } from "./VideoMenu";
import type { VideoActionHandlers } from "../../features/video/useVideoActions";
import { useContextMenu } from "../../components/ui/useContextMenu";
import VirtualGrid from "../../components/ui/VirtualGrid";
// Estimated row heights before measurement; real heights are measured per-row so these only affect
// the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the
// browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in.
// RE-MEASURE these when a mode's layout changes: they have gone stale twice already, most recently
// when the action row moved onto the card's picture (cards 340 -> 262, small 257 -> 226).
// The card figures are the roughest of the set by nature — a card's height follows its column width
// (262 measured at 4 columns in a 1268px window), so no single constant is right for every window.
const ROW_EST: Record<FeedView, number> = {
cards: 262,
cardsSmall: 226,
rows: 117,
rowsCompact: 46,
tiles: 138,
};
export interface VirtualFeedProps {
items: Video[];
view: FeedView;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onResetState: (id: string) => void;
onOpen: (v: Video, startAt?: number | null) => void;
/** The card menu's channel-filter row (see `useVideoActions`); omitted where it has no place. */
channelFilter?: VideoActionHandlers["channelFilter"];
hasNextPage: boolean;
isFetching: boolean;
fetchNextPage: (options: typeof NEXT_PAGE) => unknown;
}
// The feed's videos on the shared VirtualGrid engine — this wrapper only maps a FeedView to the
// grid's layout spec and renders a VideoCard per item. All the virtualization mechanics (measure,
// chunk, place, infinite-scroll) live in VirtualGrid.
export default function VirtualFeed({
items,
view,
onState,
onToggleSave,
onResetState,
onOpen,
channelFilter,
hasNextPage,
isFetching,
fetchNextPage,
}: VirtualFeedProps) {
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
// The card menu (the video's whole action list) lives HERE rather than in each of the
// feed's callers: this is the list, and `useContextMenu`'s contract is one menu per list. Mounting
// it at this level is also what gives the channel page the menu for free — it renders
// `<Feed channelScoped>`, so it is the same component all the way down.
const menu = useContextMenu<Video>();
const openId = menu.open ? menu.target?.id : undefined;
return (
<>
<VirtualGrid
items={items}
getKey={(v) => v.id}
minCol={minCol}
colMaxWidth={colMaxWidth}
maxCols={maxCols}
rowEst={ROW_EST[view]}
hasNextPage={hasNextPage}
isFetching={isFetching}
fetchNextPage={fetchNextPage}
keyboardNav
renderItem={(v, itemWidth, index) => (
<VideoCard
video={v}
view={view}
navIndex={index}
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
width={thumb ? 0 : itemWidth}
onState={onState}
onToggleSave={onToggleSave}
onOpen={onOpen}
menuProps={menu.triggerProps}
menuButtonProps={menu.buttonProps}
// A boolean per card, not the open id: only the two cards whose answer changes re-render.
menuOpen={openId === v.id}
/>
)}
/>
<VideoMenu
menu={menu}
handlers={{ onState, onToggleSave, onResetState, onOpen, channelFilter }}
/>
</>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { ChevronDown } from "lucide-react";
import { MenuCheck } from "../../components/ui/MenuCheck";
import { qk } from "../../lib/queryKeys";
import { api, type FeedFilters, type YearCount } from "../../lib/api";
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { Chip } from "../../components/ui/Chip";
import { Button } from "../../components/ui/Button";
import CountBadge from "../../components/ui/CountBadge";
import { useDebounced } from "../../lib/useDebounced";
import { SURFACE } from "../../components/ui/surfaces";
// The feed rail's "Year" control: quick chips for the most recent years plus a
// "More…" popover listing ALL years with per-year counts, sortable by year or count. Selecting a year
// filters the feed to that calendar year (a whole-year published_after/before range, applied in the
// api layer); it's a single-select toggle (click the active year to clear it) and mutually exclusive
// with the Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
//
// The per-year counts come from /feed/years, which drops the publish-date narrowing itself, so the
// list is keyed on the date-stripped filters: picking a year re-queries the FEED but never this list
// (its numbers wouldn't change) — the same drill-down principle as the tag facets dropping their
// own category.
const QUICK = 6; // recent-year chips shown before the "More…" overflow
// One shared empty list while the counts load: a fresh `[]` per render made the memos below recompute.
const NO_YEARS: YearCount[] = [];
export default function YearFilter({
filters,
setFilters,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [sortByCount, setSortByCount] = useState(false);
// Debounce the search term (like the sibling summary facet in Sidebar) so typing doesn't refire the
// per-year count query on every keystroke — each distinct q is a fresh cache key, so without this
// an N-char query would issue N /feed/years requests. The year/date narrowings are dropped: this
// list is a live drill-down that must not change when a year (or upload-date) is picked.
const facetFilters: FeedFilters = {
...filters,
q: useDebounced(filters.q, 300),
year: undefined,
maxAgeDays: undefined,
dateFrom: undefined,
dateTo: undefined,
};
const yearsQuery = useQuery({
queryKey: qk.feedYears(facetFilters),
queryFn: () => api.feedYears(facetFilters),
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const years = yearsQuery.data?.years ?? NO_YEARS;
const selected = filters.year;
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
const pick = (year?: number) =>
setFilters({ ...filters, year, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined });
// Quick chips = the most recent years, plus the selected year if it falls outside that window (so the
// active pick is always visible), re-sorted newest-first.
const quick = useMemo(() => {
const top = years.slice(0, QUICK);
if (selected != null && !top.some((y) => y.year === selected)) {
const sel = years.find((y) => y.year === selected) ?? { year: selected, count: 0 };
return [...top, sel].sort((a, b) => b.year - a.year);
}
return top;
}, [years, selected]);
const sorted = useMemo(
() =>
[...years].sort((a, b) =>
sortByCount ? b.count - a.count || b.year - a.year : b.year - a.year,
),
[years, sortByCount],
);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
});
const { setReference } = pop.refs;
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
if (years.length === 0) {
if (yearsQuery.isLoading) {
return (
<p className="text-muted px-1 py-1.5 text-xs" role="status" aria-live="polite">
{t("common.loading")}
</p>
);
}
if (yearsQuery.isError) {
return (
<p className="text-muted px-1 py-1.5 text-xs" role="alert">
{t("common.loadError")}
</p>
);
}
return <p className="text-muted px-1 py-1.5 text-xs">{t("sidebar.year.none")}</p>;
}
return (
<div className="flex flex-wrap gap-1.5">
{quick.map((y) => {
const on = selected === y.year;
return (
<Chip key={y.year} pressed={on} onClick={() => pick(on ? undefined : y.year)}>
<span className="tabular-nums">{y.year}</span>
{y.count > 0 && <CountBadge count={y.count} active={on} />}
</Chip>
);
})}
{years.length > quick.length && (
<>
<Chip
ref={setReference}
{...pop.getReferenceProps()}
aria-label={t("sidebar.year.moreLabel")}
>
<span>{t("sidebar.year.more")}</span>
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
</Chip>
<PopoverSurface
popover={pop}
ariaLabel={t("sidebar.year.moreLabel")}
className={`${SURFACE.menu} min-w-44 animate-[popIn_0.16s_ease]`}
>
<div className="mb-1 flex items-center gap-2 border-b border-border px-1 pb-2">
<span className="text-2xs text-muted">{t("sidebar.year.sortBy")}</span>
<div className="ml-auto flex gap-1">
{(
[
["sortYear", false],
["sortCount", true],
] as const
).map(([key, v]) => (
<Button
key={key}
variant="outline"
size="xs"
pressed={sortByCount === v}
onClick={() => setSortByCount(v)}
>
{t("sidebar.year." + key)}
</Button>
))}
</div>
</div>
<div
className="max-h-64 overflow-y-auto no-scrollbar"
role="group"
aria-label={t("sidebar.year.moreLabel")}
>
{sorted.map((y) => {
const on = selected === y.year;
return (
<PopoverItem
key={y.year}
role="menuitemradio"
checked={on}
onSelect={() => {
pick(on ? undefined : y.year);
setOpen(false);
}}
label={String(y.year)}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
<MenuCheck kind="one" on={on} />
<span className="flex-1 tabular-nums">{y.year}</span>
<span className="text-2xs text-muted shrink-0 tabular-nums">{y.count}</span>
</PopoverItem>
);
})}
</div>
</PopoverSurface>
</>
)}
</div>
);
}
@@ -0,0 +1,116 @@
// @vitest-environment jsdom
//
// A channel filter's chips are named from the subscription list, which a reload has not fetched yet:
// they used to read as raw UC… ids for a moment (the owner, 2026-10-03). The names seen last time now
// name them until the list lands; a channel never named on this browser draws a placeholder instead.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement, type ReactNode } from "react";
import { api, type FeedFilters, type ManagedChannel } from "../../lib/api";
import { claimLocalData, LS } from "../../lib/storage";
import { clearedFilters, useActiveFilters } from "../../lib/useActiveFilters";
import ActiveFilterChips from "../../features/filters/ActiveFilterChips";
import ChannelPicker from "./ChannelPicker";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const KNOWN = "UCknown0000000000000000";
const NEW = "UCnever00000000000000000";
const filters: FeedFilters = {
...clearedFilters({ q: "" } as FeedFilters),
channelIds: [KNOWN, NEW],
};
const namesKey = () => LS.channelNames;
let resolveChannels: (c: ManagedChannel[]) => void;
let qc: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client: qc }, children);
beforeEach(() => {
localStorage.clear();
claimLocalData(5);
localStorage.setItem(namesKey(), JSON.stringify({ [KNOWN]: "Remembered name" }));
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.spyOn(api, "tags").mockResolvedValue([] as never);
vi.spyOn(api, "channels").mockReturnValue(
new Promise<ManagedChannel[]>((r) => {
resolveChannels = r;
}),
);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const land = async () => {
await act(async () => {
resolveChannels([
{ id: KNOWN, title: "Renamed since" },
{ id: NEW, title: "Brand new" },
] as ManagedChannel[]);
});
};
describe("a channel filter's chip names", () => {
it("name a remembered channel before the list lands, and hold the rest as pending", async () => {
const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper });
const chip = (id: string) => result.current.find((f) => f.key === `channel:${id}`)!;
expect(chip(KNOWN)).toMatchObject({ label: "Remembered name", pending: false });
expect(chip(NEW)).toMatchObject({ label: NEW, pending: true });
await land();
await waitFor(() => expect(chip(NEW)).toMatchObject({ label: "Brand new", pending: false }));
// The list wins over the remembered copy, and replaces it for the next reload.
expect(chip(KNOWN).label).toBe("Renamed since");
expect(JSON.parse(localStorage.getItem(namesKey())!)).toEqual({
[KNOWN]: "Renamed since",
[NEW]: "Brand new",
});
});
it("draw a placeholder above the feed, not the raw id", () => {
const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper });
const { container } = render(
<ActiveFilterChips filters={result.current} onClearAll={() => {}} />,
);
expect(screen.getAllByText("Remembered name").length).toBeGreaterThan(0);
// The id is only a screen reader's (sr-only), beside a bone; nothing paints it.
for (const el of screen.getAllByText(NEW)) expect(el.className).toBe("sr-only");
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("draw a placeholder in the filter panel's picker, then the name", async () => {
const { container } = render(<ChannelPicker filters={filters} setFilters={() => {}} />, {
wrapper,
});
screen.getByText("Remembered name");
expect(screen.getByText(NEW).className).toBe("sr-only");
expect(container.querySelectorAll(".animate-pulse")).toHaveLength(1);
await land();
await waitFor(() => screen.getByText("Brand new"));
expect(screen.queryByText(NEW)).toBeNull();
expect(container.querySelectorAll(".animate-pulse")).toHaveLength(0);
});
it("fall back to the id when the list fails, so each chip stays told apart", async () => {
localStorage.removeItem(namesKey());
vi.spyOn(api, "channels").mockRejectedValue(new Error("down"));
const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper });
await waitFor(() =>
expect(result.current.find((f) => f.key === `channel:${NEW}`)).toMatchObject({
label: NEW,
pending: false,
}),
);
});
});
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import type { Tag } from "../../lib/api";
import { facetOption } from "./facetOption";
const music = { id: 7, name: "Music", category: "topic", channel_count: 238 } as Tag;
describe("a tag as a filter row", () => {
it("counts the current filter's channels", () => {
expect(facetOption(music, { "7": 3 }).count).toBe(3);
expect(facetOption(music, {}).count).toBe(0);
});
// Seen live: with the summary failed, the rail read "Music 238" beside a search that matched
// 3 Music channels — the whole library's count printed as the filter's.
it("shows no count until the facets land, rather than the whole library's", () => {
expect(facetOption(music, undefined).count).toBeUndefined();
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { Tag } from "../../lib/api";
import type { ComboboxOption } from "../../components/ui/Combobox";
// A tag as a filter row: its count is the CURRENT filter's (the summary's facets), or none at all while
// the facets load or after they failed. The rail printed the tag's whole-library count in that gap, as
// if it were the filter's: "Music 238" beside a search that matched 3 of its channels.
export const facetOption = (
tag: Tag,
facets: Record<string, number> | undefined,
): ComboboxOption => ({
value: String(tag.id),
label: tag.name,
count: facets ? (facets[String(tag.id)] ?? 0) : undefined,
});
@@ -0,0 +1,27 @@
import { createContext, useContext } from "react";
import type { FeedFilters } from "../../lib/api";
import { DEFAULT_FILTERS } from "./feedFiltersInit";
// The feed-filter contexts + hooks, split out of FeedFiltersProvider so that file exports only
// components (Fast Refresh).
//
// TWO contexts on purpose (mirrors NavigationProvider): a STABLE actions context (setFilters never
// changes reference) and a CHANGING state context (the filters object). Context consumers re-render
// only when the context they read changes — so a component that dispatches filter changes without
// reading the current filters can subscribe to actions ONLY and skip re-rendering on every tweak.
export interface FeedFiltersActions {
setFilters: (next: FeedFilters) => void;
}
export const FeedFiltersStateContext = createContext<FeedFilters>(DEFAULT_FILTERS);
export const FeedFiltersActionsContext = createContext<FeedFiltersActions>({
setFilters: () => {},
});
export function useFeedFilters(): FeedFilters {
return useContext(FeedFiltersStateContext);
}
export function useFeedFiltersActions(): FeedFiltersActions {
return useContext(FeedFiltersActionsContext);
}
@@ -0,0 +1,56 @@
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api";
import { LS, ownKey, readJSON } from "../../lib/storage";
// The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider
// (like navigationInit / playlistsInit) so that file exports only components (Fast Refresh).
export const DEFAULT_FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
untagged: false,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
/** The watch-state choices, in the order the filter offers them. */
export const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
export const FILTERS_KEY = LS.filters;
// This account's own persisted filters (see ownKey). No account
// (signed out) → defaults.
export function loadAccountFilters(): FeedFilters {
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
const fKey = ownKey(FILTERS_KEY);
let hasStored = false;
try {
hasStored = !!fKey && localStorage.getItem(fKey) != null;
} catch {
/* localStorage may be unavailable */
}
// Migrate a persisted legacy global `tagMode` into the per-category `tagModes`, matching what the
// saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes MUST run on the
// RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects DEFAULT_TAG_MODES
// (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
if (hasStored && fKey) {
const raw = readJSON<Partial<FeedFilters>>(fKey, {});
return {
...DEFAULT_FILTERS,
...raw,
tagModes: coerceTagModes(raw),
untagged: coerceUntagged(raw),
};
}
return { ...DEFAULT_FILTERS };
}
@@ -0,0 +1,19 @@
import { createContext, useContext } from "react";
import type { FeedView } from "../../lib/feedView";
// The feed view-mode contexts + hooks, split out of FeedViewProvider so that file exports only
// components (Fast Refresh).
export interface FeedViewActions {
setView: (v: FeedView) => void;
}
export const FeedViewStateContext = createContext<FeedView>("cards");
export const FeedViewActionsContext = createContext<FeedViewActions>({ setView: () => {} });
export function useFeedView(): FeedView {
return useContext(FeedViewStateContext);
}
export function useFeedViewActions(): FeedViewActions {
return useContext(FeedViewActionsContext);
}
+43
View File
@@ -0,0 +1,43 @@
import { lazyWithPreload } from "../../lib/lazyWithPreload";
import { useMe } from "../../lib/useMe";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useFeedView, useFeedViewActions } from "./feedViewContext";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
import { useSavedViews } from "./savedViewsContext";
// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
// exported alongside so the registry can attach each chunk's preload to the wrapper that renders it.
export const Feed = lazyWithPreload(() => import("./Feed"));
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search. (The channel
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
export function FeedPage() {
const me = useMe();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
const view = useFeedView();
const { setView } = useFeedViewActions();
const { ytSearch } = useNavigation();
const { enterYtSearch, exitYtSearch } = useNavigationActions();
// The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts
// on navigation) rather than inside the collapsible filter rail — otherwise opening it from the
// always-visible toolbar view chip while the rail is unpinned would render nothing and latch open.
const { modalOpen } = useSavedViews();
return (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/>
{modalOpen && <PersonalViewsModal />}
</>
);
}
@@ -0,0 +1,50 @@
import { createContext, useContext } from "react";
import { coerceTagModes, coerceUntagged, type FeedFilters, type SavedView } from "../../lib/api";
// The saved-views context + hook (and the feed blob coercion its consumers share), split out of
// SavedViewsProvider so that file exports only components (Fast Refresh).
// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it
// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration).
export const asFilters = (raw: FeedFilters): FeedFilters => ({
...raw,
tags: raw.tags ?? [],
channelIds: raw.channelIds ?? [],
tagModes: coerceTagModes(raw),
untagged: coerceUntagged(raw),
});
export type ViewSource = "feed" | "plex";
export interface SavedViewsContext {
/** Which module these views belong to — the shared UI branches its filter-describer on it. */
source: ViewSource;
views: SavedView[];
loading: boolean;
activeViewId: number | null;
displayView: SavedView | null;
isDirty: boolean;
modalOpen: boolean;
openModal: () => void;
closeModal: () => void;
apply: (v: SavedView) => void;
clearActive: () => void;
create: (name: string) => Promise<boolean>;
rename: (id: number, name: string) => Promise<boolean>;
/** Overwrite a view's captured filters (used by the modal's removable chips). */
updateFilters: (id: number, filters: Record<string, unknown>) => Promise<boolean>;
/** Save the module's CURRENTLY-applied filters into a view (the live→stored inverse of `apply`);
* resolves to the saved snapshot on success (so the caller can resync its editor) or null. */
saveCurrent: (id: number) => Promise<Record<string, unknown> | null>;
toggleDefault: (v: SavedView) => Promise<void>;
remove: (v: SavedView) => Promise<boolean>;
reorder: (nextIds: number[]) => Promise<void>;
}
export const SavedViewsCtx = createContext<SavedViewsContext | null>(null);
export function useSavedViews(): SavedViewsContext {
const ctx = useContext(SavedViewsCtx);
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
return ctx;
}