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

@@ -1,7 +1,7 @@
/**
* Serves the comments on dumps. The comments of a private dump 404 for anyone
* but its poster, and changes to them aren't broadcast. Comments are otherwise
* broadcast over the WebSocket as they are posted, edited and deleted.
* Serves the comments on dumps. Comments are broadcast over the WebSocket as
* they are posted, edited and deleted, to everyone, or to its poster alone for
* a private dump, whose comments 404 for anyone else.
*
* - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment
* on a dump, oldest first.
@@ -76,7 +76,7 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
body.body,
body.parentId ?? undefined,
);
if (!dump.isPrivate) broadcastCommentCreated(comment);
broadcastCommentCreated(comment, dump);
const responseBody: APIResponse<Comment> = { success: true, data: comment };
ctx.response.status = 201;
ctx.response.body = responseBody;
@@ -89,13 +89,13 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
if (!isUpdateCommentRequest(body)) {
throw new APIException(400, "Invalid comment data");
}
const { comment, isPrivate } = updateComment(
const { comment, dump } = updateComment(
ctx.params.commentId,
body.body,
userId,
canModerate,
);
if (!isPrivate) broadcastCommentUpdated(comment);
broadcastCommentUpdated(comment, dump);
const responseBody: APIResponse<Comment> = { success: true, data: comment };
ctx.response.body = responseBody;
});
@@ -123,12 +123,12 @@ router.get("/comments/:commentId/likers", async (ctx) => {
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
const userId = ctx.state.user.userId;
const canModerate = can(ctx.state.user, "comment:moderate");
const { dumpId, isPrivate } = deleteComment(
const dump = deleteComment(
ctx.params.commentId,
userId,
canModerate,
);
if (!isPrivate) broadcastCommentDeleted(ctx.params.commentId, dumpId);
broadcastCommentDeleted(ctx.params.commentId, dump);
const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
});

View File

@@ -28,6 +28,7 @@ import { type Page, pageOf } from "../lib/pagination.ts";
import { isReservedSlug } from "../lib/reserved-slugs.ts";
import { validateSlug } from "../lib/slugify.ts";
import { VALIDATION } from "../../shared/validation.ts";
import { broadcastCategoriesChanged } from "./ws-service.ts";
import { parseRows } from "../model/rows.ts";
/**
@@ -176,7 +177,8 @@ export function listCategories(): Category[] {
/**
* Creates a category, for `POST /api/categories`. The name is stored trimmed,
* and the position defaults to 0.
* and the position defaults to 0. Every client is told to fetch the
* categories again, as after the two writes below.
*
* @throws {APIException} 400 when the slug or name is invalid, 409 when
* either is taken within its kind.
@@ -202,6 +204,7 @@ export function createCategory(req: CreateCategoryRequest): Category {
} catch (err) {
uniqueConflict(err, req.slug, name);
}
broadcastCategoriesChanged();
return {
id,
@@ -249,6 +252,7 @@ export function updateCategory(
} catch (err) {
uniqueConflict(err, newSlug, newName);
}
broadcastCategoriesChanged();
return {
...existing,
@@ -270,6 +274,7 @@ export function deleteCategory(id: string): void {
if (result.changes === 0) {
throw new APIException(404, "Category not found");
}
broadcastCategoriesChanged();
}
/**

View File

@@ -114,12 +114,44 @@ export function createComment(
return comment;
}
/**
* The dump a comment is on, as {@link updateComment} and {@link deleteComment}
* return it: what the route needs to choose the broadcast's audience.
*/
export interface CommentDump {
id: string;
isPrivate: boolean;
/** The dump's poster, the only one who sees a private dump's comments. */
userId: string;
}
/**
* Returns the dump comment `commentId` is on.
*
* @throws {APIException} 404 when there is no such comment.
*/
function commentDump(commentId: string): CommentDump {
const row = db.prepare(
`SELECT d.id, d.is_private, d.user_id FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
).get(commentId) as
| { id: string; is_private: number; user_id: string }
| undefined;
if (!row) {
throw new APIException(404, "Comment not found");
}
return {
id: row.id,
isPrivate: Boolean(row.is_private),
userId: row.user_id,
};
}
/**
* Replaces a comment's body, for `PATCH /api/comments/:commentId`.
* `canModerate` lets someone other than the author do it. It notifies, in the
* author's name, the users it mentions for the first time, unless the dump is
* private, and relinks the attachments and backlinks. The route broadcasts the
* comment unless `isPrivate`.
* comment to those who may see its `dump`.
*
* @throws {APIException} 404 when there is no such comment, 400 when it is
* deleted, 403 when the user is neither its author nor a moderator.
@@ -129,13 +161,8 @@ export function updateComment(
body: string,
requestingUserId: string,
canModerate: boolean,
): { comment: Comment; dumpId: string; isPrivate: boolean } {
const row = db.prepare(
`SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
).get(commentId) as { dump_id: string; is_private: number } | undefined;
if (!row) {
throw new APIException(404, "Comment not found");
}
): { comment: Comment; dump: CommentDump } {
const dump = commentDump(commentId);
const existing = fetchComment(commentId);
if (existing.deleted) {
throw new APIException(400, "Cannot edit a deleted comment");
@@ -149,9 +176,9 @@ export function updateComment(
now,
commentId,
);
if (!row.is_private) {
if (!dump.isPrivate) {
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
row.dump_id,
dump.id,
) as { title: string } | undefined;
// A moderator's edit keeps the author's words, so the author mentions.
notifyMentions(
@@ -160,23 +187,19 @@ export function updateComment(
"comment",
commentId,
dumpRow?.title ?? "",
row.dump_id,
dump.id,
);
}
linkAttachments(body, "comment", commentId);
relinkBacklinks("comment", commentId, row.dump_id, body);
return {
comment: fetchComment(commentId),
dumpId: row.dump_id,
isPrivate: Boolean(row.is_private),
};
relinkBacklinks("comment", commentId, dump.id, body);
return { comment: fetchComment(commentId), dump };
}
/**
* Deletes a comment, for `DELETE /api/comments/:commentId`. `canModerate` lets
* someone other than the author do it. The body is emptied and the backlinks
* dropped, but the row stays so its replies keep their place. The route
* broadcasts the deletion unless `isPrivate`.
* broadcasts the deletion to those who may see the returned dump.
*
* @throws {APIException} 404 when there is no such comment, 403 when the user
* is neither its author nor a moderator.
@@ -185,13 +208,8 @@ export function deleteComment(
commentId: string,
requestingUserId: string,
canModerate: boolean,
): { dumpId: string; isPrivate: boolean } {
const row = db.prepare(
`SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
).get(commentId) as { dump_id: string; is_private: number } | undefined;
if (!row) {
throw new APIException(404, "Comment not found");
}
): CommentDump {
const dump = commentDump(commentId);
const comment = fetchComment(commentId);
if (comment.userId !== requestingUserId && !canModerate) {
throw new APIException(403, "Not authorized to delete this comment");
@@ -199,6 +217,6 @@ export function deleteComment(
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(
commentId,
);
relinkBacklinks("comment", commentId, row.dump_id, "");
return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) };
relinkBacklinks("comment", commentId, dump.id, "");
return dump;
}

View File

@@ -33,6 +33,11 @@ import {
} from "./rich-content-service.ts";
import { isValidHttpUrl } from "../remote/fetch.ts";
import { broadcastDumpChange } from "./ws-service.ts";
import {
broadcastHoldersDumps,
type PlaylistHolder,
playlistsHolding,
} from "./playlist-service.ts";
import {
notifyMentions,
notifyPlaylistFollowersNowPublic,
@@ -86,6 +91,23 @@ function assertFileDumpText(title?: string, comment?: string): void {
}
}
/**
* Tells clients how a dump changed, for every write of this service:
* `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.
*/
function announceDumpChange(
before: Dump | null,
after: Dump | null,
holders?: PlaylistHolder[],
): void {
broadcastDumpChange(before, after);
if (!before || (after && after.isPrivate === before.isPrivate)) return;
broadcastHoldersDumps(holders ?? playlistsHolding(before.id));
}
/**
* Notifies the followers of a dump that went public, `before` being private
* and `after` public: the poster's, as if it were just posted, and those of
@@ -149,7 +171,7 @@ export async function createUrlDump(
});
const dump: Dump = { ...fetchDump(dumpId), categoryIds };
broadcastDumpChange(null, dump);
announceDumpChange(null, dump);
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, title);
if (request.comment) {
// A private dump's mentions are notified if it goes public.
@@ -225,7 +247,7 @@ export async function createFileDump(
}
const dump: Dump = { ...fetchDump(dumpId), categoryIds: dedupedCategoryIds };
broadcastDumpChange(null, dump);
announceDumpChange(null, dump);
if (!isPrivate) notifyUserFollowersNewDump(userId, dumpId, finalTitle);
if (comment) {
// A private dump's mentions are notified if it goes public.
@@ -486,7 +508,7 @@ export async function updateDump(
} else {
attachCategoryIds("dump", [updatedDump]);
}
broadcastDumpChange(dump, updatedDump);
announceDumpChange(dump, updatedDump);
notifyIfWentPublic(dump, updatedDump);
if (updatedDump.comment) {
if (!updatedDump.isPrivate) {
@@ -558,7 +580,7 @@ export async function updateDump(
attachCategoryIds("dump", [updatedDump]);
}
broadcastDumpChange(dump, updatedDump);
announceDumpChange(dump, updatedDump);
notifyIfWentPublic(dump, updatedDump);
if (updatedDump.comment) {
if (!updatedDump.isPrivate) {
@@ -687,7 +709,7 @@ export async function replaceFileDump(
isPrivate: finalPrivate,
updatedAt: now,
};
broadcastDumpChange(dump, updatedDump);
announceDumpChange(dump, updatedDump);
notifyIfWentPublic(dump, updatedDump);
return updatedDump;
}
@@ -799,7 +821,7 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
).run(row.title, row.rich_content, row.id);
broadcastDumpChange(dump, updatedDump);
announceDumpChange(dump, updatedDump);
return updatedDump;
}
@@ -826,7 +848,7 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
dump.id,
);
const updatedDump: Dump = { ...dump, thumbnailMime: mime };
broadcastDumpChange(dump, updatedDump);
announceDumpChange(dump, updatedDump);
return updatedDump;
}
@@ -842,18 +864,20 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
.run(dump.id);
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
broadcastDumpChange(dump, updatedDump);
announceDumpChange(dump, updatedDump);
return updatedDump;
}
/**
* Deletes a dump with its files and thumbnails, for
* `DELETE /api/dumps/:dumpId`, and broadcasts `dump_deleted`.
* `DELETE /api/dumps/:dumpId`, and broadcasts `dump_deleted` and the new
* dumps of the playlists that held it.
*
* @throws {APIException} 404 when there is no such dump.
*/
export async function deleteDump(dumpId: string): Promise<void> {
const dump = fetchDump(dumpId);
const holders = playlistsHolding(dumpId);
const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId);
@@ -869,5 +893,5 @@ export async function deleteDump(dumpId: string): Promise<void> {
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
}
broadcastDumpChange(dump, null);
announceDumpChange(dump, null, holders);
}

View File

@@ -167,12 +167,14 @@ export function getUnreadCount(userId: string): number {
/**
* Marks every notification of `userId` read, when they open the
* `/notifications` page.
* `/notifications` page, and tells all their sockets, so that the bell clears
* in their other tabs and devices too.
*/
export function markAllRead(userId: string): void {
db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run(
userId,
);
sendToUser(userId, { type: "notifications_read" });
}
/**

View File

@@ -25,10 +25,8 @@ import {
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import {
broadcastPlaylistCreated,
broadcastPlaylistDeleted,
broadcastPlaylistChange,
broadcastPlaylistDumpsUpdated,
broadcastPlaylistUpdated,
} from "./ws-service.ts";
import {
notifyMentions,
@@ -138,7 +136,7 @@ export function createPlaylist(
}
linkAttachments(req.description, "playlist", id);
}
broadcastPlaylistCreated(playlist);
broadcastPlaylistChange(null, playlist);
return playlist;
}
@@ -276,7 +274,7 @@ export function listPlaylistsByUser(
* playlist is public, it notifies, in the owner's name, the users the
* description mentions. When it goes public, its followers are notified of
* its public dumps they weren't yet told of. It links the description's
* attachments and broadcasts `playlist_updated`.
* attachments and tells clients of the change (`broadcastPlaylistChange`).
*
* @throws {APIException} 404 when there is no such playlist, 403 when the user
* is neither its owner nor a moderator, 400 when the slug isn't valid or a
@@ -349,7 +347,7 @@ export function updatePlaylist(
}
linkAttachments(newDescription, "playlist", playlist.id);
}
broadcastPlaylistUpdated(updated, playlist.isPublic);
broadcastPlaylistChange(playlist, updated);
// Its followers weren't told of the dumps added while it was private.
if (!playlist.isPublic && newIsPublic) {
notifyPlaylistFollowersNowPublic({ playlistId: playlist.id });
@@ -376,7 +374,7 @@ export function deletePlaylist(
}
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic);
broadcastPlaylistChange(playlist, null);
}
/**
@@ -424,7 +422,7 @@ export function setPlaylistImage(
`UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`,
).run(imageMime, new Date().toISOString(), playlist.id);
const updated = getPlaylistById(playlist.id);
broadcastPlaylistUpdated(updated, playlist.isPublic);
broadcastPlaylistChange(playlist, updated);
return updated;
}
@@ -676,3 +674,37 @@ function getCurrentMembers(playlistId: string): PlaylistMember[] {
userId: r.user_id,
}));
}
/**
* A playlist holding a dump, as {@link playlistsHolding} returns it: what
* `broadcastPlaylistDumpsUpdated` needs to choose its audience.
*/
export type PlaylistHolder = Pick<Playlist, "id" | "isPublic" | "userId">;
/**
* Returns the playlists holding dump `dumpId`. The dump service reads them
* before it deletes the dump or changes its visibility, and passes them to
* {@link broadcastHoldersDumps} after.
*/
export function playlistsHolding(dumpId: string): PlaylistHolder[] {
const rows = db.prepare(
`SELECT p.id, p.is_public, p.user_id
FROM playlist_dumps pd INNER JOIN playlists p ON p.id = pd.playlist_id
WHERE pd.dump_id = ?;`,
).all(dumpId) as Array<{ id: string; is_public: number; user_id: string }>;
return rows.map((r) => ({
id: r.id,
isPublic: Boolean(r.is_public),
userId: r.user_id,
}));
}
/**
* Sends each of `holders` its dumps again, after one of them was deleted or
* changed visibility: the count moved, or what each viewer may see of them.
*/
export function broadcastHoldersDumps(holders: PlaylistHolder[]): void {
for (const holder of holders) {
broadcastPlaylistDumpsUpdated(holder, getCurrentMembers(holder.id));
}
}

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

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