feat(chat): right-click menus everywhere in the chat, and deleting your own message

Rail: a room — open, mark as read, mute / unmute notifications (per browser,
the unread count still shows), copy name, group settings, leave; the pet and
+ — chat with the pet, new chat, mark all chats as read. A message — copy
text, reply (quotes it into the box), message the sender privately (group),
delete your own (asks first). A file — open in a new tab, download, copy file
name. A group member — message privately, copy name, remove (the creator).

DELETE /api/chat/rooms/:id/messages/:messageId deletes the caller's OWN text
message and its files (403 for anyone else, an admin included) and tells the
members with a chat_deleted frame (exactAudience).
This commit is contained in:
2026-11-03 09:47:32 +07:00
parent 0d3e92cfda
commit 0016b6a427
15 changed files with 612 additions and 33 deletions
+18 -1
View File
@@ -3,7 +3,8 @@
* @description Chat between users (server/lib/chat.js, server/routes/chat.js):
* a direct chat is one per pair, a group's creator alone changes it, only a
* room's members read it — an admin included —, unread counts and read marks,
* files served only to members under a sandbox CSP, local mode refused, and
* files served only to members under a sandbox CSP, a message deleted only by
* its author, local mode refused, and
* every chat frame reaches exactly the users in its `to` (scope.exactAudience).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -151,6 +152,22 @@ describe("chat", () => {
);
});
it("lets only the author delete a message, its files too", async () => {
const fd = new FormData();
fd.append("body", "xoá tôi");
fd.append("files", new Blob(["x"], { type: "text/plain" }), "a.txt");
const m = (await call(2, "POST", `/rooms/${group}/messages`, fd)).body.message;
assert.equal((await call(3, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 403);
assert.equal((await call(1, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 404);
assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 200);
const left = (await call(2, "GET", `/rooms/${group}/messages`)).body.messages;
assert.ok(!left.some((x) => x.id === m.id));
assert.equal((await call(2, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 404);
// A system line is nobody's to delete.
const sys = left.find((x) => x.kind === "system");
assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${sys.id}`)).status, 403);
});
it("refuses an empty message", async () => {
assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400);
});
+27 -1
View File
@@ -5,7 +5,7 @@
* per pair of users) or a group anyone may start and name. Only a room's
* MEMBERS read or write it — an admin or analyst included, and there is no
* route that lists another user's rooms. A group's creator adds, removes and
* renames; any member may leave. A message is text (≤ 8000 chars) and up to
* renames; any member may leave; anyone deletes their own messages (only). A message is text (≤ 8000 chars) and up to
* `MAX_FILES` files of ≤ `MAX_FILE_BYTES` each, stored once under
* `<data dir>/chat/<room id>/<random id>` — a name the server makes, never one
* from the request — and served back only to the room's members (the route
@@ -349,6 +349,31 @@ function markRead(me, roomId, upTo) {
return { lastReadId: Math.max(n, 0) };
}
/**
* `me` deletes their OWN text message (and its files) — never anyone else's,
* an admin included. Answers who to tell (every member).
*/
function removeMessage(me, roomId, messageId) {
const room = roomFor(me, roomId);
const id = Number(messageId);
const m = Number.isSafeInteger(id)
? sql("SELECT * FROM chat_messages WHERE id = ? AND room_id = ?").get(id, room.id)
: null;
if (!m) throw err("ENOTFOUND", "no such message", 404);
if (m.user_id !== me || m.kind !== "text")
throw err("EFORBIDDEN", "only your own messages can be deleted", 403);
const files = sql("SELECT id FROM chat_files WHERE message_id = ?").all(id);
db().transaction(() => {
sql("DELETE FROM chat_messages WHERE id = ?").run(id);
const last = sql("SELECT MAX(created_at) AS at FROM chat_messages WHERE room_id = ?").get(
room.id
);
sql("UPDATE chat_rooms SET last_message_at = ? WHERE id = ?").run(last.at ?? null, room.id);
})();
for (const f of files) fs.rmSync(path.join(fileDir(room.id), f.id), { force: true });
return { notify: memberIds(room.id) };
}
/** A file of a room `me` is in: `{path, name, type, inline}`. */
function file(me, roomId, fileId) {
const room = roomFor(me, roomId);
@@ -372,6 +397,7 @@ module.exports = {
messages,
post,
markRead,
removeMessage,
file,
roomFor,
shapeRoom,
+1 -1
View File
@@ -347,7 +347,7 @@ function exactAudience(type, data) {
const d = data && typeof data === "object" ? data : {};
// Chat (server/lib/chat.js): only the room's members, whatever their role —
// an admin reads no one else's chat. No list = nobody.
if (type === "chat_message" || type === "chat_room") {
if (type === "chat_message" || type === "chat_room" || type === "chat_deleted") {
return new Set(Array.isArray(d.to) ? d.to : []);
}
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
+19
View File
@@ -239,6 +239,25 @@ const paths = {
},
},
},
"/api/chat/rooms/{id}/messages/{messageId}": {
delete: {
tags: ["Chat"],
summary: "Delete your own message",
description:
"Only the message's author (`403 EFORBIDDEN`, an admin included); its files go with it. Broadcasts `chat_deleted` to the members.",
parameters: [
idParam,
{ name: "messageId", in: "path", required: true, schema: { type: "integer" } },
],
responses: {
200: {
description: "Deleted",
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
},
...fail([403, 404]),
},
},
},
"/api/chat/rooms/{id}/read": {
post: {
tags: ["Chat"],
+12 -1
View File
@@ -6,7 +6,7 @@
* multipart `POST`), read marks and a room's files. Everything acts on the
* CALLER's own rooms (`server/lib/chat.js`): a room they are not in is 404,
* whatever their role. Local mode has one person and no chat (`400 ELOCALMODE`).
* Each change is broadcast as `chat_message` / `chat_room` to exactly the
* Each change is broadcast as `chat_message` / `chat_room` / `chat_deleted` to exactly the
* users concerned (`to`, `scope.exactAudience`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -139,6 +139,17 @@ router.post("/rooms/:id/messages", sameOriginGuard, upload, (req, res) =>
})(req, res)
);
router.delete(
"/rooms/:id/messages/:messageId",
sameOriginGuard,
handle((me, req, res) => {
const { notify } = chat.removeMessage(me, req.params.id, req.params.messageId);
const roomId = Number(req.params.id);
tell("chat_deleted", { roomId, messageId: Number(req.params.messageId) }, notify);
res.json({ ok: true });
})
);
router.post(
"/rooms/:id/read",
sameOriginGuard,