import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { Link, useParams } from "react-router"; import { API_URL } from "../config/api.ts"; import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts"; import { deserializeDump, deserializePublicUser } from "../model.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts"; import { Avatar } from "../components/Avatar.tsx"; import { DumpCard } from "../components/DumpCard.tsx"; import { PageShell } from "../components/PageShell.tsx"; import { PageError } from "../components/PageError.tsx"; const PAGE_SIZE = 20; const hydrateDump = (raw: Dump): Dump => deserializeDump(raw as unknown as RawDump); type State = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; profileUser: PublicUser; votes: Dump[]; hasMore: boolean; page: number; loadingMore: boolean; }; export function UserUpvoted() { const { username } = useParams(); const { user: me, token } = useAuth(); const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS(); const { cached, saveState } = useFeedCache( `feed:user-upvoted-full:${username ?? ""}`, hydrateDump, ); const [state, setState] = useState({ status: "loading" }); const [votedIds, setVotedIds] = useState>(new Set()); const [fading, setFading] = useState< Record >({}); const cancels = useRef void>>(new Map()); const prevVotedIds = useRef | null>(null); const prevMyVotesRef = useRef | null>(null); useEffect(() => () => { cancels.current.forEach((c) => c()); }, []); useEffect(() => { if (!username) return; setState({ status: "loading" }); setVotedIds(new Set()); prevVotedIds.current = null; prevMyVotesRef.current = null; if (cached) { fetch(`${API_URL}/api/users/${username}`) .then((r) => r.json()) .then((body) => { if (!body.success) throw new Error("User not found"); const voteIds = new Set(cached.items.map((d) => d.id)); setState({ status: "loaded", profileUser: deserializePublicUser(body.data), votes: cached.items, hasMore: cached.hasMore, page: cached.page, loadingMore: false, }); setVotedIds(voteIds); }) .catch((err) => setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load", }) ); return; } const authHeaders: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; Promise.all([ fetch(`${API_URL}/api/users/${username}`), fetch( `${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }, ), ]) .then(([userRes, votesRes]) => Promise.all([userRes.json(), votesRes.json()]) ) .then(([userBody, votesBody]) => { if (!userBody.success) throw new Error("User not found"); const { items, hasMore } = votesBody.success ? votesBody.data as PaginatedData : { items: [], hasMore: false }; const voteItems = items.map(deserializeDump); setState({ status: "loaded", profileUser: deserializePublicUser(userBody.data), votes: voteItems, hasMore, page: 1, loadingMore: false, }); setVotedIds(new Set(voteItems.map((d) => d.id))); }) .catch((err) => setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load", }) ); }, [username]); const profileUserId = state.status === "loaded" ? state.profileUser.id : null; // Own profile: keep votedIds in sync with myVotes useEffect(() => { if (!profileUserId || me?.id !== profileUserId) return; setVotedIds(new Set(myVotes)); if (prevMyVotesRef.current === null) { prevMyVotesRef.current = new Set(myVotes); return; } const prev = prevMyVotesRef.current; setState((s) => { if (s.status !== "loaded") return s; const voteIdSet = new Set(s.votes.map((d) => d.id)); const toAdd = [...myVotes].filter((id) => !prev.has(id) && !voteIdSet.has(id) ); if (toAdd.length === 0) return s; // Newly voted items will arrive via lastVoteEvent fetch below return s; }); prevMyVotesRef.current = new Set(myVotes); }, [myVotes, me, profileUserId]); // WS vote events useEffect(() => { if (!lastVoteEvent || !profileUserId) return; const { dumpId, voterId, action } = lastVoteEvent; if (voterId !== profileUserId) return; if (action === "remove") { setVotedIds((prev) => { const n = new Set(prev); n.delete(dumpId); return n; }); } else { setVotedIds((prev) => new Set([...prev, dumpId])); fetch(`${API_URL}/api/dumps/${dumpId}`) .then((r) => r.json()) .then((body) => { if (!body.success) return; const dump = deserializeDump(body.data); setState((s) => { if (s.status !== "loaded" || s.votes.some((d) => d.id === dumpId)) { return s; } return { ...s, votes: [dump, ...s.votes] }; }); }) .catch(() => {}); } }, [lastVoteEvent, profileUserId]); // Fade animation when items leave votedIds useEffect(() => { if (prevVotedIds.current === null) { prevVotedIds.current = new Set(votedIds); return; } const prev = prevVotedIds.current; for (const id of prev) { if (!votedIds.has(id) && !cancels.current.has(id)) { let dead = false; let kill = () => {}; kill = () => { dead = true; setFading((f) => { const n = { ...f }; delete n[id]; return n; }); cancels.current.delete(id); }; cancels.current.set(id, () => kill()); setFading((f) => ({ ...f, [id]: "cooldown" })); const t1 = setTimeout(() => { if (dead) return; setFading((f) => ({ ...f, [id]: "dismissing" })); const t2 = setTimeout(() => { if (!dead) kill(); }, 350); kill = () => { dead = true; clearTimeout(t2); setFading((f) => { const n = { ...f }; delete n[id]; return n; }); cancels.current.delete(id); }; }, 2000); kill = () => { dead = true; clearTimeout(t1); setFading((f) => { const n = { ...f }; delete n[id]; return n; }); cancels.current.delete(id); }; cancels.current.set(id, () => kill()); } } for (const id of votedIds) { if (!prev.has(id) && cancels.current.has(id)) { cancels.current.get(id)!(); } } prevVotedIds.current = new Set(votedIds); }, [votedIds]); const loadMore = useCallback(() => { if ( state.status !== "loaded" || !state.hasMore || state.loadingMore || !username ) return; const nextPage = state.page + 1; setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s); fetch( `${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }, ) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; const newItems = items.map(deserializeDump); setState((s) => s.status === "loaded" ? { ...s, votes: [...s.votes, ...newItems], hasMore, page: nextPage, loadingMore: false, } : s ); setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)])); }) .catch(() => setState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s ) ); }, [state, username, token]); const sentinelRef = useInfiniteScroll( loadMore, state.status === "loaded" && state.hasMore && !state.loadingMore, ); useEffect(() => { if (state.status !== "loaded") return; let timer: ReturnType; const onScroll = () => { clearTimeout(timer); timer = setTimeout(() => { if (state.status !== "loaded") return; saveState(state.votes, state.page, state.hasMore, globalThis.scrollY); }, 100); }; globalThis.addEventListener("scroll", onScroll, { passive: true }); return () => { globalThis.removeEventListener("scroll", onScroll); clearTimeout(timer); }; }, [state, saveState]); const scrollRestored = useRef(false); useLayoutEffect(() => { if (cached?.scrollY == null || scrollRestored.current) return; if (state.status === "loaded") { globalThis.scrollTo(0, cached.scrollY); scrollRestored.current = true; } }, [state.status, cached]); if (state.status === "loading") { return (

Loading…

); } if (state.status === "error") { return ( ← Back to profile } /> ); } const { profileUser, votes, hasMore, loadingMore } = state; const visibleDumps = votes.filter((d) => votedIds.has(d.id) || d.id in fading ); return (
← {profileUser.username}

Upvoted

{visibleDumps.length === 0 ?

Nothing here yet.

: (
    {visibleDumps.map((dump) => { const phase = fading[dump.id]; const extraCls = phase === "cooldown" ? "dump-card--fading" : phase === "dismissing" ? "dump-card--dismissing" : undefined; return ( ); })}
)}
{loadingMore &&

Loading more…

} {!hasMore && visibleDumps.length > 0 && (

All {votes.length} upvoted dumps loaded.

)} ); }