From 350e945eb14e30f81936b03f84217bd64fd916a1 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sat, 10 Oct 2026 08:43:55 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20a=20new=20link=20between=20two=20dumps=20?= =?UTF-8?q?shows=20among=20their=20related=20dumps=20at=20once=20=E2=80=94?= =?UTF-8?q?=20it=20showed=20on=20the=20next=20load=20of=20the=20page:=20th?= =?UTF-8?q?e=20backlink=20service=20recorded=20the=20link=20without=20tell?= =?UTF-8?q?ing=20anyone,=20and=20the=20dump=20page=20fetched=20its=20relat?= =?UTF-8?q?ed=20dumps=20once,=20the=20socket=20then=20only=20editing=20or?= =?UTF-8?q?=20removing=20the=20ones=20it=20had.=20relinkBacklinks=20now=20?= =?UTF-8?q?sends=20dump=5Frelated=5Fchanged=20when=20a=20note=20or=20a=20c?= =?UTF-8?q?omment=20gains=20or=20loses=20a=20link,=20naming=20the=20dump?= =?UTF-8?q?=20and=20those=20at=20the=20other=20end,=20and=20the=20page=20s?= =?UTF-8?q?howing=20one=20of=20them=20fetches=20/related=20again.=20A=20li?= =?UTF-8?q?nk=20shows=20to=20those=20who=20may=20see=20both=20its=20ends,?= =?UTF-8?q?=20so=20each=20socket=20is=20told=20only=20of=20the=20dumps=20i?= =?UTF-8?q?ts=20user=20may=20see,=20and=20not=20at=20all=20without=20one?= =?UTF-8?q?=20at=20each=20end.=20A=20private=20dump=20that=20turns=20publi?= =?UTF-8?q?c=20sends=20it=20too,=20for=20the=20links=20it=20already=20had,?= =?UTF-8?q?=20which=20are=20new=20to=20everyone=20but=20its=20poster.=20Th?= =?UTF-8?q?e=20page=20also=20fetches=20its=20related=20dumps=20again=20on?= =?UTF-8?q?=20a=20reconnection,=20as=20it=20does=20its=20comments=20and=20?= =?UTF-8?q?playlists.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/services/backlink-service.ts | 55 +++++++++++++++++- api/services/dump-service.ts | 6 +- api/services/ws-service.ts | 23 ++++++++ api/test/routes/ws.test.ts | 72 ++++++++++++++++++++++++ shared/ws.ts | 17 ++++++ src/contexts/ws/WSContext.ts | 11 ++++ src/contexts/ws/WSProvider.tsx | 4 ++ src/pages/Dump.tsx | 14 ++++- src/test/contexts/ws/WSProvider.test.tsx | 8 +++ src/test/pages/Dump.test.tsx | 19 +++++++ 10 files changed, 221 insertions(+), 8 deletions(-) diff --git a/api/services/backlink-service.ts b/api/services/backlink-service.ts index ebd41f69..6bba2961 100644 --- a/api/services/backlink-service.ts +++ b/api/services/backlink-service.ts @@ -1,7 +1,8 @@ /** * Records which dumps a dump's note and comments link to, and finds the dumps * related to one through those links. The dump and comment services keep the - * links up to date, and the dump page lists the related dumps. + * links up to date, and the dump page lists the related dumps, which it + * fetches again when `dump_related_changed` says they changed. * * @module */ @@ -16,6 +17,7 @@ import { import { parsePagePath } from "../../shared/paths.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { parseRows } from "../model/rows.ts"; +import { broadcastDumpRelatedChanged } from "./ws-service.ts"; /** * Matches an http(s) URL in Markdown. It stops at the delimiters of @@ -46,6 +48,42 @@ function resolveDumpPathId(pathname: string): string | null { return row?.id ?? null; } +/** + * Tells clients that the links between `dumpId` and each of `otherIds` + * changed, as `broadcastDumpRelatedChanged` says, after reading who may see + * each of them. It sends nothing without `otherIds`. + */ +function announceRelatedChange(dumpId: string, otherIds: string[]): void { + if (otherIds.length === 0) return; + const ids = [dumpId, ...otherIds]; + const rows = db.prepare( + `SELECT id, user_id, is_private FROM dumps + WHERE id IN (${ids.map(() => "?").join(", ")});`, + ).all(...ids) as { id: string; user_id: string; is_private: number }[]; + const dumps = rows.map((row) => ({ + id: row.id, + userId: row.user_id, + isPrivate: Boolean(row.is_private), + })); + const dump = dumps.find((d) => d.id === dumpId); + if (!dump) return; + broadcastDumpRelatedChanged(dump, dumps.filter((d) => d.id !== dumpId)); +} + +/** + * Tells clients that dump `dumpId`, which just turned public, is now related + * to the dumps linked to or from it, for the dump service. Its poster saw + * those links already; to everyone else they are new. + */ +export function announceLinksNowPublic(dumpId: string): void { + const rows = db.prepare( + `SELECT to_dump_id AS id FROM dump_backlinks WHERE from_dump_id = ? + UNION + SELECT from_dump_id FROM dump_backlinks WHERE to_dump_id = ?;`, + ).all(dumpId, dumpId) as { id: string }[]; + announceRelatedChange(dumpId, rows.map((row) => row.id)); +} + /** * Replaces the backlinks from one text, a dump's note (`dump_comment`) or one * of its comments (`comment`), with the links its current `text` holds. A link @@ -53,7 +91,8 @@ function resolveDumpPathId(pathname: string): string | null { * when it is the URL a dump was posted with, compared by `canonicalizeUrl` as * reposts are. The dump and * comment services call it on every save, and with an empty `text` when a - * comment is deleted. + * comment is deleted. Clients are told of the dumps it links or no longer + * links, as `dump_related_changed`. */ export function relinkBacklinks( sourceType: "dump_comment" | "comment", @@ -90,7 +129,11 @@ export function relinkBacklinks( targetIds.delete(fromDumpId); - transaction(() => { + const formerIds = transaction(() => { + const former = db.prepare( + `SELECT to_dump_id FROM dump_backlinks + WHERE source_type = ? AND source_id = ?;`, + ).all(sourceType, sourceId) as { to_dump_id: string }[]; db.prepare( `DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`, ).run(sourceType, sourceId); @@ -102,7 +145,13 @@ export function relinkBacklinks( for (const toDumpId of targetIds) { insert.run(sourceType, sourceId, fromDumpId, toDumpId, createdAt); } + return new Set(former.map((row) => row.to_dump_id)); }); + + // Most saves change no link, and send nothing. + announceRelatedChange(fromDumpId, [ + ...targetIds.symmetricDifference(formerIds), + ]); } /** diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 098f3be8..2f9ad1f9 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -57,7 +57,7 @@ import { VALIDATION, } from "../../shared/validation.ts"; import { linkAttachments } from "./attachment-service.ts"; -import { relinkBacklinks } from "./backlink-service.ts"; +import { announceLinksNowPublic, relinkBacklinks } from "./backlink-service.ts"; import { assertCategoriesExist, attachCategoryIds, @@ -96,7 +96,8 @@ function assertFileDumpText(title?: string, comment?: string): void { * `broadcastDumpChange` for the dump itself, and, when it was deleted or * changed visibility, the dumps of each playlist holding it, which moved for * their viewers. A deletion passes the `holders` it read before the cascade - * took the dump out of them. + * took the dump out of them. A dump that turned public also joins the related + * dumps of those it is linked with. */ function announceDumpChange( before: Dump | null, @@ -106,6 +107,7 @@ function announceDumpChange( broadcastDumpChange(before, after); if (!before || (after && after.isPrivate === before.isPrivate)) return; broadcastHoldersDumps(holders ?? playlistsHolding(before.id)); + if (after && !after.isPrivate) announceLinksNowPublic(after.id); } /** diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 9b990aee..38a61792 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -235,6 +235,29 @@ export function broadcastDumpChange( }); } +/** + * Sends `dump_related_changed` after the links between `dump` and each of + * `others` changed, for the backlink service. A link shows only to those who + * may see both its ends, so a socket is told only when its user may see + * `dump` and one of `others`, and of those alone. + */ +export function broadcastDumpRelatedChanged( + dump: Pick, + others: Pick[], +): void { + for (const client of clients) { + const sees = (d: Pick) => + !d.isPrivate || d.userId === client.userId; + if (!sees(dump)) continue; + const seen = others.filter(sees); + if (seen.length === 0) continue; + send(client.socket, { + type: "dump_related_changed", + dumpIds: [dump.id, ...seen.map((d) => d.id)], + }); + } +} + /** * Sends `votes_update` after the route stored a vote, to everyone, or with * `onlyUserId` to that user's sockets alone (for a private dump). diff --git a/api/test/routes/ws.test.ts b/api/test/routes/ws.test.ts index 023a9a4d..427e132d 100644 --- a/api/test/routes/ws.test.ts +++ b/api/test/routes/ws.test.ts @@ -654,6 +654,78 @@ wsTest("REST writes broadcast to the right audience", async (connect) => { assertEquals(x.all("playlist_dumps_updated"), []); }); +wsTest( + "dump_related_changed reaches those who may see both ends of a link", + async (connect) => { + const owner = await createUser(); + const other = await createUser(); + const src = insertDump({ userId: owner.id, title: "src" }); + const target = insertDump({ userId: other.id, title: "target" }); + const secret = insertDump({ + userId: other.id, + title: "secret", + isPrivate: true, + }); + const o = await connect(owner); + const x = await connect(other); + const anon = await connect(); + + // A comment linking a public dump and a private one: the latter's poster + // alone hears of it. + const links = + `http://gerbeur.test${target.path} http://gerbeur.test${secret.path}`; + const comment = await post(`/api/dumps/${src.id}/comments`, { + auth: owner.auth, + json: { body: links }, + }); + for (const c of [o, anon]) { + assertEquals((await c.next("dump_related_changed")).dumpIds, [ + src.id, + target.id, + ]); + } + const both = (await x.next("dump_related_changed")).dumpIds; + assertEquals(both[0], src.id); + assertEquals(new Set(both.slice(1)), new Set([target.id, secret.id])); + + // An edit that changes no link says nothing; a deletion drops them. + await patch(`/api/comments/${comment.data.id}`, { + auth: owner.auth, + json: { body: `${links} again` }, + }); + await o.next("comment_updated"); + assertEquals(o.all("dump_related_changed").length, 1); + await del(`/api/comments/${comment.data.id}`, { auth: owner.auth }); + assertEquals((await anon.next("dump_related_changed")).dumpIds, [ + src.id, + target.id, + ]); + await o.next("dump_related_changed"); + await x.next("dump_related_changed"); + + // A private dump's links are its poster's alone, until it turns public. + const hidden = insertDump({ userId: owner.id, isPrivate: true }); + await put(`/api/dumps/${hidden.id}`, { + auth: owner.auth, + json: { comment: `http://gerbeur.test${target.path}` }, + }); + assertEquals((await o.next("dump_related_changed")).dumpIds, [ + hidden.id, + target.id, + ]); + await x.sync(); + assertEquals(x.all("dump_related_changed").length, 2); + await put(`/api/dumps/${hidden.id}`, { + auth: owner.auth, + json: { isPrivate: false }, + }); + assertEquals((await x.next("dump_related_changed")).dumpIds, [ + hidden.id, + target.id, + ]); + }, +); + wsTest("a password change closes the user's sockets", async (connect) => { const user = await createUser(); const c = await connect(user); diff --git a/shared/ws.ts b/shared/ws.ts index b6152202..d50e30a4 100644 --- a/shared/ws.ts +++ b/shared/ws.ts @@ -281,6 +281,22 @@ export interface DumpDeletedMessage { dumpId: string; } +/** + * Sent by the server when the dumps related to some dumps changed: a note or + * a comment gained or lost a link to a dump, or a dump that has such links + * turned public. It goes to the sockets whose user may see both ends of a + * link. The `Dump` page showing one of `dumpIds` fetches its related dumps + * again. + */ +export interface DumpRelatedChangedMessage { + type: "dump_related_changed"; + /** + * The dump whose links changed, then the dumps at their other end, without + * those this socket's user may not see. + */ + dumpIds: string[]; +} + /** * Sent by the server when a playlist is created, to every socket if it is * public, else to its owner's. It also goes to everyone but the owner when a @@ -487,6 +503,7 @@ export type ServerToClientMessage = | DumpCreatedMessage | DumpUpdatedMessage | DumpDeletedMessage + | DumpRelatedChangedMessage | PlaylistCreatedMessage | PlaylistUpdatedMessage | PlaylistDeletedMessage diff --git a/src/contexts/ws/WSContext.ts b/src/contexts/ws/WSContext.ts index ef50b298..9c55e356 100644 --- a/src/contexts/ws/WSContext.ts +++ b/src/contexts/ws/WSContext.ts @@ -77,6 +77,16 @@ export interface DumpEvent { dump: Dump; } +/** + * Keeps the related dumps on a dump's page live. `WSProvider` emits it as a + * `related` event on each `dump_related_changed`, and the `Dump` page fetches + * its related dumps again when `dumpIds` names its dump. + */ +export interface RelatedEvent { + /** The dumps this user may see whose related dumps changed. */ + dumpIds: string[]; +} + /** * Keeps a user's profile, and their name on what they posted, up to date. * `WSProvider` emits it as a `user` event on each `user_updated`, which the @@ -96,6 +106,7 @@ export interface UserEvent { export interface WSEvents { vote: VoteEvent; dump: DumpEvent; + related: RelatedEvent; playlist: PlaylistEvent; comment: CommentEvent; user: UserEvent; diff --git a/src/contexts/ws/WSProvider.tsx b/src/contexts/ws/WSProvider.tsx index 9904d37b..96d6c6ad 100644 --- a/src/contexts/ws/WSProvider.tsx +++ b/src/contexts/ws/WSProvider.tsx @@ -538,6 +538,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { break; } + case "dump_related_changed": + events.emit("related", { dumpIds: msg.dumpIds }); + break; + case "vote_ack": { const { dumpId, action, voteCount } = msg; // With more clicks in flight, the last ack tells how it ended. diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 2e07f656..bfb8baf4 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -67,8 +67,7 @@ type DumpState = * `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 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 + * events keep them current; a reconnection fetches them and the dump 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`. */ @@ -246,6 +245,13 @@ export function Dump(): React.JSX.Element { }); useDumpListSync(relatedDumps, setRelatedDumps); + // Counts the events that say this dump gained or lost a related dump. + const [relatedVersion, setRelatedVersion] = useState(0); + useWSEvent("related", ({ dumpIds }) => { + if (loadedDumpId && dumpIds.includes(loadedDumpId)) { + setRelatedVersion((n) => n + 1); + } + }); // Counts the playlist events, each of which may change the playlists // holding the dump. @@ -285,7 +291,9 @@ export function Dump(): React.JSX.Element { }) .catch(() => {}); return () => controller.abort(); - }, [loadedDumpId, authFetch]); + // The list sync above edits and removes its dumps, but can't tell which + // one a new link adds: that, like a reconnection, fetches it again. + }, [loadedDumpId, authFetch, relatedVersion, connectionEpoch]); // The server leaves out the private playlists of others. useEffect(() => { diff --git a/src/test/contexts/ws/WSProvider.test.tsx b/src/test/contexts/ws/WSProvider.test.tsx index 00e8c484..dee6bed3 100644 --- a/src/test/contexts/ws/WSProvider.test.tsx +++ b/src/test/contexts/ws/WSProvider.test.tsx @@ -55,6 +55,7 @@ function setup({ signedIn = true } = {}) { events: { vote: useEvents("vote"), dump: useEvents("dump"), + related: useEvents("related"), playlist: useEvents("playlist"), comment: useEvents("comment"), user: useEvents("user"), @@ -458,6 +459,13 @@ describe("dumps, playlists, comments, users", () => { expect(ws().recentDumps).toEqual([]); }); + test("changed links are passed on as a related event", () => { + const { socket, events } = setup(); + socket.welcome(); + socket.receive({ type: "dump_related_changed", dumpIds: ["a", "b"] }); + expect(events().related).toEqual([{ dumpIds: ["a", "b"] }]); + }); + test("injectDump adds my own new dump once", () => { const { ws } = setup(); const d = makeDump({ id: "mine" }); diff --git a/src/test/pages/Dump.test.tsx b/src/test/pages/Dump.test.tsx index 924bb67c..4f83a609 100644 --- a/src/test/pages/Dump.test.tsx +++ b/src/test/pages/Dump.test.tsx @@ -304,6 +304,25 @@ describe("Dump page", () => { .toBeInTheDocument(); }); + test("a new link to or from this dump shows among its related dumps", async () => { + dumpRoutes(); + const { socket } = openDump(); + await screen.findByRole("heading", { name: "A find" }); + expect(screen.queryByRole("heading", { name: "Related" })).toBeNull(); + route( + "GET /api/dumps/:id/related", + () => [wire(makeDump({ id: "d2", slug: "linked", title: "Linked" }))], + ); + // Links between other dumps leave this page alone. + socket.receive({ type: "dump_related_changed", dumpIds: ["d2", "d3"] }); + socket.receive({ type: "dump_related_changed", dumpIds: ["d2", "d1"] }); + expect(await screen.findByRole("link", { name: "Linked" })) + .toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Related" })) + .toBeInTheDocument(); + expect(calls("GET /api/dumps/:id/related")).toHaveLength(2); + }); + test("a dump deleted, or made private, while on screen gives way to a notice", async () => { dumpRoutes(); const { socket } = openDump(other);