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(); + }, + }, + }; +}