/** * A dump, as a tile of the journal's mosaic. * * @module */ import { memo } from "react"; import { Link } from "react-router"; import { Plural, Trans } from "@lingui/react/macro"; import type { Dump } from "../../../shared/dump.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts"; import { dumpThumbnailUrl, dumpUrl } from "../../utils/shared/urls.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts"; import { isDumpVisited, isRecent, markDumpVisited, } from "../../utils/shared/visited.ts"; import { hasQuote, hasThumbnail, type JournalShape, } from "../../utils/feed/journalLayout.ts"; import { VoteButton } from "../social/VoteButton.tsx"; import { Markdown } from "../editor/Markdown.tsx"; import { Tooltip } from "../shared/Tooltip.tsx"; import { Thumbnail } from "../shared/Thumbnail.tsx"; import { canPlayRichContent, usePlayRichContent, } from "../../hooks/player/usePlayRichContent.ts"; /** The props of {@link JournalCard}. */ export interface JournalCardProps { /** The dump. */ dump: Dump; /** The tile's footprint in the mosaic. */ shape: JournalShape; /** Its live vote count. */ voteCount: number; /** Whether the user upvoted it. */ voted: boolean; /** Whether the user may vote (signed in). */ canVote: boolean; /** Upvotes dump `id`; `shownCount` is the count on screen. */ castVote: (id: string, shownCount?: number) => void; /** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */ removeVote: (id: string, shownCount?: number) => void; /** Whether the viewer posted it: no unread dot. */ isOwner?: boolean; } /** * Renders a dump as a mosaic tile — an image card, a pull-quote or a text card, * by its content. A playable card plays; one that can't opens the dump. * Memoized, like DumpCard: live events re-render the feed, not every card. */ export const JournalCard: React.MemoExoticComponent< (props: JournalCardProps) => React.JSX.Element > = memo(function JournalCard( { dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }: JournalCardProps, ) { const { go, cardProps, handledAsNewTab } = useDumpCardLink(dump); const { mediaToken } = useAuth(); const { playRichContent } = usePlayRichContent(); const unread = !isOwner && isRecent(dump.createdAt) && !isDumpVisited(dump.id); // 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))) go(); } // Mirrors FilePreview (the hot/new feeds) so a video shows its generated // still here too, rather than degrading to a text card with a 🎬. An image // shows the server's scaled-down preview, not the full upload. const thumbnailUrl = dump.thumbnailMime ? dumpThumbnailUrl(dump, mediaToken) : dump.kind === "file" ? (dump.fileMime?.startsWith("image/") || dump.fileMime?.startsWith("video/") ? dumpThumbnailUrl(dump, mediaToken) : null) : (dump.richContent?.thumbnailUrl ?? null); // Content mode is independent of grid footprint: a thumbnailed dump reads as // an image card, a thumbnail-less dump with a note becomes a pull-quote, and // everything else falls back to a typographic text card. const mode: "image" | "quote" | "text" = hasThumbnail(dump) && thumbnailUrl ? "image" : hasQuote(dump) ? "quote" : "text"; const fallbackIcon = dump.kind === "file" ? (() => { const m = dump.fileMime ?? ""; if (m.startsWith("video/")) return "🎬"; if (m.startsWith("audio/")) return "🎵"; return "📄"; })() : "🔗"; const richContent = dump.richContent; // In native mode a Bandcamp page is playable even with no stored embedUrl. // The card's own thumbnail (dump upload or provider) becomes the player's // header artwork. const playable = richContent && canPlayRichContent(richContent) ? { ...richContent, thumbnailUrl: thumbnailUrl ?? undefined } : null; const titleLink = ( { e.stopPropagation(); markDumpVisited(dump.id); }} > {unread &&