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,15 @@ import React, {
} from "react";
import { Link, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
import {
deserializeAuthResponse,
deserializeDump,
deserializePublicUser,
deserializeUser,
hydrateDump,
hydratePlaylist,
type RawDump,
type RawUser,
} from "../model.ts";
@@ -26,7 +28,9 @@ import { PageError } from "../components/PageError.tsx";
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 { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
@@ -37,8 +41,6 @@ import { friendlyFetchError } from "../utils/apiError.ts";
import { TextEditor } from "../components/TextEditor.tsx";
import { Markdown } from "../components/Markdown.tsx";
const PAGE_SIZE = 20;
function InviteButton() {
const { authFetch } = useAuth();
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
@@ -89,11 +91,6 @@ function InviteButton() {
);
}
const hydrateDump = (raw: Dump): Dump =>
deserializeDump(raw as unknown as RawDump);
const hydratePlaylist = (raw: Playlist): Playlist =>
deserializePlaylist(raw as unknown as RawPlaylist);
interface PaginatedList<T> {
items: T[];
hasMore: boolean;
@@ -125,6 +122,8 @@ export function UserPublicProfile() {
myVotes,
lastVoteEvent,
lastDumpEvent,
lastPlaylistEvent,
lastUserEvent,
castVote,
removeVote,
} = useWS();
@@ -149,89 +148,47 @@ export function UserPublicProfile() {
const profileUserId = state.status === "loaded" ? state.user.id : null;
const isOwnProfile = me?.id === profileUserId;
const removedDumpPositionsRef = useRef<Map<string, number>>(new Map());
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => {
if (s.status !== "loaded") return s;
const prev = s.dumps.items;
const next = fn(prev);
if (next.length < prev.length) {
const nextIds = new Set(next.map((d) => d.id));
prev.forEach((d, idx) => {
if (!nextIds.has(d.id)) {
removedDumpPositionsRef.current.set(d.id, idx);
}
});
}
return { ...s, dumps: { ...s.dumps, items: next } };
});
setState((s) =>
s.status !== "loaded"
? s
: { ...s, dumps: { ...s.dumps, items: fn(s.dumps.items) } }
);
}, []);
// No addFilter — insertion at correct position is handled by the effect below.
useDumpListSync(setDumps);
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
new Set(),
const dumpItems = state.status === "loaded" ? state.dumps.items : [];
usePositionAwareSync(
dumpItems,
setDumps,
lastDumpEvent,
(d) => d.isPrivate,
(d) => !d.isPrivate && d.userId === profileUserId,
);
useDumpListSync(setDumps, {
ownerId: profileUserId ?? undefined,
isOwner: isOwnProfile,
skipReinsert: true,
});
// Tracks the list index of each dump at the moment it was removed from the
// votes list, so we can re-insert it at the correct position when it becomes
// public again (instead of always prepending at position 0).
const removedVotePositionsRef = useRef<Map<string, number>>(new Map());
// Dump IDs removed due to vote withdrawal — must not be re-inserted on
// a future dump_updated event (that would only be for private→public transitions).
const withdrawnVoteIdsRef = useRef<Set<string>>(new Set());
const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => {
if (s.status !== "loaded") return s;
const prev = s.votes.items;
const next = fn(prev);
if (next.length < prev.length) {
const nextIds = new Set(next.map((d) => d.id));
prev.forEach((d, idx) => {
if (!nextIds.has(d.id)) {
removedVotePositionsRef.current.set(d.id, idx);
}
});
}
return { ...s, votes: { ...s.votes, items: next } };
});
setState((s) =>
s.status !== "loaded"
? s
: { ...s, votes: { ...s.votes, items: fn(s.votes.items) } }
);
}, []);
useDumpListSync(setVotes);
// Re-insert a vote-list dump at its original position after private→public.
// Skip dumps whose vote was explicitly withdrawn (those were removed intentionally).
useEffect(() => {
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
const dump = lastDumpEvent;
if (withdrawnVoteIdsRef.current.has(dump.id)) return;
const savedIdx = removedVotePositionsRef.current.get(dump.id);
if (savedIdx === undefined) return;
removedVotePositionsRef.current.delete(dump.id);
setVotes((prev) => {
if (prev.some((d) => d.id === dump.id)) return prev;
const next = [...prev];
next.splice(Math.min(savedIdx, next.length), 0, dump);
return next;
});
}, [lastDumpEvent, setVotes]);
// Re-insert a dumps-column dump at its original position after private→public.
useEffect(() => {
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
const dump = lastDumpEvent;
if (dump.userId !== profileUserId) return;
const savedIdx = removedDumpPositionsRef.current.get(dump.id);
if (savedIdx === undefined) return;
removedDumpPositionsRef.current.delete(dump.id);
setDumps((prev) => {
if (prev.some((d) => d.id === dump.id)) return prev;
const next = [...prev];
next.splice(Math.min(savedIdx, next.length), 0, dump);
return next;
});
}, [lastDumpEvent, profileUserId, setDumps]);
const voteItems = state.status === "loaded" ? state.votes.items : [];
usePositionAwareSync(
voteItems,
setVotes,
lastDumpEvent,
(d) => d.isPrivate,
(d) => !d.isPrivate && !withdrawnVoteIdsRef.current.has(d.id),
);
useDumpListSync(setVotes, { skipReinsert: true });
const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
setState((s) =>
@@ -240,11 +197,33 @@ export function UserPublicProfile() {
: { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } }
);
}, []);
const playlistItems = state.status === "loaded" ? state.playlists.items : [];
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
? (lastPlaylistEvent.playlist ?? null)
: null;
usePositionAwareSync(
playlistItems,
setPlaylists,
lastPlaylistItem,
(p) => !p.isPublic,
(p) => p.isPublic && p.userId === profileUserId,
);
usePlaylistListSync(setPlaylists, {
isOwner: isOwnProfile,
ownerId: profileUserId ?? undefined,
skipReinsert: true,
});
// Update profile user when they edit their own profile
useEffect(() => {
if (!lastUserEvent) return;
const { user } = lastUserEvent;
setState((s) => {
if (s.status !== "loaded" || s.user.id !== user.id) return s;
return { ...s, user };
});
}, [lastUserEvent]);
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -258,18 +237,21 @@ export function UserPublicProfile() {
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
prevMyVotesRef.current = null;
const controller = new AbortController();
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
if (allCached) {
// Only fetch the user object (lightweight, always fresh)
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");
const profileUser = deserializePublicUser(body.data);
setState({
status: "loaded",
user: deserializePublicUser(body.data),
user: profileUser,
dumps: {
items: cachedDumps.items,
hasMore: cachedDumps.hasMore,
@@ -289,15 +271,12 @@ export function UserPublicProfile() {
loadingMore: false,
},
});
setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id)));
})
.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();
}
(async () => {
@@ -306,18 +285,20 @@ export function UserPublicProfile() {
? { Authorization: `Bearer ${token}` }
: {};
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
fetch(`${API_URL}/api/users/${username}`),
fetch(`${API_URL}/api/users/${username}`, {
signal: controller.signal,
}),
fetch(
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
`${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, 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 },
),
fetch(
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
`${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
]);
@@ -347,10 +328,11 @@ export function UserPublicProfile() {
? dumpsBody.data
: { items: [], total: 0, hasMore: false };
const profileUser = deserializePublicUser(userBody.data);
const voteItems = votesData.items.map(deserializeDump);
setState({
status: "loaded",
user: deserializePublicUser(userBody.data),
user: profileUser,
dumps: initialList(
dumpsData.items.map(deserializeDump),
dumpsData.hasMore,
@@ -361,20 +343,20 @@ export function UserPublicProfile() {
playlistsData.hasMore,
),
});
setProfileVotedIds(new Set(voteItems.map((d) => d.id)));
} catch (err) {
if ((err as Error).name === "AbortError") return;
setState({
status: "error",
error: friendlyFetchError(err),
});
}
})();
return () => controller.abort();
}, [username]);
// Own profile: keep profileVotedIds in sync with myVotes
// Own profile: prepend dumps newly voted by the user to the preview list
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
setProfileVotedIds(new Set(myVotes));
if (prevMyVotesRef.current === null) {
prevMyVotesRef.current = new Set(myVotes);
return;
@@ -400,35 +382,28 @@ export function UserPublicProfile() {
if (!lastVoteEvent || !profileUserId) return;
const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return;
const isOwnProfile = me?.id === profileUserId;
if (action === "remove") {
if (!isOwnProfile) {
setProfileVotedIds((prev) => {
const n = new Set(prev);
n.delete(dumpId);
return n;
});
}
// Keep dump in state.votes.items as a ghost — UpvotedDumpList drives
// its own votedIds + fading state and will animate the removal.
withdrawnVoteIdsRef.current.add(dumpId);
setVotes((prev) => prev.filter((d) => d.id !== dumpId));
} else {
withdrawnVoteIdsRef.current.delete(dumpId);
if (!isOwnProfile) {
setProfileVotedIds((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.items.some((d) => d.id === dumpId)
) {
return s;
if (s.status !== "loaded") return s;
const idx = s.votes.items.findIndex((d) => d.id === dumpId);
if (idx !== -1) {
// Ghost re-voted: update in-place.
const items = [...s.votes.items];
items[idx] = dump;
return { ...s, votes: { ...s.votes, items } };
}
// First-time vote: prepend.
return {
...s,
votes: { ...s.votes, items: [dump, ...s.votes.items] },
@@ -437,7 +412,7 @@ export function UserPublicProfile() {
})
.catch(() => {});
}
}, [lastVoteEvent, me, profileUserId]);
}, [lastVoteEvent, profileUserId]);
// Save scroll position + loaded state to sessionStorage on scroll
useEffect(() => {
@@ -465,6 +440,19 @@ export function UserPublicProfile() {
};
}, [state, saveDumps, saveVotes, savePlaylists]);
// Keep the playlists cache current whenever the list changes (e.g. via WS),
// so a page refresh restores the up-to-date list rather than a stale snapshot.
const playlistFeed = state.status === "loaded" ? state.playlists : null;
useEffect(() => {
if (!playlistFeed) return;
savePlaylists(
playlistFeed.items,
playlistFeed.page,
playlistFeed.hasMore,
globalThis.scrollY,
);
}, [playlistFeed, savePlaylists]);
// Restore scroll position after cache restoration
const scrollRestored = useRef(false);
useLayoutEffect(() => {
@@ -734,9 +722,10 @@ export function UserPublicProfile() {
/>
<UpvotedDumpList
title={`Upvoted (${profileVotedIds.size}${votes.hasMore ? "+" : ""})`}
title={`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
dumps={votes.items}
votedIds={profileVotedIds}
profileUserId={profileUserId}
isOwnProfile={isOwnProfile}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
@@ -865,7 +854,8 @@ function UpvotedDumpList(
{
title,
dumps,
votedIds,
profileUserId,
isOwnProfile,
voteCounts,
myVotes,
canVote,
@@ -875,7 +865,8 @@ function UpvotedDumpList(
}: {
title: string;
dumps: Dump[];
votedIds: Set<string>;
profileUserId: string | null;
isOwnProfile: boolean;
voteCounts: Record<string, number>;
myVotes: Set<string>;
canVote: boolean;
@@ -884,84 +875,46 @@ function UpvotedDumpList(
viewAllHref: string;
},
) {
const [fading, setFading] = useState<
Record<string, "cooldown" | "dismissing">
>({});
const cancels = useRef<Map<string, () => void>>(new Map());
const prevVotedIds = useRef<Set<string> | null>(null);
const { myVotes: wsMyVotes, lastVoteEvent } = useWS();
const { fading, startFading, cancelFading } = useFading();
useEffect(() => () => {
cancels.current.forEach((c) => c());
}, []);
// votedIds is managed locally so setVotedIds + startFading/cancelFading can
// be called in the same effect body — guaranteeing a single render where the
// dump is always in visibleDumps (with or without fading class). This prevents
// the DOM node from being unmounted/remounted, which would break CSS transitions.
const [votedIds, setVotedIds] = useState(() => new Set(dumps.map((d) => d.id)));
const prevMyVotesRef = useRef<Set<string> | null>(null);
// Own profile: sync votedIds with myVotes; start/cancel fading in same batch.
useEffect(() => {
if (prevVotedIds.current === null) {
prevVotedIds.current = new Set(votedIds);
if (!profileUserId || !isOwnProfile) return;
if (prevMyVotesRef.current === null) {
setVotedIds(new Set(wsMyVotes));
prevMyVotesRef.current = new Set(wsMyVotes);
return;
}
const prev = prevMyVotesRef.current;
setVotedIds(new Set(wsMyVotes));
for (const id of prev) { if (!wsMyVotes.has(id)) startFading(id); }
for (const id of wsMyVotes) { if (!prev.has(id)) cancelFading(id); }
prevMyVotesRef.current = new Set(wsMyVotes);
}, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
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());
}
// Non-own profile: sync votedIds with WS vote events for the profile user.
useEffect(() => {
if (!lastVoteEvent || !profileUserId || isOwnProfile) 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; });
startFading(dumpId);
} else {
setVotedIds((prev) => new Set([...prev, dumpId]));
cancelFading(dumpId);
}
}, [lastVoteEvent, profileUserId, isOwnProfile, startFading, cancelFading]);
for (const id of votedIds) {
if (!prev.has(id) && cancels.current.has(id)) {
cancels.current.get(id)!();
}
}
prevVotedIds.current = new Set(votedIds);
}, [votedIds]);
const visibleDumps = dumps.filter((d) =>
votedIds.has(d.id) || d.id in fading
);
const visibleDumps = dumps.filter((d) => votedIds.has(d.id) || d.id in fading);
return (
<section className="profile-section">