/** * Renders a dump's page, with its preview, comments, related dumps and the * playlists holding it. * * @module */ import { useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { dumpThumbnailUrl, dumpUrl, isAt, userApiUrl, } from "../utils/shared/urls.ts"; import { can } from "../utils/auth/permissions.ts"; import { CategoryChips } from "../components/category/CategoryChips.tsx"; import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx"; import { API_URL } from "../config/api.ts"; import { VALIDATION } from "../../shared/validation.ts"; import type { Comment } from "../../shared/comment.ts"; import type { Dump, UpdateDumpRequest } from "../../shared/dump.ts"; import type { Playlist } from "../../shared/playlist.ts"; import type { PublicUser } from "../../shared/user.ts"; import type { Wire } from "../../shared/api.ts"; import { deserializeComment, deserializeDump, deserializePlaylist, deserializePublicUser, } from "../utils/shared/deserialize.ts"; import { friendlyFetchError, parseAPIResponse } from "../utils/shared/api.ts"; import { useAuth } from "../hooks/auth/useAuth.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { relativeTime } from "../utils/shared/relativeTime.ts"; import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts"; import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts"; import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts"; import { Avatar } from "../components/shared/Avatar.tsx"; import { RichContentCard } from "../components/dump/RichContentCard.tsx"; import { FilePreview } from "../components/dump/FilePreview.tsx"; import { VoteButton } from "../components/social/VoteButton.tsx"; import { DumpCardList } from "../components/dump/DumpCardList.tsx"; import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx"; import { PageShell } from "../components/layout/PageShell.tsx"; import { PageError } from "../components/layout/PageError.tsx"; import { Markdown } from "../components/editor/Markdown.tsx"; import { CommentThread } from "../components/comment/CommentThread.tsx"; import { Tooltip } from "../components/shared/Tooltip.tsx"; import { userPath } from "../../shared/paths.ts"; import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts"; type DumpState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; dump: Dump }; /** * Renders `/@:username/dumps/:slug`, for anyone who may see the dump: a * private dump answers 404 to anyone but its poster. The dump comes from * `GET /api/users/@:username/dumps/:slug`, or from the navigation's state when * `DumpEdit` or a title edit here just saved it. Reached by a former name or * slug, the URL takes the current ones. Its comments, related dumps and * playlists come from their own endpoints once it is loaded, and the socket's * events keep them current; a reconnection fetches the dump and its comments * again. A dump deleted, or made private by its poster, while someone else * views it gives way to a notice. The poster and moderators can edit the * title in place, and get a link to `DumpEdit`. */ export function Dump(): React.JSX.Element { const { handle, slug } = useParams(); const username = handle?.slice(1); const location = useLocation(); const navigate = useNavigate(); // Taken once, since it stays in the history entry, where a reload or Back // would show that snapshot forever. It is cleared from there below. const [handedOver, setHandedOver] = useState( () => (location.state as { dump?: Dump } | null)?.dump ?? null, ); const preloaded = isAt(handedOver, username, slug) ? handedOver : null; useEffect(() => { if (!(location.state as { dump?: Dump } | null)?.dump) return; navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null, }); }, [location, navigate]); const [dumpState, setDumpState] = useState( preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" }, ); const loadedDump = dumpState.status === "loaded" ? dumpState.dump : null; // The path last rendered. One that names another dump, as a related dump's // link does, drops the dump on screen in the same render: left for the // fetch below to drop, useCanonicalPath would first put its path back in // the URL. The dump handed over goes with it, so that coming back to its // path fetches it. const [target, setTarget] = useState({ username, slug }); if (target.username !== username || target.slug !== slug) { setTarget({ username, slug }); if (!isAt(loadedDump, username, slug)) { setHandedOver(null); setDumpState({ status: "loading" }); } } useCanonicalPath(loadedDump && dumpUrl(loadedDump)); // The dump on screen, which a path it was just given, by useCanonicalPath or // a slug edited elsewhere, needn't fetch again. const shownRef = useRef(loadedDump); useEffect(() => { shownRef.current = loadedDump; }, [loadedDump]); const [op, setOp] = useState(null); const [playlistModalOpen, setPlaylistModalOpen] = useState(false); useDocumentTitle( dumpState.status === "loaded" ? dumpState.dump.title : dumpState.status === "error" ? null : undefined, ); const [comments, setComments] = useState([]); const [relatedDumps, setRelatedDumps] = useState([]); const [collections, setCollections] = useState([]); const [titleEditing, setTitleEditing] = useState(false); const headerRightRef = useHeightTransition(titleEditing); const [titleDraft, setTitleDraft] = useState(""); const [titleSaving, setTitleSaving] = useState(false); const [titleError, setTitleError] = useState(null); const { user, mediaToken, authFetch } = useAuth(); const { voteCounts, myVotes, castVote, removeVote, deletedDumpIds, connectionEpoch, } = useWS(); useEffect(() => { if (!username || !slug) return; const controller = new AbortController(); if (preloaded) { fetch(userApiUrl({ id: preloaded.userId }), { signal: controller.signal, }) .then((r) => r.json()) .then((r) => r.success && setOp(deserializePublicUser(r.data))) .catch(() => {}); return () => controller.abort(); } if (isAt(shownRef.current, username, slug)) return; (async () => { setDumpState({ status: "loading" }); setOp(null); try { const res = await authFetch( userApiUrl({ username }, "dumps", slug), { cache: "no-store", signal: controller.signal }, ); const apiResponse = parseAPIResponse>(await res.json()); if (!apiResponse.success) { throw new Error(apiResponse.error.message); } const dump: Dump = deserializeDump(apiResponse.data); setDumpState({ status: "loaded", dump }); fetch(userApiUrl({ id: dump.userId }), { signal: controller.signal, }) .then((r) => r.json()) .then((r) => r.success && setOp(deserializePublicUser(r.data))) .catch(() => {}); } catch (err) { if ((err as Error).name === "AbortError") return; setDumpState({ status: "error", error: friendlyFetchError(err) }); } })(); return () => controller.abort(); }, [username, slug, preloaded, authFetch]); const replaceDump = (dump: Dump) => setDumpState((prev) => prev.status === "loaded" && prev.dump.id === dump.id ? { status: "loaded", dump } : prev ); useWSEvent("dump", ({ dump }) => replaceDump(dump)); const loadedDumpId = loadedDump?.id ?? null; // The dump found gone on a reconnection. One deleted, or made private, live // is in `deletedDumpIds`, which it leaves if made public again, and shows // again. const [vanishedId, setVanishedId] = useState(null); const vanished = !!loadedDumpId && (deletedDumpIds.has(loadedDumpId) || vanishedId === loadedDumpId); // An edit or a deletion missed while the socket was down. useOnReconnect(() => { if (!loadedDumpId) return; authFetch(`${API_URL}/api/dumps/${loadedDumpId}`, { cache: "no-store" }) .then(async (res) => { if (res.status === 404) return setVanishedId(loadedDumpId); const body = parseAPIResponse>(await res.json()); if (body.success) { setVanishedId(null); replaceDump(deserializeDump(body.data)); } }) .catch(() => {}); }); useWSEvent("user", ({ user }) => { setOp((op) => op?.id === user.id ? user : op); setComments((prev) => prev.some((c) => c.userId === user.id && c.authorUsername !== user.username ) ? prev.map((c) => c.userId === user.id ? { ...c, authorUsername: user.username } : c ) : prev ); setDumpState((prev) => prev.status === "loaded" && prev.dump.userId === user.id && prev.dump.ownerUsername !== user.username ? { status: "loaded", dump: { ...prev.dump, ownerUsername: user.username }, } : prev ); }); useDumpListSync(relatedDumps, setRelatedDumps); // Counts the playlist events, each of which may change the playlists // holding the dump. const [playlistsVersion, setPlaylistsVersion] = useState(0); useWSEvent("playlist", () => setPlaylistsVersion((n) => n + 1)); useEffect(() => { if (!loadedDumpId) return; const controller = new AbortController(); authFetch(`${API_URL}/api/dumps/${loadedDumpId}/comments`, { signal: controller.signal, }) .then((r) => r.json()) .then((body) => { if (body.success) { setComments((body.data as Wire[]).map(deserializeComment)); } }) .catch(() => {}); return () => controller.abort(); // connectionEpoch refetches on reconnect, for the comments missed while the // socket was down. }, [loadedDumpId, authFetch, connectionEpoch]); // The dumps that link to this one, or that it links to. useEffect(() => { if (!loadedDumpId) return; const controller = new AbortController(); authFetch(`${API_URL}/api/dumps/${loadedDumpId}/related`, { signal: controller.signal, }) .then((r) => r.json()) .then((body) => { if (body.success) { setRelatedDumps((body.data as Wire[]).map(deserializeDump)); } }) .catch(() => {}); return () => controller.abort(); }, [loadedDumpId, authFetch]); // The server leaves out the private playlists of others. useEffect(() => { if (!loadedDumpId) return; const controller = new AbortController(); authFetch(`${API_URL}/api/dumps/${loadedDumpId}/playlists`, { signal: controller.signal, }) .then((r) => r.json()) .then((body) => { if (body.success) { setCollections( (body.data as Wire[]).map(deserializePlaylist), ); } }) .catch(() => {}); return () => controller.abort(); // Any playlist event may change the list, and they are rare enough to // refetch on each, as on a reconnection. }, [loadedDumpId, authFetch, playlistsVersion, connectionEpoch]); // A notification links to `#comment-`. Rerun when the comments arrive, // since the element doesn't exist before. useEffect(() => { if (!location.hash.startsWith("#comment-")) return; const id = location.hash.slice(1); const el = document.getElementById(id); if (!el) return; el.scrollIntoView({ behavior: "smooth", block: "start" }); el.classList.add("comment-node--highlight"); const timer = setTimeout( () => el.classList.remove("comment-node--highlight"), 2000, ); return () => clearTimeout(timer); }, [comments, location.hash]); useWSEvent("comment", (ev) => { if (!loadedDumpId || ev.dumpId !== loadedDumpId) return; // A comment of this user's arrives both from the socket and from // CommentThread's callbacks below, hence the duplicate checks. const { comment, commentId } = ev; if (ev.type === "created" && comment) { setComments((prev) => prev.some((c) => c.id === comment.id) ? prev : [...prev, comment] ); } else if (ev.type === "deleted" && commentId) { setComments((prev) => prev.map((c) => c.id === commentId ? { ...c, deleted: true, body: "" } : c ) ); } else if (ev.type === "updated" && comment) { setComments((prev) => prev.map((c) => c.id === comment.id ? comment : c)); } }); if (dumpState.status === "loading") { return (

