v4: a new link between two dumps shows among their related dumps at once — it showed on the next load of the page: the backlink service recorded the link without telling anyone, and the dump page fetched its related dumps once, the socket then only editing or removing the ones it had. relinkBacklinks now sends dump_related_changed when a note or a comment gains or loses a link, naming the dump and those at the other end, and the page showing one of them fetches /related again. A link shows to those who may see both its ends, so each socket is told only of the dumps its user may see, and not at all without one at each end. A private dump that turns public sends it too, for the links it already had, which are new to everyone but its poster. The page also fetches its related dumps again on a reconnection, as it does its comments and playlists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-10 08:43:55 +00:00
parent dde0232acb
commit 350e945eb1
10 changed files with 221 additions and 8 deletions

View File

@@ -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),
]);
}
/**

View File

@@ -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);
}
/**

View File

@@ -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<Dump, "id" | "isPrivate" | "userId">,
others: Pick<Dump, "id" | "isPrivate" | "userId">[],
): void {
for (const client of clients) {
const sees = (d: Pick<Dump, "isPrivate" | "userId">) =>
!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).

View File

@@ -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<Comment>(`/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);

View File

@@ -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

View File

@@ -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;

View File

@@ -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.

View File

@@ -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(() => {

View File

@@ -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" });

View File

@@ -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);