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

@@ -39,7 +39,8 @@ import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { relativeTime } from "../utils/shared/relativeTime.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { Avatar } from "../components/shared/Avatar.tsx";
import { RichContentCard } from "../components/dump/RichContentCard.tsx";
import { FilePreview } from "../components/dump/FilePreview.tsx";
@@ -65,9 +66,11 @@ type DumpState =
* `GET /api/users/@:username/dumps/:slug`, or from the navigation's state when
* `DumpEdit` or a title edit here just saved it. Reached by a former name or
* slug, the URL takes the current ones. Its comments, related dumps and
* playlists come from their own endpoints once it is loaded, and
* `WSContext`'s events keep them current. The poster and moderators can edit
* the title in place, and get a link to `DumpEdit`.
* playlists come from their own endpoints once it is loaded, and the socket's
* events keep them current; a reconnection fetches the dump and its comments
* again. A dump deleted, or made private by its poster, while someone else
* views it gives way to a notice. The poster and moderators can edit the
* title in place, and get a link to `DumpEdit`.
*/
export function Dump(): React.JSX.Element {
const { handle, slug } = useParams();
@@ -126,9 +129,7 @@ export function Dump(): React.JSX.Element {
myVotes,
castVote,
removeVote,
lastDumpEvent,
lastCommentEvent,
lastPlaylistEvent,
deletedDumpIds,
connectionEpoch,
} = useWS();
@@ -177,20 +178,67 @@ export function Dump(): React.JSX.Element {
return () => controller.abort();
}, [username, slug, preloaded, authFetch]);
useEffect(() => {
if (!lastDumpEvent) return;
// Mirrors the socket's edits, which only an effect can observe.
// eslint-disable-next-line react-hooks/set-state-in-effect
setDumpState((prev) => {
if (prev.status !== "loaded" || prev.dump.id !== lastDumpEvent.id) {
return prev;
}
return { status: "loaded", dump: lastDumpEvent };
});
}, [lastDumpEvent]);
const replaceDump = (dump: Dump) =>
setDumpState((prev) =>
prev.status === "loaded" && prev.dump.id === dump.id
? { status: "loaded", dump }
: prev
);
useWSEvent("dump", ({ dump }) => replaceDump(dump));
const loadedDumpId = loadedDump?.id ?? null;
// The dump found gone on a reconnection. One deleted, or made private, live
// is in `deletedDumpIds`, which it leaves if made public again, and shows
// again.
const [vanishedId, setVanishedId] = useState<string | null>(null);
const vanished = !!loadedDumpId &&
(deletedDumpIds.has(loadedDumpId) || vanishedId === loadedDumpId);
// An edit or a deletion missed while the socket was down.
useOnReconnect(() => {
if (!loadedDumpId) return;
authFetch(`${API_URL}/api/dumps/${loadedDumpId}`, { cache: "no-store" })
.then(async (res) => {
if (res.status === 404) return setVanishedId(loadedDumpId);
const body = parseAPIResponse<Wire<Dump>>(await res.json());
if (body.success) {
setVanishedId(null);
replaceDump(deserializeDump(body.data));
}
})
.catch(() => {});
});
useWSEvent("user", ({ user }) => {
setOp((op) => op?.id === user.id ? user : op);
setComments((prev) =>
prev.some((c) =>
c.userId === user.id && c.authorUsername !== user.username
)
? prev.map((c) =>
c.userId === user.id ? { ...c, authorUsername: user.username } : c
)
: prev
);
setDumpState((prev) =>
prev.status === "loaded" && prev.dump.userId === user.id &&
prev.dump.ownerUsername !== user.username
? {
status: "loaded",
dump: { ...prev.dump, ownerUsername: user.username },
}
: prev
);
});
useDumpListSync(relatedDumps, setRelatedDumps);
// Counts the playlist events, each of which may change the playlists
// holding the dump.
const [playlistsVersion, setPlaylistsVersion] = useState(0);
useWSEvent("playlist", () => setPlaylistsVersion((n) => n + 1));
useEffect(() => {
if (!loadedDumpId) return;
const controller = new AbortController();
@@ -244,8 +292,8 @@ export function Dump(): React.JSX.Element {
.catch(() => {});
return () => controller.abort();
// Any playlist event may change the list, and they are rare enough to
// refetch on each.
}, [loadedDumpId, authFetch, lastPlaylistEvent]);
// refetch on each, as on a reconnection.
}, [loadedDumpId, authFetch, playlistsVersion, connectionEpoch]);
// A notification links to `#comment-<id>`. Rerun when the comments arrive,
// since the element doesn't exist before.
@@ -263,41 +311,25 @@ export function Dump(): React.JSX.Element {
return () => clearTimeout(timer);
}, [comments, location.hash]);
useEffect(() => {
if (
!lastCommentEvent || !loadedDumpId ||
lastCommentEvent.dumpId !== loadedDumpId
) return;
useWSEvent("comment", (ev) => {
if (!loadedDumpId || ev.dumpId !== loadedDumpId) return;
// A comment of this user's arrives both from the socket and from
// CommentThread's callbacks below, hence the duplicate checks.
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setComments((prev) => {
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) {
return prev;
}
return [...prev, lastCommentEvent.comment!];
});
} else if (
lastCommentEvent.type === "deleted" && lastCommentEvent.commentId
) {
const { comment, commentId } = ev;
if (ev.type === "created" && comment) {
setComments((prev) =>
prev.some((c) => c.id === comment.id) ? prev : [...prev, comment]
);
} else if (ev.type === "deleted" && commentId) {
setComments((prev) =>
prev.map((c) =>
c.id === lastCommentEvent.commentId
? { ...c, deleted: true, body: "" }
: c
)
);
} else if (
lastCommentEvent.type === "updated" && lastCommentEvent.comment
) {
setComments((prev) =>
prev.map((c) =>
c.id === lastCommentEvent.comment!.id ? lastCommentEvent.comment! : c
c.id === commentId ? { ...c, deleted: true, body: "" } : c
)
);
} else if (ev.type === "updated" && comment) {
setComments((prev) => prev.map((c) => c.id === comment.id ? comment : c));
}
}, [lastCommentEvent, loadedDumpId]);
});
if (dumpState.status === "loading") {
return (
@@ -309,6 +341,23 @@ export function Dump(): React.JSX.Element {
);
}
if (vanished) {
return (
<PageError
message={t`This dump was deleted, or made private.`}
actions={
<button
className="btn-border"
type="button"
onClick={() => navigate("/")}
>
<Trans>← Back to all dumps</Trans>
</button>
}
/>
);
}
if (dumpState.status === "error") {
return (
<PageError