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

@@ -146,8 +146,8 @@ function broadcast(
/**
* Sends `data` to every socket of `userId`. The notification service sends
* `notification_created` with it, the follow service `follows_changed`, and
* the route `chat_read_update`.
* `notification_created` and `notifications_read` with it, the follow service
* `follows_changed`, and the route `chat_read_update`.
*/
export function sendToUser(userId: string, data: ServerToClientMessage): void {
broadcast(data, (client) => client.userId === userId);
@@ -174,43 +174,56 @@ export function broadcastPresence(): void {
}
/**
* Tells the clients that may see a dump how it changed, for the dump service
* after every write. `before` is null for a new dump, `after` for a deleted
* one. A private dump's changes reach its owner's sockets alone. When a dump
* goes private, everyone else gets `dump_deleted`; when it goes public, they
* get `dump_created`, since to them it is new.
* Sends the messages that tell each client how an item it may see changed,
* for {@link broadcastDumpChange} and {@link broadcastPlaylistChange}.
* `before` is null for a new item, `after` for a deleted one. A private item's
* changes reach its owner's sockets alone. When it goes private, everyone else
* is told it was deleted, and gets nothing of the change; when it goes public,
* they are told it was created, since to them it is new.
*/
function broadcastChange<T extends { userId: string }>(
before: T | null,
after: T | null,
isPrivate: (item: T) => boolean,
messages: {
created: (item: T) => ServerToClientMessage;
updated: (item: T) => ServerToClientMessage;
deleted: (item: T) => ServerToClientMessage;
},
): void {
const item = after ?? before;
if (!item) return;
const toOwner = (client: WsClient) => client.userId === item.userId;
const toOthers = (client: WsClient) => client.userId !== item.userId;
if (!after) {
broadcast(messages.deleted(item), isPrivate(item) ? toOwner : undefined);
} else if (!before) {
broadcast(messages.created(after), isPrivate(after) ? toOwner : undefined);
} else if (isPrivate(after)) {
broadcast(messages.updated(after), toOwner);
if (!isPrivate(before)) broadcast(messages.deleted(after), toOthers);
} else if (isPrivate(before)) {
broadcast(messages.updated(after), toOwner);
broadcast(messages.created(after), toOthers);
} else {
broadcast(messages.updated(after));
}
}
/**
* Tells the clients that may see a dump how it changed, as
* {@link broadcastChange} says, for the dump service after every write.
*/
export function broadcastDumpChange(
before: Dump | null,
after: Dump | null,
): void {
const dump = after ?? before;
if (!dump) return;
const ownerId = dump.userId;
const toOwner = (client: WsClient) => client.userId === ownerId;
const toOthers = (client: WsClient) => client.userId !== ownerId;
if (!after) {
broadcast(
{ type: "dump_deleted", dumpId: dump.id },
dump.isPrivate ? toOwner : undefined,
);
} else if (!before) {
broadcast(
{ type: "dump_created", dump: after },
after.isPrivate ? toOwner : undefined,
);
} else if (after.isPrivate) {
broadcast({ type: "dump_updated", dump: after }, toOwner);
if (!before.isPrivate) {
broadcast({ type: "dump_deleted", dumpId: after.id }, toOthers);
}
} else if (before.isPrivate) {
broadcast({ type: "dump_updated", dump: after }, toOwner);
broadcast({ type: "dump_created", dump: after }, toOthers);
} else {
broadcast({ type: "dump_updated", dump: after });
}
broadcastChange(before, after, (dump) => dump.isPrivate, {
created: (dump) => ({ type: "dump_created", dump }),
updated: (dump) => ({ type: "dump_updated", dump }),
deleted: (dump) => ({ type: "dump_deleted", dumpId: dump.id }),
});
}
/**
@@ -248,50 +261,24 @@ export function broadcastCommentLikeUpdate(
);
}
/** Sends `data` to everyone for a public playlist, else to its owner alone. */
function sendToPlaylistAudience(
playlist: Pick<Playlist, "isPublic" | "userId">,
data: ServerToClientMessage,
): void {
broadcast(
data,
(client) => playlist.isPublic || client.userId === playlist.userId,
);
}
/**
* Sends `playlist_created` to the new playlist's audience. The playlist service
* calls it.
* Tells the clients that may see a playlist how it changed, as
* {@link broadcastChange} says, for the playlist service after every write to
* its details. A playlist turned private thus leaves the others' views without
* their learning what it became.
*/
export function broadcastPlaylistCreated(playlist: Playlist): void {
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
}
/**
* Sends `playlist_updated` to the playlist's audience. `wasPublic` is its
* visibility before the change: a playlist that just went private still goes
* to everyone, so that the others can drop it.
*/
export function broadcastPlaylistUpdated(
playlist: Playlist,
wasPublic: boolean,
export function broadcastPlaylistChange(
before: Playlist | null,
after: Playlist | null,
): void {
sendToPlaylistAudience(
{ isPublic: playlist.isPublic || wasPublic, userId: playlist.userId },
{ type: "playlist_updated", playlist },
);
}
/** Sends `playlist_deleted` to the audience the playlist had. */
export function broadcastPlaylistDeleted(
playlistId: string,
userId: string,
isPublic: boolean,
): void {
sendToPlaylistAudience({ isPublic, userId }, {
type: "playlist_deleted",
playlistId,
userId,
broadcastChange(before, after, (playlist) => !playlist.isPublic, {
created: (playlist) => ({ type: "playlist_created", playlist }),
updated: (playlist) => ({ type: "playlist_updated", playlist }),
deleted: (p) => ({
type: "playlist_deleted",
playlistId: p.id,
userId: p.userId,
}),
});
}
@@ -301,7 +288,7 @@ export function broadcastPlaylistDeleted(
* in a public playlist stay hidden from everyone but their posters.
*/
export function broadcastPlaylistDumpsUpdated(
playlist: Playlist,
playlist: Pick<Playlist, "id" | "isPublic" | "userId">,
members: PlaylistMember[],
): void {
for (const client of clients) {
@@ -326,24 +313,53 @@ export function broadcastUserUpdated(user: PublicUser): void {
}
/**
* Sends everyone `comment_created`. The comments route calls it only for a
* comment on a public dump, as it does the two below.
* Returns the filter of a broadcast about a comment: everyone while its dump
* is public, the dump's owner alone while it is private, since no one else may
* see it.
*/
export function broadcastCommentCreated(comment: Comment): void {
broadcast({ type: "comment_created", comment });
function audienceOf(
item: { isPrivate: boolean; userId: string },
): ((client: WsClient) => boolean) | undefined {
return item.isPrivate ? (client) => client.userId === item.userId : undefined;
}
/** Sends everyone `comment_deleted`. */
/**
* Sends `comment_created` to those who may see the comment's dump: everyone,
* or its owner alone when it is private. The comments route calls it, as it
* does the two below.
*/
export function broadcastCommentCreated(
comment: Comment,
dump: { isPrivate: boolean; userId: string },
): void {
broadcast({ type: "comment_created", comment }, audienceOf(dump));
}
/** Sends `comment_deleted`, as {@link broadcastCommentCreated} does. */
export function broadcastCommentDeleted(
commentId: string,
dumpId: string,
dump: { id: string; isPrivate: boolean; userId: string },
): void {
broadcast({ type: "comment_deleted", commentId, dumpId });
broadcast(
{ type: "comment_deleted", commentId, dumpId: dump.id },
audienceOf(dump),
);
}
/** Sends everyone `comment_updated`. */
export function broadcastCommentUpdated(comment: Comment): void {
broadcast({ type: "comment_updated", comment });
/** Sends `comment_updated`, as {@link broadcastCommentCreated} does. */
export function broadcastCommentUpdated(
comment: Comment,
dump: { isPrivate: boolean; userId: string },
): void {
broadcast({ type: "comment_updated", comment }, audienceOf(dump));
}
/**
* Sends everyone `categories_changed`, after the category manager created,
* edited or deleted one, so that every client fetches them again.
*/
export function broadcastCategoriesChanged(): void {
broadcast({ type: "categories_changed" });
}
/**