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
+35
View File
@@ -81,6 +81,7 @@ describe("chat", () => {
it("lists the other enabled users to chat with", async () => {
const r = await call(2, "GET", "/people");
assert.deepEqual(r.body.people.map((p) => p.username).sort(), ["binh", "boss", "chi"]);
assert.ok(r.body.people.every((p) => p.disabled === false));
});
it("opens one direct chat per pair, whoever starts it", async () => {
@@ -250,6 +251,40 @@ describe("chat", () => {
assert.deepEqual([...scope.exactAudience("chat_read", { to: [2, 3] })], [2, 3]);
});
it("hands a group to its longest-standing member when the creator leaves", async () => {
const g = (await call(2, "POST", "/rooms", { kind: "group", name: "Kế", userIds: [3, 4] })).body
.room.id;
await call(2, "POST", `/rooms/${g}/leave`);
const room = (await call(3, "GET", "/rooms")).body.rooms.find((r) => r.id === g);
assert.equal(room.createdBy, 3);
assert.equal((await call(3, "PATCH", `/rooms/${g}`, { name: "Kế 2" })).status, 200);
// The "left" and "renamed" lines never count as unread.
const four = (await call(4, "GET", "/rooms")).body.rooms.find((r) => r.id === g);
assert.equal(four.unread, 0);
});
it("still names a disabled user, and never offers them for a new chat", async () => {
const { db } = require("../db");
db.prepare("UPDATE users SET disabled = 1 WHERE id = 4").run();
try {
const chi = (await call(2, "GET", "/people")).body.people.find((p) => p.id === 4);
assert.equal(chi.disabled, true);
assert.equal((await call(2, "POST", "/rooms", { kind: "dm", userId: 4 })).status, 400);
} finally {
db.prepare("UPDATE users SET disabled = 0 WHERE id = 4").run();
}
});
it("leaves nothing behind in the upload folder", async () => {
const fd = new FormData();
fd.append("files", new Blob(["z"], { type: "text/plain" }), "d.txt");
assert.equal((await call(2, "POST", `/rooms/${group}/messages`, fd)).status, 201);
const fd2 = new FormData();
fd2.append("files", new Blob(["z"], { type: "text/plain" }), "e.txt");
assert.equal((await call(1, "POST", `/rooms/${group}/messages`, fd2)).status, 404);
assert.deepEqual(fs.readdirSync(path.join(TMP, "chat", ".incoming")), []);
});
it("refuses an empty message", async () => {
assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400);
});