fix(chat): the review's gaps — owner hand-over, live group lines, names, scroll, one tab's sound, uploads on disk, a chat button without the pet

- A group whose creator left or was deleted passes to its longest-standing
  member (ensureOwner), so it can still be managed.
- Group changes (added / removed / renamed / left) are sent live as
  chat_message and never count as unread, here or on the server.
- A disabled account is still named in old chats (people carries
  `disabled`) and never offered for a new one; a deleted one reads
  'Deleted user' instead of '?'.
- Load older keeps the message being read in place.
- With several tabs open only the first to see a message plays its sound /
  system notification (agenthub.chat.notified).
- The people list is read again on New chat and on every room change.
- Uploads go to <data dir>/chat/.incoming/ (multer disk storage) and are
  renamed into place — never held in memory; a refused one is removed.
- With the pet hidden, a chat button in the bottom-right corner (ChatDock,
  server mode) opens the same window, unread count and new-message card
  included.
This commit is contained in:
2026-11-03 09:59:13 +07:00
parent c797e9868e
commit 03ebea1d92
15 changed files with 471 additions and 68 deletions
+23 -6
View File
@@ -48,12 +48,17 @@ function tell(type, data, to) {
}
}
/** Multipart `files` + `body`, in memory, within the chat's limits. */
/** Multipart `files` + `body`, written to disk (never held whole in memory), within the chat's limits. */
function upload(req, res, next) {
if (!req.is("multipart/form-data")) return next();
const multer = require("multer");
multer({
storage: multer.memoryStorage(),
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, chat.incomingDir()),
// A name of ours; the browser's goes only into the database.
filename: (_req, _file, cb) =>
cb(null, require("node:crypto").randomBytes(16).toString("hex")),
}),
limits: { fileSize: chat.MAX_FILE_BYTES, files: chat.MAX_FILES },
}).array("files")(req, res, (e) => {
if (!e) return next();
@@ -98,12 +103,14 @@ router.patch(
sameOriginGuard,
handle((me, req, res) => {
const b = req.body || {};
const { room, notify } = chat.updateGroup(me, req.params.id, {
const { room, notify, lines } = chat.updateGroup(me, req.params.id, {
name: b.name,
add: b.add,
remove: b.remove,
});
tell("chat_room", { roomId: room.id }, notify);
// The "X added Y" lines, live in every open view.
for (const message of lines) tell("chat_message", { roomId: room.id, message }, room.members);
res.json({ room });
})
);
@@ -112,8 +119,11 @@ router.post(
"/rooms/:id/leave",
sameOriginGuard,
handle((me, req, res) => {
const { notify } = chat.leaveGroup(me, req.params.id);
const { notify, lines } = chat.leaveGroup(me, req.params.id);
tell("chat_room", { roomId: Number(req.params.id) }, notify);
const stay = notify.filter((id) => id !== me);
for (const message of lines)
tell("chat_message", { roomId: Number(req.params.id), message }, stay);
res.json({ ok: true });
})
);
@@ -149,9 +159,16 @@ router.post("/rooms/:id/messages", sameOriginGuard, upload, (req, res) =>
// multer reads names as latin1; browsers send UTF-8.
name: Buffer.from(f.originalname, "latin1").toString("utf8"),
type: f.mimetype,
buffer: f.buffer,
path: f.path,
}));
const { message, notify } = chat.post(me, req.params.id, { body: req.body?.body, files });
let posted;
try {
posted = chat.post(me, req.params.id, { body: req.body?.body, files });
} finally {
// Moved into place on success; anything left over is a refused upload.
for (const f of files) require("node:fs").rmSync(f.path, { force: true });
}
const { message, notify } = posted;
tell("chat_message", { roomId: message.roomId, message }, notify);
res.status(201).json({ message });
})(req, res)