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,142 @@
/**
* Keeps a list of the dumps a user upvoted in step with their votes.
*
* @module
*/
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { API_URL } from "../../config/api.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { Wire } from "../../../shared/api.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { deserializeDump } from "../../utils/shared/deserialize.ts";
import { useAuth } from "../auth/useAuth.ts";
import { useWS } from "../ws/useWS.ts";
import { fadeClassName, useFading } from "../feed/useFading.ts";
/** Passed to {@link useUpvotedList} by `UserUpvoted` and the profile. */
export interface UseUpvotedListOptions {
/** Whose upvotes the list holds, null while their profile loads. */
profileUserId: string | null;
/** Does nothing until the list is loaded. */
setItems: (fn: (prev: Dump[]) => Dump[]) => void;
/**
* Dumps already at hand, which a vote of the signed-in user on their own
* list puts first at once, before the socket's vote event. The profile
* passes its first page of the user's dumps.
*/
source?: Dump[];
}
/** Returned by {@link useUpvotedList}. */
export interface UseUpvotedListResult {
/** For `DumpCardList`'s `cardClassName`. */
cardClassName: (dump: Dump) => string | undefined;
/**
* Whether the vote on dump `id` was withdrawn since the list loaded. The
* profile's `usePositionAwareSync` keeps such a dump from coming back.
*/
isWithdrawn: (id: string) => boolean;
}
/**
* Follows the votes of `profileUserId` into their upvoted list. A dump they
* upvote is fetched and put first. One whose vote they withdraw fades out
* (`useFading`), then leaves the list, unless they vote again meanwhile. The
* socket's vote events report every browser's votes; on one's own list,
* `myVotes` also reports this tab's at once.
*/
export function useUpvotedList(
{ profileUserId, setItems, source }: UseUpvotedListOptions,
): UseUpvotedListResult {
const { user: me, token } = useAuth();
const { myVotes, lastVoteEvent } = useWS();
const isOwn = !!profileUserId && me?.id === profileUserId;
const withdrawnRef = useRef<Set<string>>(new Set());
const prevMyVotesRef = useRef<Set<string> | null>(null);
// A ref, so that a new first page doesn't count as new votes.
const sourceRef = useRef(source);
useLayoutEffect(() => {
sourceRef.current = source;
});
const { fading, startFading, cancelFading, cancelAll } = useFading((id) =>
setItems((items) => items.filter((d) => d.id !== id))
);
const withdraw = useCallback((id: string) => {
withdrawnRef.current.add(id);
startFading(id);
}, [startFading]);
const recast = useCallback((id: string) => {
withdrawnRef.current.delete(id);
cancelFading(id);
}, [cancelFading]);
// Another user's list starts afresh. This runs before the effect below that
// diffs the votes, so its baseline resets in time.
useEffect(() => {
withdrawnRef.current = new Set();
prevMyVotesRef.current = null;
cancelAll();
}, [profileUserId, cancelAll]);
useEffect(() => {
if (!isOwn) return;
const prev = prevMyVotesRef.current;
prevMyVotesRef.current = new Set(myVotes);
// Only a baseline: the list as loaded stands. myVotes may still be the
// empty set from before the socket's welcome (or the socket may never
// connect), and taking it as the truth here would hide every dump.
if (prev === null) return;
for (const id of prev) if (!myVotes.has(id)) withdraw(id);
const added = new Set([...myVotes].filter((id) => !prev.has(id)));
for (const id of added) recast(id);
const known = (sourceRef.current ?? []).filter((d) => added.has(d.id));
if (known.length === 0) return;
setItems((items) => {
const ids = new Set(items.map((d) => d.id));
const toAdd = known.filter((d) => !ids.has(d.id));
return toAdd.length === 0 ? items : [...toAdd, ...items];
});
}, [myVotes, isOwn, withdraw, recast, setItems]);
useEffect(() => {
if (!lastVoteEvent || !profileUserId) return;
const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return;
if (action === "remove") {
withdraw(dumpId);
return;
}
recast(dumpId);
// Fetched signed in, or a private dump the viewer owns would be refused.
fetch(`${API_URL}/api/dumps/${dumpId}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then(expectOk<Wire<Dump>>)
.then((wire) => {
const dump = deserializeDump(wire);
setItems((items) =>
// A withdrawn vote cast again before its dump left the list.
items.some((d) => d.id === dumpId)
? items.map((d) => d.id === dumpId ? dump : d)
: [dump, ...items]
);
})
.catch(() => {});
}, [lastVoteEvent, profileUserId, token, withdraw, recast, setItems]);
const cardClassName = useCallback(
(dump: Dump) => fadeClassName(fading[dump.id]),
[fading],
);
const isWithdrawn = useCallback(
(id: string) => withdrawnRef.current.has(id),
[],
);
return { cardClassName, isWithdrawn };
}

View File

@@ -5,29 +5,23 @@
* @module
*/
import {
type RefCallback,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { type RefCallback, useCallback, useEffect, useState } from "react";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts";
import { fetchFirstPage } from "../../utils/feed/pagedList.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { PaginatedData, Wire } from "../../../shared/api.ts";
import type { PublicUser } from "../../../shared/user.ts";
import {
deserializeDump,
deserializePublicUser,
hydrateDump,
} from "../../utils/shared/deserialize.ts";
import { useAuth } from "../auth/useAuth.ts";
import { useFeedCache } from "../feed/useFeedCache.ts";
import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts";
import { useScrollSave } from "../feed/useScrollSave.ts";
import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts";
/**
* Held by {@link useUserDumpFeed} for `UserDumps` and `UserUpvoted`. It fails
@@ -46,18 +40,10 @@ export type UserDumpFeedState =
loadingMore: boolean;
};
/** Passed to {@link useUserDumpFeed} by `UserUpvoted`. */
export interface UseUserDumpFeedOptions {
/** Called with the dumps of each page after the first, once appended. */
onItemsAppended?: (items: Dump[]) => void;
}
/** Returned by {@link useUserDumpFeed} to `UserDumps` and `UserUpvoted`. */
export interface UseUserDumpFeedResult {
state: UserDumpFeedState;
/** Lets `UserUpvoted` prepend a dump the user just upvoted. */
setState: React.Dispatch<React.SetStateAction<UserDumpFeedState>>;
/** Does nothing until loaded. Passed to `useDumpListSync`. */
/** Does nothing until loaded. Passed to `useDumpListSync` and `useUpvotedList`. */
setItems: (fn: (prev: Dump[]) => Dump[]) => void;
/** From `useInfiniteScroll`, for an element after the last card. */
sentinelRef: RefCallback<HTMLDivElement>;
@@ -75,7 +61,6 @@ export function useUserDumpFeed(
username: string | undefined,
endpoint: string,
cacheKey: string,
options?: UseUserDumpFeedOptions,
): UseUserDumpFeedResult {
const { token } = useAuth();
const { cached, saveState } = useFeedCache<Dump>(cacheKey, hydrateDump);
@@ -94,64 +79,32 @@ export function useUserDumpFeed(
useEffect(() => {
if (!username) return;
const controller = new AbortController();
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");
setState({
status: "loaded",
profileUser: deserializePublicUser(body.data),
items: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
});
})
.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}`, { signal: controller.signal }),
fetch(
`${API_URL}/api/users/${username}/${endpoint}?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
])
.then(([userRes, itemsRes]) =>
Promise.all([userRes.json(), itemsRes.json()])
)
.then(([userBody, itemsBody]) => {
if (!userBody.success) throw new Error("User not found");
const { items, hasMore } = itemsBody.success
? itemsBody.data as PaginatedData<Wire<Dump>>
: { items: [], hasMore: false };
const { signal } = controller;
// Back on the page, the list comes from the cache and only the user is
// fetched again.
const list = cached ?? fetchFirstPage(
`${API_URL}/api/users/${username}/${endpoint}`,
deserializeDump,
{ headers: token ? { Authorization: `Bearer ${token}` } : {}, signal },
);
Promise.all([fetchPublicUser(username, signal), list])
.then(([profileUser, { items, hasMore, page }]) =>
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
items: items.map(deserializeDump),
profileUser,
items,
hasMore,
page: 1,
page,
loadingMore: false,
});
})
})
)
.catch((err) => {
if (err.name === "AbortError") return;
if (signal.aborted) return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username, endpoint, cached, token]);
const { onItemsAppended } = options ?? {};
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
@@ -177,14 +130,13 @@ export function useUserDumpFeed(
}
: s
);
onItemsAppended?.(newItems);
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [state, username, token, endpoint, onItemsAppended]);
}, [state, username, token, endpoint]);
const sentinelRef = useInfiniteScroll(
loadMore,
@@ -199,14 +151,7 @@ export function useUserDumpFeed(
}, [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]);
useScrollRestore(cached?.scrollY, state.status === "loaded");
return { state, setState, setItems, sentinelRef };
return { state, setItems, sentinelRef };
}

View File

@@ -1,100 +1,114 @@
/**
* Times the fade-out of dumps leaving an upvoted list, so that a vote withdrawn
* by mistake can be cast again before the dump is gone.
* Times the fade-out of items leaving a list, so that a removal made by
* mistake can be undone before the item is gone.
*
* @module
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
/**
* The phase of a fading item, which the list maps to a CSS class. `cooldown`
* lasts 2 seconds, and `dismissing` lasts the 350ms of the CSS collapse.
* The phase of a fading item, which {@link fadeClassName} maps to a CSS class.
* `cooldown` lasts 2 seconds, and `dismissing` lasts the 350ms of the CSS
* collapse.
*/
export type FadeState = "cooldown" | "dismissing";
const COOLDOWN_MS = 2000;
const DISMISS_MS = 350;
/**
* Returned by {@link useFading} to `UserUpvoted` and the profile's upvoted
* section. The list keeps showing an item while it is in `fading`, then drops
* it.
* Returned by {@link useFading} to `useUpvotedList` and `PlaylistDetail`. The
* list keeps showing an item while it is in `fading`.
*/
export interface UseFadingResult {
fading: Record<string, FadeState>;
/** Does nothing when `id` is already fading. */
startFading: (id: string) => void;
/** Called when the vote is cast again: the item stays. */
/** Called when the removal is undone: the item stays. */
cancelFading: (id: string) => void;
/** Called when the list changes to another user's. */
/** Called when the list is replaced, as by another user's or a reload. */
cancelAll: () => void;
}
/**
* Returns the fading state of a list's items and the calls that drive it. The
* timers are cleared on unmount.
* Returns the fading state of a list's items and the calls that drive it.
* `onFaded` is called with an item whose fade ran to its end, for the list to
* drop it; a cancelled fade doesn't call it. The timers are cleared on
* unmount.
*/
export function useFading(): UseFadingResult {
export function useFading(onFaded?: (id: string) => void): UseFadingResult {
const [fading, setFading] = useState<Record<string, FadeState>>({});
const cancels = useRef<Map<string, () => void>>(new Map());
// The running timer of each fading item, whichever phase it is in.
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
// A ref, so that the calls below stay stable for their callers' effects.
const onFadedRef = useRef(onFaded);
useLayoutEffect(() => {
onFadedRef.current = onFaded;
});
useEffect(() => () => {
cancels.current.forEach((c) => c());
useEffect(() => {
const running = timers.current;
return () => running.forEach(clearTimeout);
}, []);
const drop = useCallback((id: string) => {
timers.current.delete(id);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
}, []);
const startFading = useCallback((id: string) => {
if (cancels.current.has(id)) return;
// `kill` is replaced as each timer is set, so it always clears the one
// running; `dead` stops a timer that fired before it was cleared.
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());
if (timers.current.has(id)) return;
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);
};
cancels.current.set(id, () => kill());
}, 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());
}, []);
timers.current.set(
id,
setTimeout(() => {
setFading((f) => ({ ...f, [id]: "dismissing" }));
timers.current.set(
id,
setTimeout(() => {
drop(id);
onFadedRef.current?.(id);
}, DISMISS_MS),
);
}, COOLDOWN_MS),
);
}, [drop]);
const cancelFading = useCallback((id: string) => {
cancels.current.get(id)?.();
}, []);
const timer = timers.current.get(id);
if (timer === undefined) return;
clearTimeout(timer);
drop(id);
}, [drop]);
const cancelAll = useCallback(() => {
cancels.current.forEach((c) => c());
}, []);
for (const id of [...timers.current.keys()]) cancelFading(id);
}, [cancelFading]);
return { fading, startFading, cancelFading, cancelAll };
}
/**
* Returns the class a fading card takes in `phase`, for `DumpCard`'s
* `className`, or undefined for a card that isn't fading.
*/
export function fadeClassName(
phase: FadeState | undefined,
): string | undefined {
return phase === "cooldown"
? "dump-card--fading"
: phase === "dismissing"
? "dump-card--dismissing"
: undefined;
}

View File

@@ -1,5 +1,6 @@
/**
* Saves a feed page's state, with its scroll position, as the page is left.
* Saves a feed page's state, with its scroll position, as the page is left,
* and restores the position when the page is back.
*
* @module
*/
@@ -42,3 +43,21 @@ export function useScrollSave(
};
}, [enabled]);
}
/**
* Scrolls the window to `scrollY` once `loaded`, and never again, before
* paint so that the restored page doesn't flash at the top. Feed pages pass
* `useFeedCache`'s `cached?.scrollY`, which is undefined when nothing was
* restored.
*/
export function useScrollRestore(
scrollY: number | null | undefined,
loaded: boolean,
): void {
const restored = useRef(false);
useLayoutEffect(() => {
if (scrollY == null || restored.current || !loaded) return;
globalThis.scrollTo(0, scrollY);
restored.current = true;
}, [scrollY, loaded]);
}

View File

@@ -0,0 +1,84 @@
/**
* Loads which of the signed-in user's playlists hold a dump, and adds the dump
* to them or takes it out.
*
* @module
*/
import { useCallback } from "react";
import { API_URL } from "../../config/api.ts";
import type { PlaylistMembership } from "../../../shared/playlist.ts";
import type { Wire } from "../../../shared/api.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { deserializePlaylistMembership } from "../../utils/shared/deserialize.ts";
import { useAuth } from "../auth/useAuth.ts";
import { useFetchState } from "../shared/useFetchState.ts";
/**
* Returned by {@link usePlaylistMemberships} to `AddToPlaylistModal` and
* `DumpCreateModal`, for their `PlaylistMembershipPanel`.
*/
export interface UsePlaylistMembershipsResult {
/** Empty while loading, and when the load failed. */
memberships: PlaylistMembership[];
loading: boolean;
/**
* Adds the dump to the playlist or takes it out on the server, and shows it
* only once the server agrees. A refusal or a dropped connection changes
* nothing.
*/
toggle: (membership: PlaylistMembership) => Promise<void>;
/** Puts first a playlist the panel just created with the dump in it. */
add: (membership: PlaylistMembership) => void;
}
/**
* Returns the user's playlists with whether each holds `dumpId`, from
* `GET /api/playlists/by-dump/:dumpId/memberships`. A null `dumpId`, as before
* `DumpCreateModal` has posted its dump, loads nothing.
*/
export function usePlaylistMemberships(
dumpId: string | null,
): UsePlaylistMembershipsResult {
const { authFetch } = useAuth();
const { state, setData } = useFetchState(
dumpId ?? undefined,
(signal) =>
authFetch(`${API_URL}/api/playlists/by-dump/${dumpId}/memberships`, {
signal,
})
.then(expectOk<Wire<PlaylistMembership>[]>)
.then((items) => items.map(deserializePlaylistMembership)),
);
const toggle = useCallback(async (membership: PlaylistMembership) => {
const { playlist, hasDump } = membership;
try {
const res = await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{ method: hasDump ? "DELETE" : "POST" },
);
if (!res.ok) return;
} catch {
return;
}
setData((memberships) =>
memberships.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m
)
);
}, [authFetch, dumpId, setData]);
const add = useCallback(
(membership: PlaylistMembership) =>
setData((memberships) => [membership, ...memberships]),
[setData],
);
return {
memberships: state.status === "loaded" ? state.data : [],
loading: state.status === "loading",
toggle,
add,
};
}

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,
};
}

View File

@@ -0,0 +1,81 @@
/**
* Loads one thing from the API, once per key, when first asked to.
*
* @module
*/
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import type { LoadState } from "../../utils/shared/loadState.ts";
import { friendlyFetchError } from "../../utils/shared/api.ts";
/** Held by {@link useFetchState}, and passed on to `ListState`. */
export type FetchState<T> = LoadState<{ data: T }>;
/** Returned by {@link useFetchState}. */
export interface UseFetchStateResult<T> {
state: FetchState<T>;
/** Edits the loaded data, as when a next page comes in. Does nothing until loaded. */
setData: (fn: (data: T) => T) => void;
}
/**
* Returns what `load` resolves to for `key`, loaded the first time `enabled`
* is true, and kept afterwards. Turning `enabled` off doesn't abort the load,
* so the profile's tabs, which pass "this tab is open", finish loading in the
* background. A new `key` starts over from loading and aborts the load of the
* last one, as leaving the page does; an undefined key loads nothing. `load`
* must reject with an `Error`, as `expectOk` does, whose message it shows.
*/
export function useFetchState<T>(
key: string | undefined,
load: (signal: AbortSignal) => Promise<T>,
enabled = true,
): UseFetchStateResult<T> {
const [state, setState] = useState<FetchState<T>>({ status: "loading" });
const [prevKey, setPrevKey] = useState(key);
if (prevKey !== key) {
setPrevKey(key);
setState({ status: "loading" });
}
// A ref, so that a new `load` on every render doesn't load again.
const loadRef = useRef(load);
useLayoutEffect(() => {
loadRef.current = load;
});
// The key whose load has started, and the controller that aborts it.
const startedRef = useRef<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => () => {
controllerRef.current?.abort();
startedRef.current = null;
}, [key]);
useEffect(() => {
if (!enabled || key === undefined || startedRef.current === key) return;
startedRef.current = key;
const controller = new AbortController();
controllerRef.current = controller;
loadRef.current(controller.signal)
.then((data) => setState({ status: "loaded", data }))
.catch((err) => {
if (controller.signal.aborted) return;
setState({ status: "error", error: friendlyFetchError(err) });
});
}, [enabled, key]);
const setData = useCallback((fn: (data: T) => T) => {
setState((s) =>
s.status === "loaded" ? { status: "loaded", data: fn(s.data) } : s
);
}, []);
return { state, setData };
}