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>
This commit is contained in:
khannurien
2026-10-10 07:34:49 +00:00
parent 1c5a449d84
commit 308a8299f3
53 changed files with 1941 additions and 1044 deletions

View File

@@ -1,13 +1,15 @@
/**
* Keeps a page's list of playlists in step with the socket's `playlist_*`
* messages. The user's profile and playlists pages call it.
* 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 } from "../ws/useWS.ts";
import { useWS, useWSEvent } from "../ws/useWS.ts";
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
export interface PlaylistListSyncOptions {
@@ -19,58 +21,59 @@ export interface PlaylistListSyncOptions {
*/
ownerId?: string;
/**
* Adds no playlist, for a list of followed playlists, since the socket
* doesn't say what the user follows. Playlists already listed still change
* and go.
* 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 the list set through
* `setPlaylists`. A created playlist goes first, an updated one changes in
* place or leaves when it turns private (unless `isOwner`), a deleted one
* leaves, and a playlist's `dumpCount` follows its dumps. An update never
* adds a playlist the list doesn't hold.
* Applies the socket's playlist events to `items`, the loaded list, which
* `setPlaylists` updates. A created playlist goes first, 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 { lastPlaylistEvent, deletedPlaylistIds } = useWS();
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);
const optionsRef = useRef(options);
useLayoutEffect(() => {
setPlaylistsRef.current = setPlaylists;
optionsRef.current = options;
});
useEffect(() => {
if (!lastPlaylistEvent) return;
const { isOwner, ownerId, noNewEntries } = optionsRef.current ?? {};
const ev = lastPlaylistEvent;
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;
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);
// One that left when it turned private is put back by
// `usePositionAwareSync`, where it was.
if (idx === -1) return prev;
if (!ev.playlist.isPublic && !isOwner) {
return prev.filter((p) => p.id !== ev.playlist!.id);
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;
}
const next = [...prev];
next[idx] = ev.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 [playlist, ...prev];
}
if (ev.type === "deleted") {
@@ -90,7 +93,19 @@ export function usePlaylistListSync(
return prev;
});
}, [lastPlaylistEvent]);
});
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;