/** * Keeping a list of playlists in step with the socket. * * @module */ import { useEffect, useLayoutEffect, useRef } from "react"; import type { Playlist } from "../../../shared/playlist.ts"; import { useWS } from "../ws/useWS.ts"; /** How {@link usePlaylistListSync} treats the list. */ export interface PlaylistListSyncOptions { /** Keep private playlists visible (caller is the owner). */ isOwner?: boolean; /** * Only act on created/updated events for playlists owned by this user. * Leave undefined to act on any playlist already in the list. */ ownerId?: string; /** * Set true for "followed" lists: never prepend newly created playlists * (followed membership is managed separately), but still update/remove. */ noNewEntries?: boolean; /** * When true, don't re-add a playlist that isn't in the list (idx === -1). * Use this when the caller handles re-insertion itself (e.g. with a position * map, like the dumps/votes pattern in UserPublicProfile). */ skipReinsert?: boolean; } /** * Keeps the playlist list set through `setPlaylists` in sync with real-time WS * events. * * - a playlist created → prepended (unless `noNewEntries`) * - a playlist updated → updated in place, or removed if now private * - a playlist deleted → removed * - a playlist's dumps changed → its `dumpCount` updated */ export function usePlaylistListSync( setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void, options?: PlaylistListSyncOptions, ): void { const { lastPlaylistEvent, deletedPlaylistIds } = useWS(); const setPlaylistsRef = useRef(setPlaylists); const optionsRef = useRef(options); useLayoutEffect(() => { setPlaylistsRef.current = setPlaylists; optionsRef.current = options; }); useEffect(() => { if (!lastPlaylistEvent) return; const { isOwner, ownerId, noNewEntries } = optionsRef.current ?? {}; const ev = lastPlaylistEvent; setPlaylistsRef.current((prev) => { if (ev.type === "created" && ev.playlist) { if (noNewEntries) return prev; if (ownerId && ev.playlist.userId !== ownerId) return prev; if (!ev.playlist.isPublic && !isOwner) return prev; if (prev.some((p) => p.id === ev.playlist!.id)) return prev; return [ev.playlist, ...prev]; } if (ev.type === "updated" && ev.playlist) { const idx = prev.findIndex((p) => p.id === ev.playlist!.id); if (idx === -1) { // Playlist was removed (went private). Re-add if it's now public and // visible to this list. noNewEntries lists (followed) skip this since // we can't verify the follow relationship is still active. if (noNewEntries) return prev; if (ownerId && ev.playlist.userId !== ownerId) return prev; if (!ev.playlist.isPublic && !isOwner) return prev; // skipReinsert: caller handles re-insertion at the correct position. if (optionsRef.current?.skipReinsert) return prev; return [ev.playlist, ...prev]; } // Remove if it became private and the viewer can't see private playlists if (!ev.playlist.isPublic && !isOwner) { return prev.filter((p) => p.id !== ev.playlist!.id); } const next = [...prev]; next[idx] = ev.playlist; return next; } 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; }); }, [lastPlaylistEvent]); useEffect(() => { if (deletedPlaylistIds.size === 0) return; setPlaylistsRef.current((prev) => prev.filter((p) => !deletedPlaylistIds.has(p.id)) ); }, [deletedPlaylistIds]); }