From c7ce31c1bda91450899bf96c3c2cf4108b37dd57 Mon Sep 17 00:00:00 2001 From: khannurien Date: Thu, 24 Sep 2026 19:39:21 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20a=20feed=20card=20opens=20in=20a=20new=20?= =?UTF-8?q?tab=20on=20a=20middle=20click,=20wherever=20you=20point=20?= =?UTF-8?q?=E2=80=94=20the=20cards=20are=20clickable=20elements=20rather?= =?UTF-8?q?=20than=20links,=20so=20only=20the=20title,=20the=20one=20real?= =?UTF-8?q?=20anchor,=20answered=20a=20middle=20click;=20the=20rest=20of?= =?UTF-8?q?=20the=20card,=20the=20play=20button=20included,=20did=20nothin?= =?UTF-8?q?g,=20and=20a=20middle=20press=20there=20started=20the=20browser?= =?UTF-8?q?'s=20autoscroll=20instead.=20A=20shared=20useDumpCardLink=20now?= =?UTF-8?q?=20gives=20DumpCard=20and=20JournalCard=20an=20auxclick=20that?= =?UTF-8?q?=20opens=20the=20dump=20in=20a=20new=20tab,=20with=20the=20midd?= =?UTF-8?q?le=20press's=20default=20suppressed=20so=20autoscroll=20doesn't?= =?UTF-8?q?=20swallow=20it,=20and=20a=20ctrl/cmd/shift=20click=20does=20th?= =?UTF-8?q?e=20same=20=E2=80=94=20caught=20ahead=20of=20the=20play=20butto?= =?UTF-8?q?n,=20so=20a=20modified=20click=20on=20a=20playable=20card=20ope?= =?UTF-8?q?ns=20it=20rather=20than=20playing=20it,=20while=20a=20plain=20c?= =?UTF-8?q?lick=20still=20plays.=20Anchors=20inside=20a=20card=20keep=20th?= =?UTF-8?q?eir=20own=20target:=20the=20title,=20category=20chips,=20and=20?= =?UTF-8?q?a=20source=20link's=20=E2=86=97=20still=20open=20what=20they=20?= =?UTF-8?q?point=20at.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/components/DumpCard.tsx | 13 +++---- src/components/JournalCard.tsx | 23 ++++++------ src/hooks/useDumpCardLink.ts | 64 ++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 20 deletions(-) create mode 100644 src/hooks/useDumpCardLink.ts diff --git a/src/components/DumpCard.tsx b/src/components/DumpCard.tsx index 9bd45b14..71c555ba 100644 --- a/src/components/DumpCard.tsx +++ b/src/components/DumpCard.tsx @@ -1,10 +1,11 @@ -import { Link, useNavigate } from "react-router"; +import { Link } from "react-router"; import { Plural, Trans } from "@lingui/react/macro"; import type { Dump } from "../model.ts"; import { relativeTime } from "../utils/relativeTime.ts"; import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useCategories } from "../hooks/useCategories.ts"; +import { useDumpCardLink } from "../hooks/useDumpCardLink.ts"; import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; import FilePreview from "./FilePreview.tsx"; import RichContentCard from "./RichContentCard.tsx"; @@ -28,7 +29,7 @@ export function DumpCard( { dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }: DumpCardProps, ) { - const navigate = useNavigate(); + const { cardProps, handledAsNewTab } = useDumpCardLink(dump); const { token } = useAuth(); const { byId } = useCategories(); const dumpCategories = (dump.categoryIds ?? []) @@ -37,21 +38,17 @@ export function DumpCard( const unread = !isOwner && isRecent(dump.createdAt) && !isDumpVisited(dump.id); - function handleNavigate() { - markDumpVisited(dump.id); - navigate(dumpUrl(dump)); - } - return (
  • e.stopPropagation() : undefined} + onClickCapture={handledAsNewTab} > {dump.kind === "file" ? diff --git a/src/components/JournalCard.tsx b/src/components/JournalCard.tsx index aa237781..01c635f2 100644 --- a/src/components/JournalCard.tsx +++ b/src/components/JournalCard.tsx @@ -1,9 +1,10 @@ -import { Link, useNavigate } from "react-router"; +import { Link } from "react-router"; import { Plural, Trans } from "@lingui/react/macro"; import type { Dump } from "../model.ts"; import { relativeTime } from "../utils/relativeTime.ts"; import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { useAuth } from "../hooks/useAuth.ts"; +import { useDumpCardLink } from "../hooks/useDumpCardLink.ts"; import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayout.ts"; import { VoteButton } from "./VoteButton.tsx"; @@ -32,22 +33,17 @@ export function JournalCard( { dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }: JournalCardProps, ) { - const navigate = useNavigate(); + const { go, cardProps, handledAsNewTab } = useDumpCardLink(dump); const { token } = useAuth(); const { playRichContent } = usePlayRichContent(); const unread = !isOwner && isRecent(dump.createdAt) && !isDumpVisited(dump.id); - function handleNavigate() { - markDumpVisited(dump.id); - navigate(dumpUrl(dump)); - } - // A playable card plays. If playback turns out to be impossible — a Bandcamp // page with no streams and no embed to fall back to — the card must still do // what an unplayable one does rather than swallowing the click. async function handlePlayOrNavigate(rc: NonNullable) { - if (!await playRichContent(rc, dumpUrl(dump))) handleNavigate(); + if (!await playRichContent(rc, dumpUrl(dump))) go(); } // Mirrors FilePreview (the hot/new feeds) so a video shows its generated @@ -157,9 +153,12 @@ export function JournalCard( return (
  • void handlePlayOrNavigate(playable) - : handleNavigate} + ? (e) => { + if (!handledAsNewTab(e)) void handlePlayOrNavigate(playable); + } + : cardProps.onClick} >
    +
  • {dump.comment!} @@ -211,7 +210,7 @@ export function JournalCard( // text return ( -
  • +
  • diff --git a/src/hooks/useDumpCardLink.ts b/src/hooks/useDumpCardLink.ts new file mode 100644 index 00000000..f0ff1890 --- /dev/null +++ b/src/hooks/useDumpCardLink.ts @@ -0,0 +1,64 @@ +import type { MouseEvent } from "react"; +import { useNavigate } from "react-router"; +import type { Dump } from "../model.ts"; +import { dumpUrl } from "../utils/urls.ts"; +import { markDumpVisited } from "../utils/visited.ts"; + +function isOverLink(e: MouseEvent) { + return (e.target as Element).closest("a") !== null; +} + +function wantsNewTab(e: MouseEvent) { + return e.ctrlKey || e.metaKey || e.shiftKey; +} + +/** + * A feed card is a clickable element rather than a link, so it gets none of a + * link's behaviour for free. This gives it back: a middle click (or a + * ctrl/cmd/shift click) anywhere on the card opens the dump in a new tab, even + * over a play button whose plain click starts the media. Anchors inside the + * card (the title, a source link, category chips) keep their own target. + */ +export function useDumpCardLink(dump: Pick) { + const navigate = useNavigate(); + + function openInNewTab() { + markDumpVisited(dump.id); + globalThis.open(dumpUrl(dump), "_blank", "noopener"); + } + + function go() { + markDumpVisited(dump.id); + navigate(dumpUrl(dump)); + } + + return { + /** Navigates in place. */ + go, + /** For click handlers that do something other than navigate (play): + * returns true when the click was a new-tab request and was handled. */ + handledAsNewTab(e: MouseEvent) { + if (!wantsNewTab(e) || isOverLink(e)) return false; + e.preventDefault(); + e.stopPropagation(); + openInNewTab(); + return true; + }, + cardProps: { + onClick(e: MouseEvent) { + if (wantsNewTab(e)) openInNewTab(); + else go(); + }, + // Windows and Linux browsers start autoscroll on a middle press, which + // eats the auxclick that follows. + onMouseDown(e: MouseEvent) { + if (e.button === 1 && !isOverLink(e)) e.preventDefault(); + }, + onAuxClick(e: MouseEvent) { + if (e.button !== 1 || isOverLink(e)) return; + e.preventDefault(); + openInNewTab(); + }, + }, + }; +}