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:
@@ -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
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user