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,43 +1,75 @@
/**
* Keeps a page's list of dumps in step with the dump and comment events that
* `WSContext` receives.
* Keeps a page's list of dumps in step with the socket: the dumps deleted,
* edited and posted, their comment counts, and their posters' names.
*
* @module
*/
import { useEffect, useLayoutEffect, useRef } from "react";
import type { Dump } from "../../../shared/dump.ts";
import { useWS } from "../ws/useWS.ts";
import { useWS, useWSEvent } from "../ws/useWS.ts";
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
/**
* Passed to {@link useDumpListSync} by a profile's own list of dumps, and by
* `usePlaylist` for the playlist's owner. The feeds pass none.
* Passed to {@link useDumpListSync} by the lists that need more than edits and
* deletions. The index's feed adds no dump through it, since it merges
* `WSContext`'s `recentDumps` itself.
*/
export interface DumpListSyncOptions {
/** Keeps an edited dump that is now private, for its owner's own list. */
/**
* The list holds the viewer's own private dumps, as their profile, the
* index and the search do, so that one they make private stays. Only its
* poster hears of a private dump's edits.
*/
isOwner?: boolean;
/**
* Which dumps the list may hold. A dump that left it, as by turning
* private, comes back where it was when it returns and this accepts it.
* Without it, the list never gains a dump.
*/
belongs?: (dump: Dump) => boolean;
/**
* Also adds a dump `belongs` accepts that the list never held, where the
* newest-first order puts it among the dumps loaded: a new one first. One
* older than all of them is left to the next pages. For the lists sorted by
* `createdAt`, as a profile's dumps.
*/
addNew?: boolean;
}
/**
* Applies the socket's dump and comment events to the list that `setDumps`
* updates. A deleted dump is removed, an edited one is replaced in place, and
* a new or deleted comment moves its dump's `commentCount`. It never adds a
* dump: the feeds merge `WSContext`'s `recentDumps` themselves, and the
* profiles and `usePlaylist` put back a dump that returns with
* `usePositionAwareSync`.
* Returns `list` with `dump` before the first dump posted earlier, or as is
* when none was: the dump belongs to a later page.
*/
function insertByDate(list: Dump[], dump: Dump): Dump[] {
if (list.length === 0) return [dump];
const idx = list.findIndex((d) =>
d.createdAt.getTime() < dump.createdAt.getTime()
);
return idx === -1 ? list : insertAt(list, dump, idx);
}
/**
* Applies the socket's dump, comment and user events to `items`, the loaded
* list, which `setDumps` updates. A deleted dump is removed, an edited one is
* replaced in place, one gone private leaves unless `isOwner`, and a new or
* returning one is added as `belongs` and `addNew` say. A new or deleted
* comment moves its dump's `commentCount`, and a renamed user's dumps take
* their new name.
*/
export function useDumpListSync(
items: Dump[],
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
options?: DumpListSyncOptions,
): void {
const { deletedDumpIds, lastDumpEvent, lastCommentEvent } = useWS();
const { deletedDumpIds } = useWS();
const takePosition = usePositionMemory(items);
// Refs, so that the effects run on new events only, not on new callbacks.
// A ref, so that the effect runs on new deletions only, not on new
// callbacks.
const setDumpsRef = useRef(setDumps);
const optionsRef = useRef(options);
useLayoutEffect(() => {
setDumpsRef.current = setDumps;
optionsRef.current = options;
});
useEffect(() => {
@@ -47,31 +79,33 @@ export function useDumpListSync(
);
}, [deletedDumpIds]);
useEffect(() => {
if (!lastDumpEvent) return;
const isOwner = optionsRef.current?.isOwner;
const dump = lastDumpEvent;
setDumpsRef.current((prev) => {
useWSEvent("dump", ({ dump }) => {
const { isOwner, belongs, addNew } = options ?? {};
const accepted = !!belongs?.(dump);
// Read here, not in the updater, which React may call twice.
const oldIdx = accepted ? takePosition(dump.id) : undefined;
setDumps((prev) => {
const idx = prev.findIndex((d) => d.id === dump.id);
if (idx === -1) return prev;
if (dump.isPrivate && !isOwner) {
return prev.filter((d) => d.id !== dump.id);
if (idx !== -1) {
if (dump.isPrivate && !isOwner) {
return prev.filter((d) => d.id !== dump.id);
}
const next = [...prev];
next[idx] = dump;
return next;
}
const next = [...prev];
next[idx] = dump;
return next;
if (!accepted) return prev;
if (oldIdx !== undefined) return insertAt(prev, dump, oldIdx);
return addNew ? insertByDate(prev, dump) : prev;
});
}, [lastDumpEvent]);
});
// The server sends comment events for public dumps only, so a private
// dump's count stays as fetched.
useEffect(() => {
if (!lastCommentEvent) return;
const { type, dumpId } = lastCommentEvent;
// The server sends a private dump's comment events to its owner alone, who
// alone lists it.
useWSEvent("comment", ({ type, dumpId }) => {
if (type !== "created" && type !== "deleted") return;
const delta = type === "created" ? 1 : -1;
setDumpsRef.current((prev) => {
setDumps((prev) => {
const idx = prev.findIndex((d) => d.id === dumpId);
if (idx === -1) return prev;
const next = [...prev];
@@ -81,5 +115,17 @@ export function useDumpListSync(
};
return next;
});
}, [lastCommentEvent]);
});
useWSEvent("user", ({ user }) => {
setDumps((prev) =>
prev.some((d) =>
d.userId === user.id && d.ownerUsername !== user.username
)
? prev.map((d) =>
d.userId === user.id ? { ...d, ownerUsername: user.username } : d
)
: prev
);
});
}