v3: the profile page is split by one rule, and what it re-invented is shared across the app — UserPublicProfile was 1,949 lines because it held its data, six tabs, seven subcomponents and hand-rolled copies of things the app already had or repeated elsewhere. The page now only picks the tab and puts the pieces together (253 lines); useProfile holds the user and the three lists with their live sync, cache and scroll; each tab and header piece is one file under components/profile/; the followed and invitees tabs load on first open through the new useFetchState, keyed by username, which keeps them across tab switches. Every piece taken out replaces its copies: DumpCardList, which reads the votes from WSContext, is every list of dump cards (the index feeds, followed, search, related dumps, UserDumps, UserUpvoted, the profile), so Index no longer threads vote props into the feeds; PlaylistCardList every list of playlist cards; FeedFooter the eight sentinel/loading-more/end blocks; ListState the loading/error/empty chains; Segmented the settings toggles, RoleSwitcher and the playlist edit form, with SegmentedField built on it; LoadMoreButton the three load-more buttons; NewDumpButton UserDumps' and the profile's; useScrollRestore four copies of the scroll restore, and the profile uses useScrollSave instead of saving its whole cache on every scroll. useFading takes an onFaded callback, which replaces PlaylistDetail's copy of its timers; useUpvotedList is the vote tracking UserUpvoted and the profile each had, and a withdrawn upvote now leaves the list and the cache once faded (it was only hidden, and came back after a tab switch). usePlaylistMemberships is the membership load and toggle AddToPlaylistModal and DumpCreateModal each had. pagedList.ts (PagedList, firstPage, restoredList, fetchFirstPage, withFirst), fetchPublicUser and dataOr are shared by the profile, UserPlaylists and useUserDumpFeed; a refused side list still shows empty, as documented, and only the user fails the page. FollowedFeed's two sections are one local hook, and its readPage is expectOk. A card is its owner's when the item is the signed-in user's, everywhere: the profile's upvoted column marked every dump as the owner's on one's own profile. No message changed; the catalogs only move their references. 643 frontend tests pass (11 new for useFetchState, useFading's onFaded and pagedList), and tsc, eslint, doc:lint, deno check and deno fmt are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-29 20:50:49 +00:00
parent 80f3292c8f
commit 69d9bf899b
54 changed files with 3460 additions and 3601 deletions

View File

