v3: code quality pass, various bug fixes

This commit is contained in:
khannurien
2026-03-23 07:47:49 +00:00
parent d94a319d96
commit fbbbb43258
44 changed files with 1060 additions and 698 deletions

View File

@@ -7,13 +7,14 @@ import {
} from "react";
import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
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 } from "../model.ts";
import { deserializeDump, deserializePublicUser, hydrateDump } 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";
@@ -21,10 +22,6 @@ 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 }
@@ -54,26 +51,19 @@ export function UserUpvoted() {
useDumpListSync(setVotesDumps);
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 { fading, startFading, cancelFading, cancelAll } = useFading();
const prevMyVotesRef = useRef<Set<string> | null>(null);
useEffect(() => () => {
cancels.current.forEach((c) => c());
}, []);
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
cancelAll();
setVotedIds(new Set());
prevVotedIds.current = null;
prevMyVotesRef.current = null;
const controller = new AbortController();
if (cached) {
fetch(`${API_URL}/api/users/${username}`)
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
@@ -88,23 +78,21 @@ export function UserUpvoted() {
});
setVotedIds(voteIds);
})
.catch((err) =>
setState({
status: "error",
error: friendlyFetchError(err),
})
);
return;
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
Promise.all([
fetch(`${API_URL}/api/users/${username}`),
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
fetch(
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
`${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
])
.then(([userRes, votesRes]) =>
@@ -126,37 +114,32 @@ export function UserUpvoted() {
});
setVotedIds(new Set(voteItems.map((d) => d.id)));
})
.catch((err) =>
setState({
status: "error",
error: friendlyFetchError(err),
})
);
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username]);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
// Own profile: keep votedIds in sync with myVotes
// 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.
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
setVotedIds(new Set(myVotes));
if (prevMyVotesRef.current === null) {
// First sync after load: initialize without animating the diff.
setVotedIds(new Set(myVotes));
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;
});
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]);
}, [myVotes, me, profileUserId, startFading, cancelFading]);
// WS vote events
useEffect(() => {
@@ -170,8 +153,11 @@ 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]));
cancelFading(dumpId);
fetch(`${API_URL}/api/dumps/${dumpId}`)
.then((r) => r.json())
.then((body) => {
@@ -186,73 +172,8 @@ export function UserUpvoted() {
})
.catch(() => {});
}
}, [lastVoteEvent, profileUserId]);
}, [lastVoteEvent, profileUserId, startFading, cancelFading]);
// 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 (
@@ -262,7 +183,7 @@ export function UserUpvoted() {
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}`,
`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
@@ -342,9 +263,7 @@ export function UserUpvoted() {
}
const { profileUser, votes, hasMore, loadingMore } = state;
const visibleDumps = votes.filter((d) =>
votedIds.has(d.id) || d.id in fading
);
const visibleDumps = votes.filter((d) => votedIds.has(d.id) || d.id in fading);
return (
<PageShell>