v3: code quality pass
This commit is contained in:
@@ -1,139 +1,60 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
deserializePublicUser,
|
||||
hydrateDump,
|
||||
} from "../model.ts";
|
||||
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 { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
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";
|
||||
|
||||
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 { user: me } = 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 setVotesDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
setState((s) => s.status !== "loaded" ? s : { ...s, votes: fn(s.votes) });
|
||||
}, []);
|
||||
useDumpListSync(setVotesDumps);
|
||||
|
||||
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
||||
const { fading, startFading, cancelFading, cancelAll } = useFading();
|
||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
setState({ status: "loading" });
|
||||
cancelAll();
|
||||
setVotedIds(new Set());
|
||||
prevMyVotesRef.current = null;
|
||||
const controller = new AbortController();
|
||||
const onItemsAppended = useCallback((newItems: Dump[]) => {
|
||||
setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)]));
|
||||
}, []);
|
||||
|
||||
if (cached) {
|
||||
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
|
||||
.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) => {
|
||||
if (err.name === "AbortError") return;
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}
|
||||
const { state, setState, setItems, sentinelRef } = useUserDumpFeed(
|
||||
username,
|
||||
"votes",
|
||||
`feed:user-upvoted-full:${username ?? ""}`,
|
||||
{ onItemsAppended },
|
||||
);
|
||||
|
||||
const authHeaders: HeadersInit = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
Promise.all([
|
||||
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{ headers: authHeaders, signal: controller.signal },
|
||||
),
|
||||
])
|
||||
.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) => {
|
||||
if (err.name === "AbortError") return;
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [username]);
|
||||
useDumpListSync(setItems);
|
||||
|
||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||
|
||||
// Own profile: keep votedIds in sync with myVotes.
|
||||
// Fading is triggered directly here to avoid a gap render between
|
||||
// setVotedIds and the old prevVotedIds tracking effect.
|
||||
// Reset vote tracking when username changes
|
||||
useEffect(() => {
|
||||
cancelAll();
|
||||
setVotedIds(new Set());
|
||||
prevMyVotesRef.current = null;
|
||||
}, [username]);
|
||||
|
||||
// Seed votedIds once items are loaded
|
||||
useEffect(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
setVotedIds(new Set(state.items.map((d) => d.id)));
|
||||
}, [state.status]);
|
||||
|
||||
// Own profile: keep votedIds in sync with myVotes
|
||||
useEffect(() => {
|
||||
if (!profileUserId || me?.id !== profileUserId) return;
|
||||
if (prevMyVotesRef.current === null) {
|
||||
// First sync after load: initialize without animating the diff.
|
||||
setVotedIds(new Set(myVotes));
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
return;
|
||||
@@ -157,7 +78,6 @@ export function UserUpvoted() {
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
// Start fading in same batch so visibleDumps never has a gap render.
|
||||
startFading(dumpId);
|
||||
} else {
|
||||
setVotedIds((prev) => new Set([...prev, dumpId]));
|
||||
@@ -168,82 +88,16 @@ export function UserUpvoted() {
|
||||
if (!body.success) return;
|
||||
const dump = deserializeDump(body.data);
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded" || s.votes.some((d) => d.id === dumpId)) {
|
||||
if (s.status !== "loaded" || s.items.some((d) => d.id === dumpId)) {
|
||||
return s;
|
||||
}
|
||||
return { ...s, votes: [dump, ...s.votes] };
|
||||
return { ...s, items: [dump, ...s.items] };
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [lastVoteEvent, profileUserId, startFading, cancelFading]);
|
||||
|
||||
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=${DEFAULT_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>
|
||||
@@ -265,27 +119,18 @@ export function UserUpvoted() {
|
||||
);
|
||||
}
|
||||
|
||||
const { profileUser, votes, hasMore, loadingMore } = state;
|
||||
const { profileUser, items: 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>
|
||||
<ProfileSubpageHeader
|
||||
username={username!}
|
||||
profileUser={profileUser}
|
||||
title="Upvoted"
|
||||
/>
|
||||
|
||||
{visibleDumps.length === 0
|
||||
? <p className="empty-state">Nothing here yet.</p>
|
||||
@@ -308,6 +153,7 @@ export function UserUpvoted() {
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
className={extraCls}
|
||||
isOwner={!!me && me.id === dump.userId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user