@@ -0,0 +1,233 @@
/**
* Loads a profile's user with the first page of their dumps, upvotes and
* playlists, and keeps them live.
*
* @module
*/
import { useCallback, useEffect, useState } from "react";
import { API_URL } from "../../config/api.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts";
import type { Wire } from "../../../shared/api.ts";
import { friendlyFetchError } from "../../utils/shared/api.ts";
import {
deserializeDump,
deserializePlaylist,
hydrateDump,
hydratePlaylist,
} from "../../utils/shared/deserialize.ts";
import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts";
import type { LoadState } from "../../utils/shared/loadState.ts";
import {
fetchFirstPage,
type PagedList,
restoredList,
} from "../../utils/feed/pagedList.ts";
import { useAuth } from "../auth/useAuth.ts";
import { useWS } from "../ws/useWS.ts";
import { useFeedCache } from "../feed/useFeedCache.ts";
import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts";
import { usePositionAwareSync } from "../feed/usePositionAwareSync.ts";
import { useDumpListSync } from "../dump/useDumpListSync.ts";
import { useUpvotedList } from "../dump/useUpvotedList.ts";
import { usePlaylistListSync } from "../playlist/usePlaylistListSync.ts";
/**
* The profile's lists, each holding its first page only: each has its own page
* for the rest.
*/
export interface ProfileData {
user: PublicUser;
dumps: PagedList<Dump>;
votes: PagedList<Dump>;
playlists: PagedList<Playlist>;
}
/** Returned by {@link useProfile} to `UserPublicProfile`. */
export interface UseProfileResult {
state: LoadState<ProfileData>;
/** Whether the signed-in user is the profile's. False while it loads. */
isOwnProfile: boolean;
/** For the header's edits, which the server echoes to other browsers. */
setUser: (fn: (user: PublicUser) => PublicUser) => void;
/** For `NewPlaylistButton`, whose playlist the socket echoes later. */
setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void;
/** From `useUpvotedList`, for the upvoted list's `DumpCardList`. */
upvotedCardClassName: (dump: Dump) => string | undefined;
}
type ListKey = "dumps" | "votes" | "playlists";
/** Returns `s` with `fn` applied to the items of its list `key`. */
function updateList<K extends ListKey>(
s: LoadState<ProfileData>,
key: K,
fn: (items: ProfileData[K]["items"]) => ProfileData[K]["items"],
): LoadState<ProfileData> {
if (s.status !== "loaded") return s;
return { ...s, [key]: { ...s[key], items: fn(s[key].items) } };
}
/**
* Returns the profile of `username`, from `GET /api/users/:username`, with the
* first pages of `…/dumps`, `…/votes` and `…/playlists`, fetched signed in so
* that users see their own private items. A list the server refuses shows
* empty; the page fails only with the user. The socket's events keep the lists
* live, and `useUpvotedList` the upvoted one. The lists and the scroll
* position are saved as the page is left, and going back restores them,
* refetching only the user.
*/
export function useProfile(username: string | undefined): UseProfileResult {
const { user: me, token } = useAuth();
const { lastDumpEvent, lastPlaylistEvent, lastUserEvent } = useWS();
const dumpsCache = useFeedCache<Dump>(
`feed:profile-dumps:${username ?? ""}`,
hydrateDump,
);
const votesCache = useFeedCache<Dump>(
`feed:profile-votes:${username ?? ""}`,
hydrateDump,
);
const playlistsCache = useFeedCache<Playlist>(
`feed:profile-playlists:${username ?? ""}`,
hydratePlaylist,
);
const [state, setState] = useState<LoadState<ProfileData>>({
status: "loading",
});
const [prevUsername, setPrevUsername] = useState(username);
if (prevUsername !== username) {
setPrevUsername(username);
setState({ status: "loading" });
}
const profileUserId = state.status === "loaded" ? state.user.id : null;
const isOwnProfile = !!me && me.id === profileUserId;
const setUser = useCallback((fn: (user: PublicUser) => PublicUser) => {
setState((s) => s.status === "loaded" ? { ...s, user: fn(s.user) } : s);
}, []);
const setDumps = useCallback((fn: (items: Dump[]) => Dump[]) => {
setState((s) => updateList(s, "dumps", fn));
}, []);
const setVotes = useCallback((fn: (items: Dump[]) => Dump[]) => {
setState((s) => updateList(s, "votes", fn));
}, []);
const setPlaylists = useCallback((fn: (items: Playlist[]) => Playlist[]) => {
setState((s) => updateList(s, "playlists", fn));
}, []);
const dumpItems = state.status === "loaded" ? state.dumps.items : [];
usePositionAwareSync(
dumpItems,
setDumps,
lastDumpEvent,
(d) => !d.isPrivate && d.userId === profileUserId,
);
useDumpListSync(setDumps, { isOwner: isOwnProfile });
const upvoted = useUpvotedList({
profileUserId,
setItems: setVotes,
source: dumpItems,
});
const voteItems = state.status === "loaded" ? state.votes.items : [];
usePositionAwareSync(
voteItems,
setVotes,
lastDumpEvent,
(d) => !d.isPrivate && !upvoted.isWithdrawn(d.id),
);
useDumpListSync(setVotes);
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.userId === profileUserId,
);
usePlaylistListSync(setPlaylists, {
isOwner: isOwnProfile,
ownerId: profileUserId ?? undefined,
});
useEffect(() => {
if (!lastUserEvent) return;
// Mirrors a socket event into state, which can only happen in an effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setUser((u) => u.id === lastUserEvent.user.id ? lastUserEvent.user : u);
}, [lastUserEvent, setUser]);
const cachedDumps = dumpsCache.cached;
const cachedVotes = votesCache.cached;
const cachedPlaylists = playlistsCache.cached;
useEffect(() => {
if (!username) return;
const controller = new AbortController();
const { signal } = controller;
const headers: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
const firstPageOf = <T>(list: string, deserialize: (w: Wire<T>) => T) =>
fetchFirstPage(`${API_URL}/api/users/${username}/${list}`, deserialize, {
headers,
signal,
});
// Back on the page, the lists come from the cache and only the user is
// fetched again.
const cached = cachedDumps && cachedVotes && cachedPlaylists;
Promise.all([
fetchPublicUser(username, signal),
cached
? restoredList(cachedDumps)
: firstPageOf("dumps", deserializeDump),
cached
? restoredList(cachedVotes)
: firstPageOf("votes", deserializeDump),
cached
? restoredList(cachedPlaylists)
: firstPageOf("playlists", deserializePlaylist),
])
.then(([user, dumps, votes, playlists]) =>
setState({ status: "loaded", user, dumps, votes, playlists })
)
.catch((err) => {
if (signal.aborted) return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username, cachedDumps, cachedVotes, cachedPlaylists, token]);
const saveDumps = dumpsCache.saveState;
const saveVotes = votesCache.saveState;
const savePlaylists = playlistsCache.saveState;
useScrollSave(
state.status === "loaded",
useCallback((y) => {
if (state.status !== "loaded") return;
const { dumps, votes, playlists } = state;
saveDumps(dumps.items, dumps.page, dumps.hasMore, y);
saveVotes(votes.items, votes.page, votes.hasMore, y);
savePlaylists(playlists.items, playlists.page, playlists.hasMore, y);
}, [state, saveDumps, saveVotes, savePlaylists]),
);
useScrollRestore(cachedDumps?.scrollY, state.status === "loaded");
return {
state,
isOwnProfile,
setUser,
setPlaylists,
upvotedCardClassName: upvoted.cardClassName,
};
}