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:
@@ -31,10 +31,9 @@ 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 { useWS } from "../ws/useWS.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 { usePositionAwareSync } from "../feed/usePositionAwareSync.ts";
|
||||
import { useDumpListSync } from "../dump/useDumpListSync.ts";
|
||||
|
||||
/** Returned by {@link usePlaylist} to `PlaylistDetail`. */
|
||||
@@ -98,9 +97,10 @@ function withDetails(
|
||||
* 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 fades out, one it adds is fetched, a new order is
|
||||
* applied, one that goes private leaves and comes back at its place, and a
|
||||
* deleted playlist sends the page home. A new slug replaces the URL, keeping
|
||||
* 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.
|
||||
*/
|
||||
@@ -111,7 +111,6 @@ export function usePlaylist(
|
||||
): UsePlaylistResult {
|
||||
const navigate = useNavigate();
|
||||
const { user, authFetch } = useAuth();
|
||||
const { lastDumpEvent, lastPlaylistEvent } = useWS();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
// The path last fetched. The URL may move on to the same playlist's current
|
||||
@@ -121,7 +120,7 @@ export function usePlaylist(
|
||||
// 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 } = useFetchState(
|
||||
const { state, setData, reload } = useFetchState(
|
||||
target.username && target.slug &&
|
||||
`${target.username}/${target.slug}:${user?.id ?? "guest"}`,
|
||||
(signal) =>
|
||||
@@ -158,9 +157,6 @@ export function usePlaylist(
|
||||
// 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 }>());
|
||||
// Every dump taken out of the playlist, by anyone, which mustn't come back
|
||||
// when it next changes, as one that went private does.
|
||||
const removedRef = useRef(new Set<string>());
|
||||
|
||||
const sendRemoval = useCallback((dumpId: string) => {
|
||||
const pending = pendingRef.current.get(dumpId);
|
||||
@@ -172,7 +168,6 @@ export function usePlaylist(
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
})
|
||||
.catch(() => {
|
||||
removedRef.current.delete(dumpId);
|
||||
setDumps((dumps) => {
|
||||
if (dumps.some((d) => d.id === dumpId)) return dumps;
|
||||
const next = [...dumps];
|
||||
@@ -224,9 +219,8 @@ export function usePlaylist(
|
||||
setData((p) => withDetails(p, updated));
|
||||
}, [setData]);
|
||||
|
||||
useEffect(() => {
|
||||
const ev = lastPlaylistEvent;
|
||||
if (!ev || !id || ev.playlistId !== id) return;
|
||||
useWSEvent("playlist", (ev) => {
|
||||
if (!id || ev.playlistId !== id) return;
|
||||
if (ev.type === "updated" && ev.playlist) {
|
||||
applyDetails(ev.playlist);
|
||||
} else if (ev.type === "deleted") {
|
||||
@@ -239,13 +233,11 @@ export function usePlaylist(
|
||||
const shown = new Set(playlist?.dumps.map((d) => d.id));
|
||||
for (const dumpId of shown) {
|
||||
if (listed.has(dumpId)) continue;
|
||||
removedRef.current.add(dumpId);
|
||||
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;
|
||||
removedRef.current.delete(dumpId);
|
||||
if (shown.has(dumpId)) {
|
||||
// Put back while it was fading out.
|
||||
cancelFading(dumpId);
|
||||
@@ -265,34 +257,34 @@ export function usePlaylist(
|
||||
}
|
||||
setDumps((dumps) => inOrder(dumps, ids));
|
||||
}
|
||||
}, [lastPlaylistEvent, id, applyDetails, navigate, authFetch, setDumps]);
|
||||
});
|
||||
|
||||
useWSEvent("user", ({ user }) => {
|
||||
setData((p) =>
|
||||
p.userId === user.id && p.ownerUsername !== user.username
|
||||
? { ...p, ownerUsername: user.username }
|
||||
: p
|
||||
);
|
||||
});
|
||||
|
||||
// Before `useDumpListSync`: it notes a dump's place before the sync takes
|
||||
// it out, as when it goes private.
|
||||
usePositionAwareSync(
|
||||
playlist?.dumps ?? [],
|
||||
setDumps,
|
||||
lastDumpEvent,
|
||||
(d) => !d.isPrivate && !removedRef.current.has(d.id),
|
||||
);
|
||||
// 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.
|
||||
useDumpListSync(setDumps, {
|
||||
// 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 });
|
||||
removedRef.current.add(dumpId);
|
||||
startFading(dumpId);
|
||||
}, [startFading]);
|
||||
|
||||
const undoRemove = useCallback((dumpId: string) => {
|
||||
pendingRef.current.delete(dumpId);
|
||||
removedRef.current.delete(dumpId);
|
||||
cancelFading(dumpId);
|
||||
}, [cancelFading]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user