v3: every paged list loads its pages one way, PlaylistDetail is split like the profile, and the comments keep the README's rules — Index, FollowedFeed, useUserDumpFeed, UserPlaylists, UserListPopover, Notifications, Search and the profile's followed users each hand-wrote "fetch the next page, append it, clear loadingMore on failure". A list the API pages is now a PagedList whose next page useLoadMore appends, and a list that loads on its own goes through usePagedList, which is useFetchState (now taking an initial value, as a restored cache, that starts loaded) plus useLoadMore. Pages are read by pageUrl with readPage (a refusal throws) or readFirstPage (a refusal is empty, as fetchFirstPage was), signed-in reads go through authFetch, and useFeedCache's saveState takes the list. Notifications keeps the socket's arrivals as their own list merged at render, as Index does with recentDumps, and marks them read in one effect. PlaylistDetail (963 lines) only puts the pieces together (146): usePlaylist holds the playlist, its live sync and its edits, PlaylistHeader, PlaylistEditForm and PlaylistDumpList are one file each, and LiveDumpCard is the vote wiring DumpCardList and the playlist both use. What it re-invented is shared: the load is useFetchState, a dump going private and back is usePositionAwareSync, deletes and edits are useDumpListSync (which also keeps comment counts, which the page didn't). A removal is sent when its fade ends, or as the page is left, so an undo sends nothing: the removal/re-add/reorder dance, the pending-undo order and the 80 ms debounce of its echoes are gone. A refused reorder goes back to the server's last order and says so, instead of reloading the page. The comments written since the documentation pass broke its rules in places: module docs as a label and a colon ("The "dumps" tab of a profile: …"), noun-phrase fragments on types, fields and constants, stale lists of callers, over-long lines; they are sentences now, and the ones the refactor made wrong (PlaylistDetail as useFading's and FollowPlaylistButton's caller, "the profiles" as usePositionAwareSync's only users) say what is true. 650 frontend and 214 backend tests pass (new: usePagedList, useFetchState's initial, pageUrl/readPage, the deferred removal, a dump returning at its place in a playlist), and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
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 21:19:33 +00:00
parent 69d9bf899b
commit 07ac4bc629
81 changed files with 2016 additions and 2210 deletions

View File

@@ -0,0 +1,334 @@
/**
* Loads a playlist with its dumps for its page, keeps them live, and makes the
* changes its owner or a moderator makes there.
*
* @module
*/
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type { Dump } from "../../../shared/dump.ts";
import type {
Playlist,
PlaylistWithDumps,
ReorderPlaylistRequest,
} from "../../../shared/playlist.ts";
import {
deserializeDump,
deserializePlaylistWithDumps,
} from "../../utils/shared/deserialize.ts";
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
import { playlistUrl } from "../../utils/shared/urls.ts";
import { useAuth } from "../auth/useAuth.ts";
import { useWS } from "../ws/useWS.ts";
import { type FetchState, useFetchState } from "../shared/useFetchState.ts";
import { type FadeState, useFading } from "../feed/useFading.ts";
import { usePositionAwareSync } from "../feed/usePositionAwareSync.ts";
import { useDumpListSync } from "../dump/useDumpListSync.ts";
/** Returned by {@link usePlaylist} to `PlaylistDetail`. */
export interface UsePlaylistResult {
state: FetchState<PlaylistWithDumps>;
/**
* It holds the dumps being removed, which stay listed while they fade. A
* dump in it can't be dragged, and during its `cooldown` its removal can be
* undone.
*/
fading: Record<string, FadeState>;
/** It says why the server refused a removal, a reorder or a delete. */
actionError: string | null;
/** Shows the playlist as `PlaylistEditForm` saved it. */
applyDetails: (playlist: Playlist) => void;
/**
* Fades the dump out, and takes it out of the playlist on the server when
* the fade ends, or when the page is left before.
*/
remove: (dumpId: string) => void;
/** Keeps a dump that `remove` is fading out. Nothing was sent yet. */
undoRemove: (dumpId: string) => void;
/**
* Moves the dump at `from` to `to`, counted among the dumps not fading, on
* screen only, as a drag goes along.
*/
move: (from: number, to: number) => void;
/** Sends the order `move` left, as a drag ends. */
saveOrder: () => void;
/** Leaves for home once the playlist is deleted, or sets `actionError`. */
deletePlaylist: () => Promise<void>;
}
/**
* Returns `items` with those that `ids` names put in its order, in the slots
* those items held. The others, as the dumps fading out, keep their slots.
*/
function inOrder<T extends { id: string }>(items: T[], ids: string[]): T[] {
const byId = new Map(items.map((i) => [i.id, i]));
const ordered = ids.filter((id) => byId.has(id)).map((id) => byId.get(id)!);
const named = new Set(ordered.map((i) => i.id));
let next = 0;
return items.map((i) => named.has(i.id) ? ordered[next++] : i);
}
/** Returns `playlist` with the details of `updated`, keeping its dumps. */
function withDetails(
playlist: PlaylistWithDumps,
updated: Playlist,
): PlaylistWithDumps {
return {
...playlist,
...updated,
// Set only where the dumps were counted, which an edit's answer isn't.
ownerUsername: updated.ownerUsername ?? playlist.ownerUsername,
dumps: playlist.dumps,
};
}
/**
* Returns the playlist `playlistId` (its id or its slug) from
* `GET /api/playlists/:playlistId`, with the dumps the viewer may see in it.
* The socket's events keep its details and dumps live: a dump another client
* removes fades out, one it adds is fetched, a new order is applied, one that
* goes private leaves and comes back at its place, and a deleted playlist
* sends the page home. A new slug replaces the URL.
*/
export function usePlaylist(
playlistId: string | undefined,
): UsePlaylistResult {
const navigate = useNavigate();
const { user, authFetch } = useAuth();
const { lastDumpEvent, lastPlaylistEvent } = useWS();
const [actionError, setActionError] = useState<string | null>(null);
// The server's last order of the dumps this viewer may see, which a refused
// reorder goes back to.
const serverOrderRef = useRef<string[]>([]);
const { state, setData } = useFetchState(
playlistId && `${playlistId}:${user?.id ?? "guest"}`,
(signal) =>
authFetch(`${API_URL}/api/playlists/${playlistId}`, { signal })
.then(expectOk<Wire<PlaylistWithDumps>>)
.then((wire) => {
const playlist = deserializePlaylistWithDumps(wire);
serverOrderRef.current = playlist.dumps.map((d) => d.id);
return playlist;
}),
);
const playlist = state.status === "loaded" ? state.data : null;
// The socket names playlists by id, while the URL may hold the slug.
const id = playlist?.id;
const url = `${API_URL}/api/playlists/${id}`;
const setDumps = useCallback(
(fn: (dumps: Dump[]) => Dump[]) =>
setData((p) => ({ ...p, dumps: fn(p.dumps) })),
[setData],
);
// This viewer's removals whose fade hasn't ended, with the dump and its
// place, to put it back if the server refuses.
const pendingRef = useRef(new Map<string, { dump: Dump; index: number }>());
// Every dump taken out of the playlist, by anyone, which mustn't come back
// when it next changes, as one that went private does.
const removedRef = useRef(new Set<string>());
const sendRemoval = useCallback((dumpId: string) => {
const pending = pendingRef.current.get(dumpId);
if (!pending) return;
pendingRef.current.delete(dumpId);
// `keepalive`: it may be sent as the page is left.
authFetch(`${url}/dumps/${dumpId}`, { method: "DELETE", keepalive: true })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
})
.catch(() => {
removedRef.current.delete(dumpId);
setDumps((dumps) => {
if (dumps.some((d) => d.id === dumpId)) return dumps;
const next = [...dumps];
next.splice(Math.min(pending.index, next.length), 0, pending.dump);
return next;
});
setActionError(t`The dump couldn't be removed. Try again.`);
});
}, [authFetch, url, setDumps]);
const { fading, startFading, cancelFading } = useFading((dumpId) => {
setDumps((dumps) => dumps.filter((d) => d.id !== dumpId));
sendRemoval(dumpId);
});
// For the effects, which must run on new events only, and the calls below,
// which must stay stable.
const latestRef = useRef({
playlist,
fading,
startFading,
cancelFading,
sendRemoval,
});
useLayoutEffect(() => {
latestRef.current = {
playlist,
fading,
startFading,
cancelFading,
sendRemoval,
};
});
useEffect(() => {
const flush = () =>
pendingRef.current.forEach((_, dumpId) =>
latestRef.current.sendRemoval(dumpId)
);
globalThis.addEventListener("pagehide", flush);
return () => {
globalThis.removeEventListener("pagehide", flush);
flush();
};
}, []);
const applyDetails = useCallback((updated: Playlist) => {
const slug = latestRef.current.playlist?.slug;
setData((p) => withDetails(p, updated));
if (updated.slug && updated.slug !== slug) {
navigate(playlistUrl(updated), { replace: true });
}
}, [setData, navigate]);
useEffect(() => {
const ev = lastPlaylistEvent;
if (!ev || !id || ev.playlistId !== id) return;
if (ev.type === "updated" && ev.playlist) {
applyDetails(ev.playlist);
} else if (ev.type === "deleted") {
navigate("/");
} else if (ev.type === "dumps_updated" && ev.dumpIds) {
const ids = ev.dumpIds;
const { playlist, startFading, cancelFading } = latestRef.current;
serverOrderRef.current = ids;
const listed = new Set(ids);
const shown = new Set(playlist?.dumps.map((d) => d.id));
for (const dumpId of shown) {
if (listed.has(dumpId)) continue;
removedRef.current.add(dumpId);
startFading(dumpId);
}
for (const dumpId of ids) {
// This viewer's own removal, which the server hasn't had yet.
if (pendingRef.current.has(dumpId)) continue;
removedRef.current.delete(dumpId);
if (shown.has(dumpId)) {
// Put back while it was fading out.
cancelFading(dumpId);
continue;
}
authFetch(`${API_URL}/api/dumps/${dumpId}`)
.then(expectOk<Wire<Dump>>)
.then((wire) => {
const dump = deserializeDump(wire);
setDumps((dumps) =>
dumps.some((d) => d.id === dump.id)
? dumps
: inOrder([...dumps, dump], serverOrderRef.current)
);
})
.catch(() => {});
}
setDumps((dumps) => inOrder(dumps, ids));
}
}, [lastPlaylistEvent, id, applyDetails, navigate, authFetch, setDumps]);
// Before `useDumpListSync`: it notes a dump's place before the sync takes
// it out, as when it goes private.
usePositionAwareSync(
playlist?.dumps ?? [],
setDumps,
lastDumpEvent,
(d) => !d.isPrivate && !removedRef.current.has(d.id),
);
// A private dump is listed only to the playlist's owner, and only their own,
// which is the only private dump whose changes reach them.
useDumpListSync(setDumps, {
isOwner: !!user && user.id === playlist?.userId,
});
const remove = useCallback((dumpId: string) => {
const dumps = latestRef.current.playlist?.dumps ?? [];
const index = dumps.findIndex((d) => d.id === dumpId);
if (index === -1) return;
pendingRef.current.set(dumpId, { dump: dumps[index], index });
removedRef.current.add(dumpId);
startFading(dumpId);
}, [startFading]);
const undoRemove = useCallback((dumpId: string) => {
pendingRef.current.delete(dumpId);
removedRef.current.delete(dumpId);
cancelFading(dumpId);
}, [cancelFading]);
const move = useCallback((from: number, to: number) => {
const { fading } = latestRef.current;
setDumps((dumps) => {
const ids = dumps.filter((d) => !(d.id in fading)).map((d) => d.id);
const [moved] = ids.splice(from, 1);
ids.splice(to, 0, moved);
return inOrder(dumps, ids);
});
}, [setDumps]);
const saveOrder = useCallback(() => {
// The server wants every dump it lists to this viewer, those whose
// removal isn't sent yet included.
const onServer = new Set(serverOrderRef.current);
const dumpIds = (latestRef.current.playlist?.dumps ?? [])
.map((d) => d.id)
.filter((dumpId) => onServer.has(dumpId));
authFetch(`${url}/order`, {
method: "PUT",
body: JSON.stringify({ dumpIds } satisfies ReorderPlaylistRequest),
})
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
})
.catch(() => {
setDumps((dumps) => inOrder(dumps, serverOrderRef.current));
setActionError(t`The new order couldn't be saved. Try again.`);
});
}, [authFetch, url, setDumps]);
const deletePlaylist = useCallback(async () => {
setActionError(null);
try {
const res = await authFetch(url, { method: "DELETE" });
// expectOk throws the server's message.
if (!res.ok) await expectOk(res);
} catch (err) {
setActionError(friendlyFetchError(err));
return;
}
navigate("/");
}, [authFetch, url, navigate]);
return {
state,
fading,
actionError,
applyDetails,
remove,
undoRemove,
move,
saveOrder,
deletePlaylist,
};
}