Files
gerbeur/src/pages/UserUpvoted.tsx
khannurien cf988ae608
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
v3: allow users to edit dump thumbnail, fixed some linter errors in the frontend
2026-06-21 15:41:40 +00:00

200 lines
6.3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
import { deserializeDump } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts";
import { DumpCard } from "../components/DumpCard.tsx";
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
export function UserUpvoted() {
const { username } = useParams();
const { user: me } = useAuth();
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
const { fading, startFading, cancelFading, cancelAll } = useFading();
const prevMyVotesRef = useRef<Set<string> | null>(null);
const onItemsAppended = useCallback((newItems: Dump[]) => {
setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)]));
}, []);
const { state, setState, setItems, sentinelRef } = useUserDumpFeed(
username,
"votes",
`feed:user-upvoted-full:${username ?? ""}`,
{ onItemsAppended },
);
useDumpListSync(setItems);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
const [prevUsername, setPrevUsername] = useState(username);
if (prevUsername !== username) {
setPrevUsername(username);
setVotedIds(new Set());
}
useEffect(() => {
cancelAll();
prevMyVotesRef.current = null;
}, [username, cancelAll]);
const [prevStateStatus, setPrevStateStatus] = useState(state.status);
const [prevStateItems, setPrevStateItems] = useState(
state.status === "loaded" ? state.items : null,
);
const currentItems = state.status === "loaded" ? state.items : null;
if (
prevStateStatus !== state.status ||
prevStateItems !== currentItems
) {
setPrevStateStatus(state.status);
setPrevStateItems(currentItems);
if (state.status === "loaded") {
setVotedIds(new Set(state.items.map((d) => d.id)));
}
}
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
if (prevMyVotesRef.current === null) {
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
setVotedIds(new Set(myVotes));
prevMyVotesRef.current = new Set(myVotes);
return;
}
const prev = prevMyVotesRef.current;
setVotedIds(new Set(myVotes));
for (const id of prev) if (!myVotes.has(id)) startFading(id);
for (const id of myVotes) if (!prev.has(id)) cancelFading(id);
prevMyVotesRef.current = new Set(myVotes);
}, [myVotes, me, profileUserId, startFading, cancelFading]);
useEffect(() => {
if (!lastVoteEvent || !profileUserId) return;
const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return;
if (action === "remove") {
// setVotedIds and startFading must fire together to avoid a render with
// stale votedIds between the two updates.
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds((prev) => {
const n = new Set(prev);
n.delete(dumpId);
return n;
});
startFading(dumpId);
} else {
setVotedIds((prev) => new Set([...prev, dumpId]));
cancelFading(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.items.some((d) => d.id === dumpId)) {
return s;
}
return { ...s, items: [dump, ...s.items] };
});
})
.catch(() => {});
}
}, [lastVoteEvent, profileUserId, startFading, cancelFading, setState]);
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">
<Trans>Loading</Trans>
</p>
</PageShell>
);
}
if (state.status === "error") {
return (
<PageError
message={state.error}
actions={
<Link to={`/users/${username}`} className="btn-border">
<Trans> Back to profile</Trans>
</Link>
}
/>
);
}
const { profileUser, items: votes, hasMore, loadingMore } = state;
const visibleDumps = votes.filter((d) =>
votedIds.has(d.id) || d.id in fading
);
return (
<PageShell
feed={visibleDumps.length > 0 && (
<>
<ul className="dump-feed">
{visibleDumps.map((dump) => {
const phase = fading[dump.id];
const extraCls = phase === "cooldown"
? "dump-card--fading"
: phase === "dismissing"
? "dump-card--dismissing"
: undefined;
return (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
className={extraCls}
isOwner={!!me && me.id === dump.userId}
/>
);
})}
</ul>
{hasMore && <div ref={sentinelRef} />}
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
)}
>
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title={t`Upvoted`}
/>
{visibleDumps.length === 0 && (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)}
</PageShell>
);
}