Files
gerbeur/src/hooks/playlist/usePlaylistListSync.ts

118 lines
4.0 KiB
TypeScript

/**
* Keeps a page's list of playlists in step with the socket's `playlist_*`
* messages, and their owners' names. The user's profile, the playlists page
* and the search call it.
*
* @module
*/
import { useEffect, useLayoutEffect, useRef } from "react";
import type { Playlist } from "../../../shared/playlist.ts";
import { useWS, useWSEvent } from "../ws/useWS.ts";
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
import { withByDate } from "../../utils/feed/pagedList.ts";
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
export interface PlaylistListSyncOptions {
/** Keeps private playlists in the list, for their owner. */
isOwner?: boolean;
/**
* Adds only this user's playlists, when one is created or comes back into
* view. Without it, anyone's are added.
*/
ownerId?: string;
/**
* Adds no new playlist, for a list of followed playlists, since the socket
* doesn't say what the user follows, or of search results. Playlists
* already listed still change, go, and come back where they were.
*/
noNewEntries?: boolean;
}
/**
* Applies the socket's playlist events to `items`, the loaded list, newest
* first, which `setPlaylists` updates. A created playlist goes where its date
* puts it, first when new, an updated one changes in place or leaves when it
* turns private (unless `isOwner`), one that left comes back where it was
* when it returns, a deleted one leaves, and a playlist's `dumpCount` follows
* its dumps. A renamed user's playlists take their new name.
*/
export function usePlaylistListSync(
items: Playlist[],
setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void,
options?: PlaylistListSyncOptions,
): void {
const { deletedPlaylistIds } = useWS();
const takePosition = usePositionMemory(items);
// A ref, so that the effect runs on new deletions only, not on new
// callbacks.
const setPlaylistsRef = useRef(setPlaylists);
useLayoutEffect(() => {
setPlaylistsRef.current = setPlaylists;
});
useWSEvent("playlist", (ev) => {
const { isOwner, ownerId, noNewEntries } = options ?? {};
const shown = ev.playlist &&
(ev.playlist.isPublic || isOwner) &&
(!ownerId || ev.playlist.userId === ownerId);
// Read here, not in the updater, which React may call twice.
const oldIdx = shown ? takePosition(ev.playlistId) : undefined;
setPlaylists((prev) => {
if ((ev.type === "created" || ev.type === "updated") && ev.playlist) {
const playlist = ev.playlist;
const idx = prev.findIndex((p) => p.id === playlist.id);
if (idx !== -1) {
if (!shown) return prev.filter((p) => p.id !== playlist.id);
const next = [...prev];
next[idx] = playlist;
return next;
}
if (!shown) return prev;
if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx);
// An update never adds a playlist the list doesn't hold.
if (ev.type === "updated" || noNewEntries) return prev;
return withByDate(prev, playlist);
}
if (ev.type === "deleted") {
return prev.filter((p) => p.id !== ev.playlistId);
}
if (ev.type === "dumps_updated" && ev.dumpIds) {
const idx = prev.findIndex((p) => p.id === ev.playlistId);
if (idx === -1) return prev;
const next = [...prev];
next[idx] = {
...next[idx],
dumpCount: ev.dumpCount ?? ev.dumpIds.length,
};
return next;
}
return prev;
});
});
useWSEvent("user", ({ user }) => {
setPlaylists((prev) =>
prev.some((p) =>
p.userId === user.id && p.ownerUsername !== user.username
)
? prev.map((p) =>
p.userId === user.id ? { ...p, ownerUsername: user.username } : p
)
: prev
);
});
useEffect(() => {
if (deletedPlaylistIds.size === 0) return;
setPlaylistsRef.current((prev) =>
prev.filter((p) => !deletedPlaylistIds.has(p.id))
);
}, [deletedPlaylistIds]);
}