fix(ui): the card's channel name is a real link too; call sites the bug hunt left untested now have tests
Deep-review R6 — the small items: - The video card's channel name was still a <button> (pre-existing): ctrl/middle-click did nothing there while the title above it and the player's channel name honoured them. It renders through ChannelNameLink now, which grew a forwarded ref (the clamp measurement), pass-through attributes, a `tooltip`, and an opt-in `stopPropagation` so no click reaches the card's row. - ChannelNameLink rendered an empty, still-tabbable <a> for a channel with no title yet (a stub first seen through a live search; pre-existing at this site). It falls back to the translated "Channel". - The feed's SortKey→SortOption mapping existed twice in Feed.tsx; one `feedSortOptions` in feedSort.ts serves both modes (DIRECTIONLESS is module-private again). - The backfill refresh's comment claimed a one-page cost. It refetches every loaded page of the grid, and that is the right price: backfilled videos are older, so they land below the head. Tests for call sites that could be deleted with every lane green: the channel page running the backfill refresh (knip had been blinded by the hook test's import), Feed handing the grid the live results in the chosen key AND direction, the card using the link. Plus the alt-click arm of the shared click predicate, and a plex filter-chip test that claimed it made no request while firing a real one. Verified on localdev: the feed's channel names are anchors (same 14 px muted look), a plain click opens the channel in-app. 7 mutations, each red on its assertion.
This commit is contained in:
@@ -7,6 +7,12 @@
|
||||
// path (which does more than navigate — the player has to close and reclaim its history entry first).
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
|
||||
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
||||
@@ -57,6 +63,9 @@ describe("ChannelNameLink", () => {
|
||||
["ctrl-click", { ctrlKey: true }],
|
||||
["cmd-click", { metaKey: true }],
|
||||
["shift-click", { shiftKey: true }],
|
||||
// Alt/Option-click on a link is "download the target" — the one arm no test dispatched, so
|
||||
// dropping it from the shared predicate hijacked it on every card thumbnail too.
|
||||
["alt-click", { altKey: true }],
|
||||
])("leaves %s to the browser", (_name, mod) => {
|
||||
const { link, onOpenInApp } = renderLink();
|
||||
// Neither hijacked in-app nor prevented — the href is left to do its job.
|
||||
@@ -70,4 +79,31 @@ describe("ChannelNameLink", () => {
|
||||
expect(link.getAttribute("href")).toContain("page=channels");
|
||||
expect(link.getAttribute("href")).toContain("q=abc");
|
||||
});
|
||||
|
||||
it("gives a channel with no title yet an accessible name", () => {
|
||||
// A channel first seen through a live search can be a stub with no title. The anchor used to
|
||||
// render empty — still in the tab order, announced by nothing but its href.
|
||||
render(<ChannelNameLink channelId={CH} title={null} onOpenInApp={() => {}} />);
|
||||
expect(screen.getByRole("link", { name: "player.channel" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps every click from a clickable ancestor when asked to — the browser's gestures too", () => {
|
||||
// The video card's row reacts to clicks; its channel name must not trigger it, whichever way it
|
||||
// is clicked.
|
||||
const row = vi.fn();
|
||||
render(
|
||||
<div onClick={row}>
|
||||
<ChannelNameLink channelId={CH} title="LEMMiNO" onOpenInApp={() => {}} stopPropagation />
|
||||
</div>,
|
||||
);
|
||||
const link = screen.getByText("LEMMiNO");
|
||||
// The bubbling guard in clickWasHandled never sees these (that is the point), so stop the
|
||||
// browser's navigation in the capture phase instead.
|
||||
const noNav = (e: Event) => e.preventDefault();
|
||||
document.addEventListener("click", noNav, true);
|
||||
for (const init of [{ button: 0 }, { ctrlKey: true }, { button: 1 }])
|
||||
link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ...init }));
|
||||
document.removeEventListener("click", noNav, true);
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +1,52 @@
|
||||
import { forwardRef, type AnchorHTMLAttributes } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isPlainLeftClick } from "../lib/nav";
|
||||
import { urlWithParams } from "../lib/urlState";
|
||||
|
||||
// A channel name that is a REAL link. The player showed it as a `<button>`, so the browser's own
|
||||
// gestures did nothing on it: middle-click and Ctrl/Cmd-click opened no tab, on a thing that looks
|
||||
// exactly like a link (the YouTube icon right next to it IS an anchor and does open one).
|
||||
// exactly like a link (the YouTube icon right next to it IS an anchor and does open one). The video
|
||||
// card's channel name had the same `<button>`, so it renders through here too.
|
||||
//
|
||||
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
|
||||
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
|
||||
// the SPA path does more than navigate (the player has to close first and reclaim its own
|
||||
// Back-to-close history entry; see closeThenNavigate).
|
||||
export default function ChannelNameLink({
|
||||
channelId,
|
||||
title,
|
||||
onOpenInApp,
|
||||
className,
|
||||
}: {
|
||||
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
|
||||
channelId: string;
|
||||
title: string | null | undefined;
|
||||
/** The in-app navigation, run only for a plain left click. */
|
||||
onOpenInApp: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
/** The native tooltip (`title` is taken by the channel's name). */
|
||||
tooltip?: string;
|
||||
/** Keep EVERY click on the link — the browser's own gestures too — from reaching a clickable
|
||||
* ancestor (the video card's row). */
|
||||
stopPropagation?: boolean;
|
||||
};
|
||||
|
||||
const ChannelNameLink = forwardRef<HTMLAnchorElement, Props>(function ChannelNameLink(
|
||||
{ channelId, title, onOpenInApp, tooltip, stopPropagation, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
ref={ref}
|
||||
href={urlWithParams({ chan: channelId })}
|
||||
title={tooltip}
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
if (!isPlainLeftClick(e)) return; // let the browser open its tab/window
|
||||
e.preventDefault();
|
||||
onOpenInApp();
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{title}
|
||||
{/* A channel first seen through a live search can have no title yet. An empty anchor would
|
||||
still be in the tab order, announced by nothing but its href. */}
|
||||
{title || t("player.channel")}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default ChannelNameLink;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// @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("./NavigationProvider", () => ({
|
||||
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./FeedFiltersProvider", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("./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));
|
||||
});
|
||||
});
|
||||
@@ -27,9 +27,8 @@ import { notify, resolveVideo } from "../lib/notifications";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import {
|
||||
buildSort,
|
||||
DIRECTIONLESS,
|
||||
feedSortOptions,
|
||||
parseSort,
|
||||
SORT_DEFAULT_DIR,
|
||||
SORT_KEYS,
|
||||
sortYtResults,
|
||||
YT_SORT_KEYS,
|
||||
@@ -447,14 +446,7 @@ export default function Feed({
|
||||
{/* 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={YT_SORT_KEYS.map((k) => ({
|
||||
key: k,
|
||||
label: t("feed.sortKey." + k),
|
||||
directionless: DIRECTIONLESS.includes(k),
|
||||
defaultDir: DIRECTIONLESS.includes(k)
|
||||
? undefined
|
||||
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
|
||||
}))}
|
||||
options={feedSortOptions(YT_SORT_KEYS, (k) => t("feed.sortKey." + k))}
|
||||
sort={ytSort}
|
||||
onChange={(next) => setYtSort(next ?? { key: "relevance", dir: "desc" })}
|
||||
label={t("feed.sortLabel")}
|
||||
@@ -589,16 +581,10 @@ export default function Feed({
|
||||
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[] = (
|
||||
filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys
|
||||
).map((k) => ({
|
||||
key: k,
|
||||
label: t("feed.sortKey." + k),
|
||||
directionless: DIRECTIONLESS.includes(k),
|
||||
defaultDir: DIRECTIONLESS.includes(k)
|
||||
? undefined
|
||||
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
|
||||
}));
|
||||
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
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// @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 deep-review of 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("./WizardProvider", () => ({ 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,77 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The video card's channel name was a <button>: a plain click opened the channel in-app, but
|
||||
// middle-click and Ctrl/Cmd-click did nothing — while the title right above it, an anchor, honoured
|
||||
// them. Found by the 2026-09 bug-hunt deep-review, when the player's channel name had just become a
|
||||
// real link and the card's had not. (The link's own gesture rules are pinned in
|
||||
// ChannelNameLink.dom.test.tsx; this pins that the CARD uses it.)
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({ openChannel: vi.fn() }));
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./NavigationProvider", () => ({
|
||||
useNavigationActions: () => ({ openChannel: h.openChannel }),
|
||||
}));
|
||||
|
||||
import VideoCard from "./VideoCard";
|
||||
import type { Video } 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;
|
||||
});
|
||||
|
||||
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
||||
const video = {
|
||||
id: "abc123",
|
||||
title: "Cicada 3301",
|
||||
channel_id: CH,
|
||||
channel_title: "LEMMiNO",
|
||||
status: "new",
|
||||
saved: false,
|
||||
position_seconds: 0,
|
||||
duration_seconds: 1200,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: "https://www.youtube.com/watch?v=abc123",
|
||||
thumbnail_url: "",
|
||||
} as unknown as Video;
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
h.openChannel.mockClear();
|
||||
window.history.replaceState({}, "", "/?page=feed");
|
||||
});
|
||||
|
||||
describe("VideoCard — the channel name", () => {
|
||||
it("is a real link to the channel, and a plain click still opens it in-app", () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
view="cards"
|
||||
width={320}
|
||||
onState={() => {}}
|
||||
onToggleSave={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const link = screen.getByTestId("video-card-channel");
|
||||
expect(link.tagName).toBe("A");
|
||||
expect(link.getAttribute("href")).toBe(`/?page=feed&chan=${CH}`);
|
||||
link.click();
|
||||
expect(h.openChannel).toHaveBeenCalledWith(CH, "LEMMiNO");
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import type { Video, VideoStatus } from "../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||
import { isPlainLeftClick } from "../lib/nav";
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
|
||||
function Actions({
|
||||
video,
|
||||
@@ -439,7 +440,7 @@ function VideoCard({
|
||||
// and the channel name: both truncate in some views and fit in others, and a tooltip that just
|
||||
// repeats what's already legible is noise.
|
||||
const titleRef = useRef<HTMLAnchorElement>(null);
|
||||
const channelRef = useRef<HTMLButtonElement>(null);
|
||||
const channelRef = useRef<HTMLAnchorElement>(null);
|
||||
const [titleClamped, setTitleClamped] = useState(false);
|
||||
const [channelClamped, setChannelClamped] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -473,19 +474,22 @@ function VideoCard({
|
||||
{video.title}
|
||||
</a>
|
||||
);
|
||||
const channelButton = (
|
||||
<button
|
||||
// A real link, like the title above it: a <button> here ignored middle-click and Ctrl/Cmd-click,
|
||||
// so the same card honoured the browser's gestures on its title and silently dropped them on its
|
||||
// channel name.
|
||||
const channelLink = (
|
||||
<ChannelNameLink
|
||||
ref={channelRef}
|
||||
data-testid="video-card-channel"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openChannel(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
||||
}}
|
||||
title={channelClamped ? (video.channel_title ?? undefined) : undefined}
|
||||
channelId={video.channel_id}
|
||||
title={video.channel_title}
|
||||
onOpenInApp={() =>
|
||||
openChannel(video.channel_id, video.channel_title ?? t("card.thisChannel"))
|
||||
}
|
||||
stopPropagation
|
||||
tooltip={channelClamped ? (video.channel_title ?? undefined) : undefined}
|
||||
className="text-sm text-muted truncate block w-fit max-w-full text-left hover:text-fg"
|
||||
>
|
||||
{video.channel_title}
|
||||
</button>
|
||||
/>
|
||||
);
|
||||
const actions = (
|
||||
<Actions
|
||||
@@ -547,7 +551,7 @@ function VideoCard({
|
||||
filter panel are doing. Until the first measure lands (width 0) show everything: the
|
||||
row is off-screen for that frame anyway, and guessing narrow would flash. */}
|
||||
<div className="min-w-0 flex-1">{title}</div>
|
||||
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelButton}</div>}
|
||||
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelLink}</div>}
|
||||
{fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
|
||||
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
|
||||
holds the widest bare form ("101.7K" at 48) in either language — dropping the word
|
||||
@@ -578,7 +582,7 @@ function VideoCard({
|
||||
const textBlock = (
|
||||
<>
|
||||
{title}
|
||||
<div className="mt-0.5">{channelButton}</div>
|
||||
<div className="mt-0.5">{channelLink}</div>
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{views}
|
||||
{views && " · "}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
// for the in-panel narrowings (expanded filters + watch-state) but NO LONGER emits a sort chip —
|
||||
// sort is an always-visible control above the grid now, like the Feed. i18n is key-passthrough and
|
||||
// the provider is mocked so the hook can be driven with a non-default sort present.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { EMPTY_PLEX_FILTERS } from "../lib/api";
|
||||
import { api, EMPTY_PLEX_FILTERS } from "../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -40,8 +40,15 @@ import { usePlexActiveFilters } from "./usePlexActiveFilters";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
// The hook resolves collection titles it does not already have through react-query, so it needs a
|
||||
// client. Every id here already HAS a title, so the query stays disabled and nothing is requested.
|
||||
// client. "B" has no title in the state, so the hook DOES ask for it — stubbed here, so no test issues
|
||||
// a real request (an unmocked one used to fire from the first test and retry after it had finished).
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "plexCollections").mockResolvedValue({ collections: [] } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: qc }, children);
|
||||
|
||||
@@ -59,7 +66,7 @@ describe("usePlexActiveFilters", () => {
|
||||
|
||||
it("labels a link-supplied collection with its resolved title, not the bare id", () => {
|
||||
// Seed the resolution the hook would fetch, so this asserts the WIRING (hook -> builder -> label)
|
||||
// rather than the network. Nothing is mocked.
|
||||
// rather than the network.
|
||||
qc.setQueryData(qk.plexCollections("titles", "B"), {
|
||||
collections: [{ id: "B", title: "Pirates of the Caribbean Collection" }],
|
||||
});
|
||||
|
||||
@@ -37,10 +37,13 @@ const backfillChangedCatalog = (
|
||||
* background job has written a row — which is why the list stayed on the ~19 cards the channel had
|
||||
* at subscribe time while the count above it climbed into the thousands.
|
||||
*
|
||||
* Cost is bounded: the predicate fires only on a tick where the count actually moved, the poll
|
||||
* itself pauses while the tab is unfocused, and a channel grid is normally a single 60-item page
|
||||
* whose newest-first head the (older) backfilled videos don't disturb — so the refetch usually
|
||||
* comes back structurally identical and re-renders nothing. */
|
||||
* What it costs, honestly: an invalidation refetches EVERY loaded page of the grid's infinite
|
||||
* query — one request per page the viewer has scrolled through, on each 4 s tick where the count
|
||||
* moved, for as long as the backfill runs (the poll pauses while the tab is hidden, and stops when
|
||||
* the back-catalogue is in). That is the right price, not an accident: backfilled videos are OLDER
|
||||
* than what the grid opened with, so under the newest-first order they land in the pages BELOW the
|
||||
* head — refreshing only the first page would leave out exactly the rows the backfill wrote. The
|
||||
* head itself comes back structurally identical and re-renders nothing. */
|
||||
export function useBackfillFeedRefresh(detail: ChannelDetail | undefined, qc: QueryClient) {
|
||||
const seen = useRef<ChannelDetail | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// …); the UI thinks in a key + direction. Kept here rather than inside Feed so the active-filter
|
||||
// chips can label a sort without re-deriving the mapping.
|
||||
import type { Video } from "./api";
|
||||
import type { SortOption } from "./columnSort";
|
||||
|
||||
export type SortKey =
|
||||
| "date"
|
||||
@@ -24,7 +25,7 @@ export const SORT_KEYS: SortKey[] = [
|
||||
];
|
||||
|
||||
/** Sorts with no meaningful asc/desc flip. */
|
||||
export const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
|
||||
const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
|
||||
|
||||
const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string; desc: string }> = {
|
||||
date: { desc: "newest", asc: "oldest" },
|
||||
@@ -58,6 +59,20 @@ export function buildSort(key: SortKey, dir: "asc" | "desc"): string {
|
||||
return SORT_MAP[key][dir];
|
||||
}
|
||||
|
||||
/** Feed sort keys as options for the shared SortControl. ONE mapping for the library feed and the
|
||||
* live search results, which share the vocabulary: two copies could hand the same key a different
|
||||
* direction default, or lose its directionless flag, depending on which mode you were in. */
|
||||
export function feedSortOptions(keys: SortKey[], label: (k: SortKey) => string): SortOption[] {
|
||||
return keys.map((k) => ({
|
||||
key: k,
|
||||
label: label(k),
|
||||
directionless: DIRECTIONLESS.includes(k),
|
||||
defaultDir: DIRECTIONLESS.includes(k)
|
||||
? undefined
|
||||
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Live YouTube search results ----------------------------------------------------------------
|
||||
//
|
||||
// The results view had NO sort control at all — the order was the provider's and that was that. It is
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sortYtResults, YT_SORT_KEYS, SORT_KEYS } from "./feedSort";
|
||||
import {
|
||||
feedSortOptions,
|
||||
sortYtResults,
|
||||
SORT_DEFAULT_DIR,
|
||||
YT_SORT_KEYS,
|
||||
SORT_KEYS,
|
||||
} from "./feedSort";
|
||||
import type { Video } from "./api";
|
||||
|
||||
// The live-YouTube results view shipped with no sort control at all: the provider's order was the
|
||||
@@ -111,3 +117,17 @@ describe("sortYtResults", () => {
|
||||
expect(titles(input)).toEqual(titles(RESULTS));
|
||||
});
|
||||
});
|
||||
|
||||
describe("feedSortOptions", () => {
|
||||
// The library feed and the live results used to build their options from two copies of this
|
||||
// mapping; one helper means a key cannot get a different default direction in one of the modes.
|
||||
it("gives directional keys their default direction and marks shuffle/relevance directionless", () => {
|
||||
const opts = feedSortOptions(["relevance", "date", "duration", "shuffle"], (k) => `label:${k}`);
|
||||
expect(opts.map((o) => [o.key, o.label, !!o.directionless, o.defaultDir])).toEqual([
|
||||
["relevance", "label:relevance", true, undefined],
|
||||
["date", "label:date", false, SORT_DEFAULT_DIR.date],
|
||||
["duration", "label:duration", false, SORT_DEFAULT_DIR.duration],
|
||||
["shuffle", "label:shuffle", true, undefined],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user