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

@@ -10,6 +10,7 @@ import {
insertDump,
insertPlaylist,
PASSWORD,
patch,
PNG,
post,
put,
@@ -19,7 +20,9 @@ import {
} from "../helpers.ts";
import { assert, assertEquals, assertExists, assertFalse } from "@std/assert";
import type { AuthTokens } from "../../../shared/user.ts";
import type { Comment } from "../../../shared/comment.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import type { ServerToClientMessage } from "../../../shared/ws.ts";
// deno.json's "dom" lib types WebSocket's second argument as protocols only,
@@ -618,12 +621,20 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
await o.sync();
assertEquals(o.all("dump_deleted").length, 1);
// Comments on a private dump stay quiet.
// Comments on a private dump reach its owner alone.
const priv = insertDump({ userId: owner.id, isPrivate: true });
await post(`/api/dumps/${priv.id}/comments`, {
const shh = await post<Comment>(`/api/dumps/${priv.id}/comments`, {
auth: owner.auth,
json: { body: "shh" },
});
await o.next("comment_created", (m) => m.comment.body === "shh");
await patch(`/api/comments/${shh.data.id}`, {
auth: owner.auth,
json: { body: "shhh" },
});
await o.next("comment_updated", (m) => m.comment.body === "shhh");
await del(`/api/comments/${shh.data.id}`, { auth: owner.auth });
await o.next("comment_deleted", (m) => m.commentId === shh.data.id);
await post(`/api/dumps/${pub.data.id}/comments`, {
auth: owner.auth,
json: { body: "hi" },
@@ -632,6 +643,8 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
assertEquals(created.comment.body, "hi");
await x.sync();
assertEquals(x.all("comment_created").length, 1);
assertEquals(x.all("comment_updated"), []);
assertEquals(x.all("comment_deleted"), []);
// A private playlist's events go to its owner alone.
const pl = insertPlaylist({ userId: owner.id, isPublic: false });
@@ -744,3 +757,128 @@ wsTest(
assertEquals((await x.next("dump_created")).dump.id, id);
},
);
wsTest(
"a playlist gone private leaves the others' views without its new details",
async (connect) => {
const owner = await createUser();
const other = await createUser();
const pl = insertPlaylist({ userId: owner.id, title: "Open" });
const o = await connect(owner);
const x = await connect(other);
const anon = await connect();
await put<Playlist>(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { title: "Secret plans", isPublic: false },
});
const mine = await o.next("playlist_updated");
assertEquals(mine.playlist.title, "Secret plans");
for (const c of [x, anon]) {
assertEquals((await c.next("playlist_deleted")).playlistId, pl.id);
await c.sync();
assertEquals(c.all("playlist_updated"), []);
}
// Going public again is an arrival for the others.
await put(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { isPublic: true },
});
await o.next("playlist_updated", (m) => m.playlist.isPublic);
for (const c of [x, anon]) {
const back = await c.next("playlist_created");
assertEquals(back.playlist.title, "Secret plans");
}
},
);
wsTest(
"a dump deleted or turned private is taken out of its playlists live",
async (connect) => {
const owner = await createUser();
const other = await createUser();
const pl = insertPlaylist({ userId: owner.id });
const keep = insertDump({ userId: owner.id });
const flip = insertDump({ userId: owner.id });
const gone = insertDump({ userId: owner.id });
for (const [i, d] of [keep, flip, gone].entries()) {
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, ?, ?);`,
).run(pl.id, d.id, i, new Date().toISOString());
}
const o = await connect(owner);
const x = await connect(other);
await put(`/api/dumps/${flip.id}`, {
auth: owner.auth,
json: { isPrivate: true },
});
const hidden = await x.next("playlist_dumps_updated");
assertEquals(hidden.dumpIds, [keep.id, gone.id]);
assertEquals(hidden.dumpCount, 3);
assertEquals(
(await o.next("playlist_dumps_updated")).dumpIds,
[keep.id, flip.id, gone.id],
);
await put(`/api/dumps/${flip.id}`, {
auth: owner.auth,
json: { isPrivate: false },
});
assertEquals(
(await x.next("playlist_dumps_updated")).dumpIds,
[keep.id, flip.id, gone.id],
);
await del(`/api/dumps/${gone.id}`, { auth: owner.auth });
const after = await x.next(
"playlist_dumps_updated",
(m) => m.dumpCount === 2,
);
assertEquals(after.dumpIds, [keep.id, flip.id]);
// An edit that keeps the visibility leaves the playlists alone.
await put(`/api/dumps/${keep.id}`, {
auth: owner.auth,
json: { title: "Renamed" },
});
await x.next("dump_updated");
await x.sync();
assertEquals(x.all("playlist_dumps_updated").length, 3);
},
);
wsTest(
"reading the notifications clears the bell on every socket",
async (connect) => {
const user = await createUser();
const other = await createUser();
const a = await connect(user);
const b = await connect(user);
const x = await connect(other);
await post("/api/me/notifications/read", { auth: user.auth });
await a.next("notifications_read");
await b.next("notifications_read");
await x.sync();
assertEquals(x.all("notifications_read"), []);
},
);
wsTest("category changes are announced to everyone", async (connect) => {
const admin = await createUser({ role: "admin" });
const anon = await connect();
const created = await post<{ id: string }>("/api/categories", {
auth: admin.auth,
json: { kind: "dump", slug: "jazz", name: "Jazz" },
});
await anon.next("categories_changed");
await put(`/api/categories/${created.data.id}`, {
auth: admin.auth,
json: { name: "Jazz & co" },
});
await anon.next("categories_changed");
await del(`/api/categories/${created.data.id}`, { auth: admin.auth });
await anon.next("categories_changed");
});