Loading dump…

); } if (vanished) { return ( navigate("/")} > ← Back to all dumps } /> ); } if (dumpState.status === "error") { return ( } /> ); } const { dump } = dumpState; const canEdit = !!user && (dump.userId === user.id || can(user, "dump:moderate")); const startTitleEdit = () => { setTitleDraft(dump.title); setTitleError(null); setTitleEditing(true); }; const handleTitleSave = async () => { const trimmed = titleDraft.trim(); if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return; setTitleSaving(true); setTitleError(null); try { const res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, { method: "PUT", body: JSON.stringify( { title: trimmed } satisfies UpdateDumpRequest, ), }); const apiResponse = parseAPIResponse>(await res.json()); if (!apiResponse.success) { setTitleError(apiResponse.error.message); return; } const updatedDump = deserializeDump(apiResponse.data); setTitleEditing(false); // The page stays mounted across the slug change below, and would keep // the old title. setDumpState({ status: "loaded", dump: updatedDump }); if (updatedDump.slug !== dump.slug) { navigate(dumpUrl(updatedDump), { replace: true, state: { dump: updatedDump }, }); } } catch (err) { setTitleError(friendlyFetchError(err)); } finally { setTitleSaving(false); } }; return (
{user && ( )}
{titleEditing ? (
setTitleDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleTitleSave(); } else if (e.key === "Escape") { setTitleEditing(false); } }} maxLength={VALIDATION.DUMP_TITLE_MAX} disabled={titleSaving} autoFocus />
{titleError && (

{titleError}

)}
) : (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); startTitleEdit(); } } : undefined} >

{dump.title}

{canEdit && ( ✎ )}
)}
{op ? ( {op.username} ) : …} {dump.updatedAt && ( edited {relativeTime(dump.updatedAt)} )} {dump.isPrivate && ( private )}
{dump.comment && ( {dump.comment} )}
{dump.kind === "file" ? : dump.richContent ? ( ) : ( {dump.url} )}
{canEdit && ( Edit )} ← Back to all dumps
{relatedDumps.length > 0 && (

Related

)} {collections.length > 0 && (

In collections

)} setComments((prev) => prev.some((x) => x.id === c.id) ? prev : [...prev, c] )} onCommentDeleted={(id) => setComments((prev) => prev.map((c) => c.id === id ? { ...c, deleted: true, body: "" } : c ) )} onCommentUpdated={(updated) => setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)) )} />
{playlistModalOpen && ( setPlaylistModalOpen(false)} /> )}
); }