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:
2026-09-11 01:38:31 +02:00
parent 060deb89d2
commit efd95fca01
11 changed files with 425 additions and 55 deletions
+17 -13
View File
@@ -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 && " · "}