393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
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<Dump>(
|
|
`feed:user-upvoted-full:${username ?? ""}`,
|
|
hydrateDump,
|
|
);
|
|
|
|
const [state, setState] = useState<State>({ status: "loading" });
|
|
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
|
const [fading, setFading] = useState<
|
|
Record<string, "cooldown" | "dismissing">
|
|
>({});
|
|
const cancels = useRef<Map<string, () => void>>(new Map());
|
|
const prevVotedIds = useRef<Set<string> | null>(null);
|
|
const prevMyVotesRef = useRef<Set<string> | 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<RawDump>
|
|
: { 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<RawDump>;
|
|
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<typeof setTimeout>;
|
|
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 (
|
|
<PageShell>
|
|
<p className="page-loading">Loading…</p>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
if (state.status === "error") {
|
|
return (
|
|
<PageError
|
|
message={state.error}
|
|
actions={
|
|
<Link to={`/users/${username}`} className="logout-btn">
|
|
← Back to profile
|
|
</Link>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const { profileUser, votes, hasMore, loadingMore } = state;
|
|
const visibleDumps = votes.filter((d) =>
|
|
votedIds.has(d.id) || d.id in fading
|
|
);
|
|
|
|
return (
|
|
<PageShell>
|
|
<div className="profile-subpage-header">
|
|
<Link to={`/users/${username}`} className="profile-subpage-back">
|
|
← {profileUser.username}
|
|
</Link>
|
|
<div className="profile-subpage-title-row">
|
|
<Avatar
|
|
userId={profileUser.id}
|
|
username={profileUser.username}
|
|
hasAvatar={!!profileUser.avatarMime}
|
|
size={36}
|
|
/>
|
|
<h1 className="profile-subpage-title">Upvoted</h1>
|
|
</div>
|
|
</div>
|
|
|
|
{visibleDumps.length === 0
|
|
? <p className="empty-state">Nothing here yet.</p>
|
|
: (
|
|
<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}
|
|
/>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
|
|
<div ref={sentinelRef} />
|
|
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
|
{!hasMore && visibleDumps.length > 0 && (
|
|
<p className="index-status">All {votes.length} upvoted dumps loaded.</p>
|
|
)}
|
|
</PageShell>
|
|
);
|
|
}
|