v3: code quality pass, various bug fixes
This commit is contained in:
@@ -57,13 +57,16 @@ export function Dump() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
const controller = new AbortController();
|
||||
|
||||
if (preloaded) {
|
||||
fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`)
|
||||
fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
|
||||
.catch(() => {});
|
||||
return;
|
||||
return () => controller.abort();
|
||||
}
|
||||
|
||||
setDumpState({ status: "loading" });
|
||||
@@ -73,6 +76,7 @@ export function Dump() {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
const apiResponse = await res.json();
|
||||
@@ -82,14 +86,18 @@ export function Dump() {
|
||||
const dump: Dump = deserializeDump(apiResponse.data);
|
||||
setDumpState({ status: "loaded", dump });
|
||||
|
||||
fetch(`${API_URL}/api/users/by-id/${dump.userId}`)
|
||||
fetch(`${API_URL}/api/users/by-id/${dump.userId}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
|
||||
.catch(() => {});
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setDumpState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [selectedDump, preloaded]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,7 +113,9 @@ export function Dump() {
|
||||
// Fetch comments when dump loads
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
const controller = new AbortController();
|
||||
fetch(`${API_URL}/api/dumps/${selectedDump}/comments`, {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
@@ -115,6 +125,7 @@ export function Dump() {
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [selectedDump, token]);
|
||||
|
||||
// Scroll to and highlight a comment when navigating to #comment-{id}
|
||||
@@ -133,8 +144,11 @@ export function Dump() {
|
||||
}, [comments, location.hash]);
|
||||
|
||||
// React to WS comment events
|
||||
// Note: selectedDump may be a slug, but lastCommentEvent.dumpId is always a UUID.
|
||||
// Compare against the loaded dump's actual ID.
|
||||
const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null;
|
||||
useEffect(() => {
|
||||
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return;
|
||||
if (!lastCommentEvent || !loadedDumpId || lastCommentEvent.dumpId !== loadedDumpId) return;
|
||||
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
|
||||
setComments((prev) => {
|
||||
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) {
|
||||
@@ -161,7 +175,7 @@ export function Dump() {
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [lastCommentEvent, selectedDump]);
|
||||
}, [lastCommentEvent, loadedDumpId]);
|
||||
|
||||
if (dumpState.status === "loading") {
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -11,11 +12,12 @@ import { Avatar } from "../components/Avatar.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { AppHeader } from "../components/AppHeader.tsx";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
|
||||
import {
|
||||
deserializeDump,
|
||||
type Dump,
|
||||
hydrateDump,
|
||||
type PaginatedData,
|
||||
type RawDump,
|
||||
type User,
|
||||
@@ -29,12 +31,6 @@ import { useWS } from "../hooks/useWS.ts";
|
||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// After JSON roundtrip, createdAt is a string — re-parse it
|
||||
const hydrateDump = (raw: Dump): Dump =>
|
||||
deserializeDump(raw as unknown as RawDump);
|
||||
|
||||
type DumpsState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -210,11 +206,13 @@ export function Index() {
|
||||
useEffect(() => {
|
||||
if (mainFetchDone.current || cached) return;
|
||||
mainFetchDone.current = true;
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`,
|
||||
`${API_URL}/api/dumps/?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
},
|
||||
);
|
||||
@@ -229,12 +227,17 @@ export function Index() {
|
||||
loadingMore: false,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setDumpsState({
|
||||
status: "error",
|
||||
error: friendlyFetchError(err),
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mainFetchDone.current = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [cached, token]);
|
||||
|
||||
// ── Followed feeds fetch (lazy, on first tab open) ──
|
||||
@@ -252,7 +255,7 @@ export function Index() {
|
||||
loadingMore: false,
|
||||
});
|
||||
} else {
|
||||
fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${PAGE_SIZE}`, {
|
||||
fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
@@ -286,7 +289,7 @@ export function Index() {
|
||||
});
|
||||
} else {
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/playlists?page=1&limit=${PAGE_SIZE}`,
|
||||
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
@@ -312,7 +315,7 @@ export function Index() {
|
||||
}
|
||||
}, [
|
||||
tab,
|
||||
user?.id,
|
||||
user,
|
||||
token,
|
||||
cachedFollowedUsers,
|
||||
cachedFollowedPlaylists,
|
||||
@@ -331,7 +334,7 @@ export function Index() {
|
||||
setDumpsState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||
);
|
||||
fetch(`${API_URL}/api/dumps/?page=${nextPage}&limit=${PAGE_SIZE}`, {
|
||||
fetch(`${API_URL}/api/dumps/?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
@@ -368,7 +371,7 @@ export function Index() {
|
||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
@@ -407,7 +410,7 @@ export function Index() {
|
||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
@@ -529,7 +532,7 @@ export function Index() {
|
||||
const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : [];
|
||||
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
|
||||
|
||||
const restIds = new Set(dumps.map((d) => d.id));
|
||||
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
|
||||
const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps]
|
||||
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { Tooltip } from "../components/Tooltip.tsx";
|
||||
@@ -22,8 +22,6 @@ import { deserializeNotification } from "../model.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -219,7 +217,7 @@ export function Notifications() {
|
||||
useEffect(() => {
|
||||
// 1. Fetch with original read state so unread items are highlighted
|
||||
// 2. Only after displaying, mark all read on the server
|
||||
authFetch(`${API_URL}/api/notifications?page=1&limit=${PAGE_SIZE}`)
|
||||
authFetch(`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("Failed to load");
|
||||
@@ -270,7 +268,7 @@ export function Notifications() {
|
||||
const nextPage = state.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||
authFetch(
|
||||
`${API_URL}/api/notifications?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
`${API_URL}/api/notifications?page=${nextPage}&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
|
||||
@@ -92,10 +92,16 @@ export function PlaylistDetail() {
|
||||
cancels.current.forEach((c) => c());
|
||||
}, []);
|
||||
|
||||
const fetchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchPlaylist = () => {
|
||||
if (!playlistId) return;
|
||||
fetchAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
fetchAbortRef.current = controller;
|
||||
setState({ status: "loading" });
|
||||
fetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => {
|
||||
@@ -122,6 +128,7 @@ export function PlaylistDetail() {
|
||||
cancels.current.clear();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === "AbortError") return;
|
||||
setState({
|
||||
status: "error",
|
||||
error: friendlyFetchError(err),
|
||||
@@ -131,6 +138,7 @@ export function PlaylistDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylist();
|
||||
return () => fetchAbortRef.current?.abort();
|
||||
}, [playlistId]);
|
||||
|
||||
// Start the cooldown→dismissing→gone sequence for a dump being removed.
|
||||
@@ -465,8 +473,10 @@ export function PlaylistDetail() {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: editTitle,
|
||||
description: editDescription || undefined,
|
||||
...(editTitle !== state.playlist.title ? { title: editTitle } : {}),
|
||||
...(editDescription !== (state.playlist.description ?? "")
|
||||
? { description: editDescription || null }
|
||||
: {}),
|
||||
isPublic: editIsPublic,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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 { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
@@ -22,10 +23,6 @@ import { DumpCreateModal } from "../components/DumpCreateModal.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 }
|
||||
@@ -41,7 +38,7 @@ type State =
|
||||
export function UserDumps() {
|
||||
const { username } = useParams();
|
||||
const { user: me, token } = useAuth();
|
||||
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||
const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS();
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:user-dumps-full:${username ?? ""}`,
|
||||
hydrateDump,
|
||||
@@ -56,19 +53,27 @@ export function UserDumps() {
|
||||
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) });
|
||||
}, []);
|
||||
const addFilter = useCallback((dump: Dump): boolean => {
|
||||
if (!profileUserId) return false;
|
||||
if (dump.userId !== profileUserId) return false;
|
||||
return isOwnProfile || !dump.isPrivate;
|
||||
}, [profileUserId, isOwnProfile]);
|
||||
useDumpListSync(setDumps, addFilter);
|
||||
const dumpItems = state.status === "loaded" ? state.dumps : [];
|
||||
usePositionAwareSync(
|
||||
dumpItems,
|
||||
setDumps,
|
||||
lastDumpEvent,
|
||||
(d) => d.isPrivate,
|
||||
(d) => !d.isPrivate && d.userId === profileUserId,
|
||||
);
|
||||
useDumpListSync(setDumps, {
|
||||
ownerId: profileUserId ?? undefined,
|
||||
isOwner: isOwnProfile,
|
||||
skipReinsert: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
setState({ status: "loading" });
|
||||
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");
|
||||
@@ -81,23 +86,21 @@ export function UserDumps() {
|
||||
loadingMore: false,
|
||||
});
|
||||
})
|
||||
.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}/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 },
|
||||
),
|
||||
])
|
||||
.then(([userRes, dumpsRes]) =>
|
||||
@@ -117,12 +120,11 @@ export function UserDumps() {
|
||||
loadingMore: false,
|
||||
});
|
||||
})
|
||||
.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 loadMore = useCallback(() => {
|
||||
@@ -133,7 +135,7 @@ export function UserDumps() {
|
||||
const nextPage = state.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 {
|
||||
PaginatedData,
|
||||
@@ -15,9 +15,11 @@ import type {
|
||||
PublicUser,
|
||||
RawPlaylist,
|
||||
} from "../model.ts";
|
||||
import { deserializePlaylist, deserializePublicUser } from "../model.ts";
|
||||
import { deserializePlaylist, deserializePublicUser, hydratePlaylist } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
||||
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
@@ -27,10 +29,6 @@ import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const hydratePlaylist = (raw: Playlist): Playlist =>
|
||||
deserializePlaylist(raw as unknown as RawPlaylist);
|
||||
|
||||
interface PlaylistFeed {
|
||||
items: Playlist[];
|
||||
hasMore: boolean;
|
||||
@@ -55,6 +53,7 @@ function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
|
||||
export function UserPlaylists() {
|
||||
const { username } = useParams();
|
||||
const { user: me, authFetch, token } = useAuth();
|
||||
const { lastPlaylistEvent } = useWS();
|
||||
|
||||
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
|
||||
Playlist
|
||||
@@ -82,9 +81,21 @@ export function UserPlaylists() {
|
||||
: { ...s, created: { ...s.created, items: fn(s.created.items) } }
|
||||
);
|
||||
}, []);
|
||||
const createdItems = state.status === "loaded" ? state.created.items : [];
|
||||
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
|
||||
? (lastPlaylistEvent.playlist ?? null)
|
||||
: null;
|
||||
usePositionAwareSync(
|
||||
createdItems,
|
||||
setCreated,
|
||||
lastPlaylistItem,
|
||||
(p) => !p.isPublic,
|
||||
(p) => p.isPublic && p.userId === profileUserId,
|
||||
);
|
||||
usePlaylistListSync(setCreated, {
|
||||
isOwner: isOwnProfile,
|
||||
ownerId: profileUserId ?? undefined,
|
||||
skipReinsert: true,
|
||||
});
|
||||
|
||||
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||
@@ -99,13 +110,14 @@ export function UserPlaylists() {
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
setState({ status: "loading" });
|
||||
const controller = new AbortController();
|
||||
|
||||
const authHeaders: HeadersInit = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
|
||||
if (cachedCreated && cachedFollowed) {
|
||||
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");
|
||||
@@ -126,23 +138,22 @@ export function UserPlaylists() {
|
||||
},
|
||||
});
|
||||
})
|
||||
.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();
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
fetch(`${API_URL}/api/users/${username}`),
|
||||
fetch(`${API_URL}/api/users/${username}`, { 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 },
|
||||
),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${PAGE_SIZE}`,
|
||||
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
])
|
||||
.then(([userRes, createdRes, followedRes]) =>
|
||||
@@ -169,12 +180,11 @@ export function UserPlaylists() {
|
||||
),
|
||||
});
|
||||
})
|
||||
.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 loadMoreCreated = useCallback(() => {
|
||||
@@ -189,7 +199,7 @@ export function UserPlaylists() {
|
||||
: s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
@@ -230,7 +240,7 @@ export function UserPlaylists() {
|
||||
: s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user