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