/** * Everything a playlist's page shows and changes goes through this hook. It * loads the playlist with its dumps, 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 { isAt, playlistUrl, userApiUrl } from "../../utils/shared/urls.ts"; import { useCanonicalPath } from "../shared/useCanonicalPath.ts"; import { useAuth } from "../auth/useAuth.ts"; import { useOnReconnect, useWSEvent } from "../ws/useWS.ts"; import { type FetchState, useFetchState } from "../shared/useFetchState.ts"; import { type FadeState, useFading } from "../feed/useFading.ts"; import { useDumpListSync } from "../dump/useDumpListSync.ts"; /** Returned by {@link usePlaylist} to `PlaylistDetail`. */ export interface UsePlaylistResult { state: FetchState; /** * 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; /** 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; } /** * 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(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. dumpCount: updated.dumpCount ?? playlist.dumpCount, dumps: playlist.dumps, }; } /** * Returns the playlist at `/@username/playlists/slug` from * `GET /api/users/@:username/playlists/:slug`, with the dumps the viewer may * see in it. The socket's events keep its details and dumps live: a dump * another client removes, or that turns private, fades out, one it adds, or * that turns public, is fetched, a new order is applied, and a playlist * deleted, or turned private by someone else, sends the page home. A * reconnection loads it again. A new slug replaces the URL, keeping * the page's `view`, and a path that names the playlist on screen, as that * one does, fetches nothing. */ export function usePlaylist( username: string | undefined, slug: string | undefined, view?: "edit", ): UsePlaylistResult { const navigate = useNavigate(); const { user, authFetch } = useAuth(); const [actionError, setActionError] = useState(null); // The path last fetched. The URL may move on to the same playlist's current // path, which mustn't fetch it again. const [target, setTarget] = useState({ username, slug }); // The server's last order of the dumps this viewer may see, which a refused // reorder goes back to. const serverOrderRef = useRef([]); const { state, setData, reload } = useFetchState( target.username && target.slug && `${target.username}/${target.slug}:${user?.id ?? "guest"}`, (signal) => authFetch( // The key above is falsy, and nothing fetched, without both. userApiUrl({ username: target.username! }, "playlists", target.slug!), { signal }, ) .then(expectOk>) .then((wire) => { const playlist = deserializePlaylistWithDumps(wire); serverOrderRef.current = playlist.dumps.map((d) => d.id); return playlist; }), ); const playlist = state.status === "loaded" ? state.data : null; if ( (target.username !== username || target.slug !== slug) && !isAt(playlist, username, slug) ) { setTarget({ username, slug }); } useCanonicalPath(playlist && playlistUrl(playlist, view)); // The socket and the API's writes name playlists by id. 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()); 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(() => { 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(); }; }, []); // A new slug, or owner's name, reaches the URL through useCanonicalPath. const applyDetails = useCallback((updated: Playlist) => { setData((p) => withDetails(p, updated)); }, [setData]); useWSEvent("playlist", (ev) => { if (!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; 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; if (shown.has(dumpId)) { // Put back while it was fading out. cancelFading(dumpId); continue; } authFetch(`${API_URL}/api/dumps/${dumpId}`) .then(expectOk>) .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)); } }); useWSEvent("user", ({ user }) => { setData((p) => p.userId === user.id && p.ownerUsername !== user.username ? { ...p, ownerUsername: user.username } : p ); }); // 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. Which dumps come // and go is the playlist's `dumps_updated`, above. useDumpListSync(playlist?.dumps ?? [], setDumps, { isOwner: !!user && user.id === playlist?.userId, }); useOnReconnect(reload); 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 }); startFading(dumpId); }, [startFading]); const undoRemove = useCallback((dumpId: string) => { pendingRef.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, }; }