Files
gerbeur/src/hooks/playlist/usePlaylist.ts
khannurien 308a8299f3 v4: every live update reaches every page that shows what it changed — the client kept each kind of event in one "latest" slot, so two arriving before a render lost the first (two notifications of one dump going public showed one); they now go through a subscription, useWSEvent, and each reaches every subscriber. A playlist made private sent its new title and description to everyone, and kept them on a visitor's page; playlists now follow the dumps' rule, broadcastChange: others get playlist_deleted, and playlist_created when it goes public again. A dump deleted or turned private sends its playlists' dumps again, so their counts and pages follow. Comments on a private dump reach its poster's other tabs. Reading the notifications clears the bell in every tab (notifications_read), and a category change makes every client fetch the categories again (categories_changed). The dump page gives way to a notice when its dump is deleted or made private. A profile gains the user's new dumps, the followed feed those of the users followed and the playlists followed, the search results follow edits and deletions, the index's category feed drops a dump edited out of it, and a renamed user's name follows on cards and comments. After a reconnection, every live list fetches the pages it holds again (usePagedList's resync), and the playlist, the profile, the dump, the categories and the follows are fetched again. The position memory of usePositionAwareSync is now inside the list sync hooks, so a returning item is put back without depending on the order the hooks are called.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:34:49 +00:00

346 lines
12 KiB
TypeScript

/**
* 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<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.
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<string | null>(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<string[]>([]);
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<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;
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<string, { dump: Dump; index: number }>());
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<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));
}
});
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,
};
}