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
+108
View File
@@ -0,0 +1,108 @@
/**
* @file ChatDock.tsx
* @description Chat between users for someone who hid the pet (server mode):
* a round 💬 button in the bottom-right corner, above the status bar, with
* the unread count, that opens the same chat window the pet has (`ChatHub.tsx`
* — rooms rail without the pet, a room, New chat). A new message from someone
* shows as the same card above the button while the window is closed. Mounted
* by `SagiMount` only while the pet is off, so the two never both run.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { MessagesSquare } from "lucide-react";
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "./ChatHub";
import { useChat } from "./useChat";
export function ChatDock() {
const { t } = useTranslation("settings");
const chat = useChat();
const [open, setOpen] = useState(false);
const [pane, setPane] = useState<"new" | number>("new");
const [settingsAsk, setSettingsAsk] = useState(0);
const room = typeof pane === "number" ? chat.rooms.find((r) => r.id === pane) : undefined;
// Opening with nothing picked: the newest chat, else New chat.
useEffect(() => {
if (!open) return;
if (typeof pane === "number" && !room) setPane(chat.rooms[0]?.id ?? "new");
}, [open, pane, room, chat.rooms]);
useEffect(() => {
if (open && pane === "new" && chat.rooms[0]) setPane(chat.rooms[0].id);
// Only when the window opens.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const { setActive } = chat;
useEffect(() => {
setActive(open && typeof pane === "number" ? pane : null);
}, [open, pane, setActive]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
if (!chat.enabled) return null;
return (
<div className="fixed bottom-10 right-4 z-40 flex flex-col items-end gap-2">
{open && (
<div className="flex items-start gap-2">
<ChatRail
chat={chat}
active={pane}
onSelect={(v) => setPane(v === "pet" ? "new" : v)}
onSettings={(id) => {
setPane(id);
setSettingsAsk((n) => n + 1);
}}
/>
{room ? (
<ChatRoomView
key={`${room.id}:${room.clearedId ?? 0}`}
chat={chat}
room={room}
onClose={() => setOpen(false)}
onLeft={() => setPane("new")}
onOpenRoom={setPane}
showSettings={settingsAsk}
/>
) : (
<NewChat chat={chat} onOpen={setPane} onClose={() => setOpen(false)} />
)}
</div>
)}
{!open && chat.notice && (
<ChatNotice
notice={chat.notice}
onDismiss={chat.dismissNotice}
onOpen={(roomId) => {
chat.dismissNotice();
setPane(roomId);
setOpen(true);
}}
/>
)}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="relative flex h-11 w-11 items-center justify-center rounded-full bg-accent-solid text-white shadow-lg transition-opacity hover:opacity-90"
aria-label={
chat.unread ? t("sagi.people.dockUnread", { n: chat.unread }) : t("sagi.people.dock")
}
title={t("sagi.people.dock")}
aria-expanded={open}
>
<MessagesSquare size={20} />
{chat.unread > 0 && (
<span className="absolute -right-1 -top-1 min-w-[1.25rem] rounded-full bg-status-danger px-1 text-center text-[10px] font-semibold leading-5 text-white">
{chat.unread > 99 ? "99+" : chat.unread}
</span>
)}
</button>
</div>
);
}
+29 -10
View File
@@ -18,6 +18,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { flushSync } from "react-dom";
import { import {
ArrowLeft, ArrowLeft,
ArrowUp, ArrowUp,
@@ -44,14 +45,15 @@ import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { UserAvatar } from "../users/UserAvatar"; import { UserAvatar } from "../users/UserAvatar";
import { useAuth } from "../AuthGate"; import { useAuth } from "../AuthGate";
import i18n from "../../i18n";
import type { ChatState } from "./useChat"; import type { ChatState } from "./useChat";
const MAX_FILES = 10; const MAX_FILES = 10;
const MAX_FILE_BYTES = 25 * 1024 * 1024; const MAX_FILE_BYTES = 25 * 1024 * 1024;
/** Shown name of a person (display name, else login). */ /** Shown name of a person (display name, else login); an account deleted since says so. */
const personName = (p: Pick<ChatPerson, "username" | "displayName"> | undefined) => const personName = (p: Pick<ChatPerson, "username" | "displayName"> | undefined) =>
p ? p.displayName || p.username : "?"; p ? p.displayName || p.username : i18n.t("settings:sagi.people.deletedUser");
/** Everyone the view may need a name for: the people list plus the viewer. */ /** Everyone the view may need a name for: the people list plus the viewer. */
function useDirectory(chat: ChatState) { function useDirectory(chat: ChatState) {
@@ -70,9 +72,8 @@ function useDirectory(chat: ChatState) {
}, [chat.people, self]); }, [chat.people, self]);
} }
/** The other person of a direct chat. */ /** The other person of a direct chat (-1: their account was deleted). */
const otherOf = (room: ChatRoom, me: number | null) => const otherOf = (room: ChatRoom, me: number | null) => room.members.find((id) => id !== me) ?? -1;
room.members.find((id) => id !== me) ?? room.members[0] ?? -1;
function roomTitle(room: ChatRoom, me: number | null, dir: Map<number, ChatPerson>) { function roomTitle(room: ChatRoom, me: number | null, dir: Map<number, ChatPerson>) {
if (room.kind === "dm") return personName(dir.get(otherOf(room, me))); if (room.kind === "dm") return personName(dir.get(otherOf(room, me)));
@@ -263,7 +264,8 @@ export function ChatRail({
/** `"pet"`, `"new"` or a room id. */ /** `"pet"`, `"new"` or a room id. */
active: "pet" | "new" | number; active: "pet" | "new" | number;
onSelect: (v: "pet" | "new" | number) => void; onSelect: (v: "pet" | "new" | number) => void;
petName: string; /** The pet's name; absent when the pet is hidden (the chat dock): no pet item. */
petName?: string;
/** Open a group with its settings shown (its right-click menu). */ /** Open a group with its settings shown (its right-click menu). */
onSettings?: (roomId: number) => void; onSettings?: (roomId: number) => void;
}) { }) {
@@ -283,6 +285,7 @@ export function ChatRail({
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-14 flex-col items-center gap-2 overflow-y-auto overflow-x-hidden rounded-3xl border border-border-light py-3 shadow-2xl" className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-14 flex-col items-center gap-2 overflow-y-auto overflow-x-hidden rounded-3xl border border-border-light py-3 shadow-2xl"
aria-label={t("sagi.people.rail")} aria-label={t("sagi.people.rail")}
> >
{petName && (
<button <button
type="button" type="button"
onClick={() => onSelect("pet")} onClick={() => onSelect("pet")}
@@ -294,7 +297,8 @@ export function ChatRail({
> >
<span aria-hidden>🐾</span> <span aria-hidden>🐾</span>
</button> </button>
<span className="h-px w-8 shrink-0 bg-border" aria-hidden /> )}
{petName && <span className="h-px w-8 shrink-0 bg-border" aria-hidden />}
{chat.rooms.map((r) => { {chat.rooms.map((r) => {
const title = roomTitle(r, chat.me, dir); const title = roomTitle(r, chat.me, dir);
return ( return (
@@ -394,9 +398,11 @@ export function ChatRail({
</> </>
) : ( ) : (
<> <>
{petName && (
<MenuItem onSelect={pick(() => onSelect("pet"))}> <MenuItem onSelect={pick(() => onSelect("pet"))}>
{t("sagi.people.menuPet", { name: petName })} {t("sagi.people.menuPet", { name: petName })}
</MenuItem> </MenuItem>
)}
<MenuItem onSelect={pick(() => onSelect("new"))}>{t("sagi.people.new")}</MenuItem> <MenuItem onSelect={pick(() => onSelect("new"))}>{t("sagi.people.new")}</MenuItem>
<MenuItem <MenuItem
disabled={!chat.unread} disabled={!chat.unread}
@@ -515,6 +521,11 @@ export function NewChat({
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const [picked, setPicked] = useState<Set<number>>(new Set()); const [picked, setPicked] = useState<Set<number>>(new Set());
const [name, setName] = useState(""); const [name, setName] = useState("");
// Accounts made since the window opened.
const { reloadPeople } = chat;
useEffect(() => {
void reloadPeople();
}, [reloadPeople]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const group = picked.size > 1 || name.trim() !== ""; const group = picked.size > 1 || name.trim() !== "";
const toggle = (id: number) => const toggle = (id: number) =>
@@ -551,7 +562,11 @@ export function NewChat({
</div> </div>
<div className="flex min-h-0 flex-1 flex-col gap-2 px-3 py-2"> <div className="flex min-h-0 flex-1 flex-col gap-2 px-3 py-2">
<p className="text-[11px] text-fg-muted">{t("sagi.people.newHint")}</p> <p className="text-[11px] text-fg-muted">{t("sagi.people.newHint")}</p>
<PeoplePicker people={chat.people} picked={picked} onToggle={toggle} /> <PeoplePicker
people={chat.people.filter((p) => !p.disabled)}
picked={picked}
onToggle={toggle}
/>
{picked.size > 1 && ( {picked.size > 1 && (
<input <input
className="input w-full min-w-0 text-sm" className="input w-full min-w-0 text-sm"
@@ -702,7 +717,7 @@ function GroupSettings({
const leave = async () => { const leave = async () => {
if (await leaveRoom(chat, room, roomTitle(room, chat.me, dir), t)) onLeft(); if (await leaveRoom(chat, room, roomTitle(room, chat.me, dir), t)) onLeft();
}; };
const outside = chat.people.filter((p) => !room.members.includes(p.id)); const outside = chat.people.filter((p) => !p.disabled && !room.members.includes(p.id));
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden px-3 py-3"> <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden px-3 py-3">
<button <button
@@ -1001,7 +1016,11 @@ export function ChatRoomView({
try { try {
const r = await api.chat.messages(room.id, first.id); const r = await api.chat.messages(room.id, first.id);
stick.current = false; stick.current = false;
setMessages((prev) => [...r.messages, ...prev]); // Keep the message being read where it is: the page grows above it.
const el = listRef.current;
const fromBottom = el ? el.scrollHeight - el.scrollTop : 0;
flushSync(() => setMessages((prev) => [...r.messages, ...prev]));
if (el) el.scrollTop = el.scrollHeight - fromBottom;
setHasMore(r.hasMore); setHasMore(r.hasMore);
} catch (e) { } catch (e) {
toast.error(e); toast.error(e);
+14 -2
View File
@@ -3,14 +3,17 @@
* @description Gate for the Sagi mascot: follows the enabled preference * @description Gate for the Sagi mascot: follows the enabled preference
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so * (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no * a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
* WebSocket reducer. * WebSocket reducer. While it is off, the chat between users still has a
* way in: `ChatDock` (its own chunk, server mode only).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { lazy, Suspense, useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { sagiPrefs } from "./prefs"; import { sagiPrefs } from "./prefs";
import { useAuth } from "../AuthGate";
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi }))); const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
const ChatDock = lazy(() => import("./ChatDock").then((m) => ({ default: m.ChatDock })));
/** The mascot's enabled preference, followed live (Settings, other tabs). */ /** The mascot's enabled preference, followed live (Settings, other tabs). */
export function useSagiEnabled(): boolean { export function useSagiEnabled(): boolean {
@@ -20,7 +23,16 @@ export function useSagiEnabled(): boolean {
} }
export function SagiMount() { export function SagiMount() {
if (!useSagiEnabled()) return null; const { state } = useAuth();
if (!useSagiEnabled()) {
// The pet hidden: chat with other users from a button of its own.
if (state.mode !== "server") return null;
return (
<Suspense fallback={null}>
<ChatDock />
</Suspense>
);
}
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<Sagi /> <Sagi />
@@ -0,0 +1,69 @@
/**
* @file ChatDock.test.tsx
* @description The chat button shown while the pet is hidden (`ChatDock.tsx`,
* mounted by `SagiMount`): server mode only, the unread count on it, and a
* click opens the newest chat with the rooms rail beside it (no pet item).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
const auth = vi.hoisted(() => ({
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } } as unknown,
}));
const api = vi.hoisted(() => ({
chat: {
rooms: vi.fn(),
people: vi.fn(),
messages: vi.fn(),
read: vi.fn(),
fileUrl: () => "",
},
users: { avatarUrl: () => null },
me: { avatarUrl: () => null },
}));
vi.mock("../../AuthGate", () => ({ useAuth: () => ({ state: auth.state }) }));
vi.mock("../../../lib/api", async (orig) => ({ ...(await orig<Record<string, unknown>>()), api }));
import { ChatDock } from "../ChatDock";
const room = {
id: 7,
kind: "dm" as const,
name: "",
createdBy: 1,
createdAt: "2026-11-03T01:00:00Z",
lastMessageAt: null,
members: [1, 2],
lastMessage: null,
lastReadId: 0,
unread: 3,
};
beforeEach(() => {
vi.clearAllMocks();
auth.state = { mode: "server", user: { id: 1, username: "me", role: "developer" } };
api.chat.rooms.mockResolvedValue({ rooms: [room] });
api.chat.people.mockResolvedValue({
people: [{ id: 2, username: "an", displayName: "An", avatarAt: null }],
});
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
api.chat.read.mockResolvedValue({ lastReadId: 0 });
});
describe("ChatDock", () => {
it("shows the unread count and opens the newest chat", async () => {
render(<ChatDock />);
const btn = await screen.findByRole("button", { name: "Chats, 3 unread" });
fireEvent.click(btn);
await waitFor(() => expect(screen.getByRole("dialog", { name: "Chat with An" })).toBeTruthy());
expect(screen.queryByText("🐾")).toBeNull();
});
it("is not there in local mode", () => {
auth.state = { mode: "local" };
render(<ChatDock />);
expect(screen.queryByRole("button", { name: /Chats/ })).toBeNull();
});
});
@@ -8,7 +8,8 @@
* scroll only one way; a new message raises a notice that opens its room; and * scroll only one way; a new message raises a notice that opens its room; and
* the right-click menus of a room, a message, a file and a group member — * the right-click menus of a room, a message, a file and a group member —
* deleting a chat for yourself and disbanding a group (its creator) included; * deleting a chat for yourself and disbanding a group (its creator) included;
* and "Seen" under your newest message. * "Seen" under your newest message; a disabled account never offered for a
* new chat, a deleted one named as such; and the rail without the pet.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -93,6 +94,7 @@ function state(over: Partial<ChatState> = {}): ChatState {
onDeleted: vi.fn(), onDeleted: vi.fn(),
muted: new Set<number>(), muted: new Set<number>(),
toggleMute: vi.fn(), toggleMute: vi.fn(),
reloadPeople: vi.fn(async () => {}),
...over, ...over,
}; };
} }
@@ -366,4 +368,22 @@ describe("ChatHub", () => {
rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 2 } })); rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 2 } }));
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by everyone"); expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by everyone");
}); });
it("never offers a disabled account, and names a deleted one", () => {
const chat = state({
people: [
{ id: 2, username: "an", displayName: "An", avatarAt: null },
{ id: 3, username: "binh", displayName: null, avatarAt: null, disabled: true },
],
// A direct chat whose other person's account was deleted.
rooms: [{ ...dm, members: [1] }],
});
render(<NewChat chat={chat} onOpen={() => {}} onClose={() => {}} />);
expect(screen.queryByRole("checkbox", { name: /binh/ })).toBeNull();
expect(chat.reloadPeople).toHaveBeenCalled();
render(<ChatRail chat={chat} active="new" onSelect={() => {}} />);
expect(screen.getByRole("button", { name: "Deleted user" })).toBeTruthy();
// No pet name: no pet item.
expect(screen.queryByText("🐾")).toBeNull();
});
}); });
@@ -4,7 +4,8 @@
* notice (sender, room) and bumps the room's unread count, plays the sound * notice (sender, room) and bumps the room's unread count, plays the sound
* only with notifications on, and stays quiet for the room on screen and for * only with notifications on, and stays quiet for the room on screen and for
* the user's own messages; a `chat_read` frame moves that member's read mark * the user's own messages; a `chat_read` frame moves that member's read mark
* ("Seen"); nothing runs in local mode. * ("Seen"); a group change is no unread message; with several tabs only the
* first to see a message plays its sound; nothing runs in local mode.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -109,4 +110,28 @@ describe("useChat", () => {
); );
expect(result.current.rooms[0]!.reads?.[2]).toBe(9); expect(result.current.rooms[0]!.reads?.[2]).toBe(9);
}); });
it("does not count a group change as unread", async () => {
const { result } = renderHook(() => useChat());
await waitFor(() => expect(result.current.rooms.length).toBe(1));
act(() => frame({ ...msg(5, 2), kind: "system", body: "renamed" }));
expect(result.current.unread).toBe(0);
expect(result.current.notice).toBeNull();
});
it("plays a message's sound in one tab only", async () => {
localStorage.setItem(
"agent-monitor-notifications",
JSON.stringify({ enabled: true, sound: "pop" })
);
const a = renderHook(() => useChat());
const b = renderHook(() => useChat());
await waitFor(() => expect(a.result.current.rooms.length).toBe(1));
await waitFor(() => expect(b.result.current.rooms.length).toBe(1));
act(() => frame(msg(6, 2)));
expect(sound.playSound).toHaveBeenCalledTimes(1);
// Both still show the card.
expect(a.result.current.notice).not.toBeNull();
expect(b.result.current.notice).not.toBeNull();
});
}); });
+36 -8
View File
@@ -15,7 +15,9 @@
* unread count still shows), kept per browser in `agenthub.chat.muted`; a * unread count still shows), kept per browser in `agenthub.chat.muted`; a
* `chat_deleted` frame drops a message from the open view and re-reads the * `chat_deleted` frame drops a message from the open view and re-reads the
* list; a `chat_read` frame moves that member's read mark (`reads`, what the * list; a `chat_read` frame moves that member's read mark (`reads`, what the
* room shows as Seen). Off in local mode — one person has no one to chat with. * room shows as Seen). With several tabs open, only the first to see a
* message plays its sound / system notification (`agenthub.chat.notified`).
* Off in local mode — one person has no one to chat with.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -50,12 +52,26 @@ export type ChatState = {
dismissNotice: () => void; dismissNotice: () => void;
/** The open room's view, told when a message of it is deleted. */ /** The open room's view, told when a message of it is deleted. */
onDeleted: (fn: ((roomId: number, messageId: number) => void) | null) => void; onDeleted: (fn: ((roomId: number, messageId: number) => void) | null) => void;
/** Read the people list again (New chat, a room change). */
reloadPeople: () => Promise<void>;
/** Rooms whose new messages raise no notice or sound. */ /** Rooms whose new messages raise no notice or sound. */
muted: Set<number>; muted: Set<number>;
toggleMute: (roomId: number) => void; toggleMute: (roomId: number) => void;
}; };
const MUTED_KEY = "agenthub.chat.muted"; const MUTED_KEY = "agenthub.chat.muted";
const NOTIFIED_KEY = "agenthub.chat.notified";
/** True for the first tab of this browser to claim message `id`'s sound / notification. */
function claimNotice(id: number): boolean {
try {
if (Number(localStorage.getItem(NOTIFIED_KEY) || 0) >= id) return false;
localStorage.setItem(NOTIFIED_KEY, String(id));
} catch {
// No storage: every tab plays it.
}
return true;
}
function readMuted(): Set<number> { function readMuted(): Set<number> {
try { try {
const v = JSON.parse(localStorage.getItem(MUTED_KEY) || "[]"); const v = JSON.parse(localStorage.getItem(MUTED_KEY) || "[]");
@@ -115,20 +131,27 @@ export function useChat(): ChatState {
} }
}, [enabled]); }, [enabled]);
const reloadPeople = useCallback(async () => {
if (!enabled) return;
try {
setPeople((await api.chat.people()).people);
} catch {
// Names fall back until the next read.
}
}, [enabled]);
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
void reload(); void reload();
api.chat void reloadPeople();
.people() }, [enabled, reload, reloadPeople]);
.then((r) => setPeople(r.people))
.catch(() => undefined);
}, [enabled, reload]);
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
const off = eventBus.subscribe((msg: WSMessage) => { const off = eventBus.subscribe((msg: WSMessage) => {
if (msg.type === "chat_room") { if (msg.type === "chat_room") {
void reload(); void reload();
void reloadPeople();
return; return;
} }
if (msg.type === "chat_read") { if (msg.type === "chat_read") {
@@ -168,7 +191,8 @@ export function useChat(): ChatState {
...r, ...r,
lastMessage: m, lastMessage: m,
lastMessageAt: m.createdAt, lastMessageAt: m.createdAt,
unread: own ? 0 : r.unread + 1, // A group change (system line) is not a message to read.
unread: own ? 0 : m.kind === "text" ? r.unread + 1 : r.unread,
lastReadId: own ? m.id : r.lastReadId, lastReadId: own ? m.id : r.lastReadId,
}; };
}); });
@@ -189,6 +213,9 @@ export function useChat(): ChatState {
const title = room && room.kind === "group" ? `${from} · ${room.name || "…"}` : from; const title = room && room.kind === "group" ? `${from} · ${room.name || "…"}` : from;
const text = preview(m); const text = preview(m);
setNotice({ key: m.id, roomId: m.roomId, title, text }); setNotice({ key: m.id, roomId: m.roomId, title, text });
// Several tabs open: only the first to see a message makes a sound or a
// system notification (message ids only grow).
if (!claimNotice(m.id)) return;
const prefs = loadNotifPrefs(); const prefs = loadNotifPrefs();
if (prefs.enabled) playSound(prefs.sound); if (prefs.enabled) playSound(prefs.sound);
if (hidden) void localNotification(title, text); if (hidden) void localNotification(title, text);
@@ -201,7 +228,7 @@ export function useChat(): ChatState {
off(); off();
offConn(); offConn();
}; };
}, [enabled, me, reload]); }, [enabled, me, reload, reloadPeople]);
const upsert = useCallback((room: ChatRoom) => { const upsert = useCallback((room: ChatRoom) => {
setRooms((prev) => [room, ...prev.filter((r) => r.id !== room.id)]); setRooms((prev) => [room, ...prev.filter((r) => r.id !== room.id)]);
@@ -271,6 +298,7 @@ export function useChat(): ChatState {
notice, notice,
dismissNotice, dismissNotice,
onDeleted, onDeleted,
reloadPeople,
muted, muted,
toggleMute, toggleMute,
}; };
+4 -1
View File
@@ -290,7 +290,10 @@
"disbandBody": "{{name}} and all its messages and files are deleted for every member. This cannot be undone.", "disbandBody": "{{name}} and all its messages and files are deleted for every member. This cannot be undone.",
"seen": "Seen", "seen": "Seen",
"seenAll": "Seen by everyone", "seenAll": "Seen by everyone",
"seenBy": "Seen by {{names}}" "seenBy": "Seen by {{names}}",
"deletedUser": "Deleted user",
"dock": "Chats",
"dockUnread": "Chats, {{n}} unread"
} }
}, },
"pricing": { "pricing": {
+4 -1
View File
@@ -290,7 +290,10 @@
"disbandBody": "{{name}} cùng toàn bộ tin nhắn và file sẽ bị xoá với mọi thành viên. Không thể hoàn tác.", "disbandBody": "{{name}} cùng toàn bộ tin nhắn và file sẽ bị xoá với mọi thành viên. Không thể hoàn tác.",
"seen": "Đã xem", "seen": "Đã xem",
"seenAll": "Mọi người đã xem", "seenAll": "Mọi người đã xem",
"seenBy": "{{names}} đã xem" "seenBy": "{{names}} đã xem",
"deletedUser": "Người dùng đã xoá",
"dock": "Trò chuyện",
"dockUnread": "Trò chuyện, {{n}} tin chưa đọc"
} }
}, },
"pricing": { "pricing": {
+2
View File
@@ -553,6 +553,8 @@ export type ChatPerson = {
username: string; username: string;
displayName: string | null; displayName: string | null;
avatarAt: string | null; avatarAt: string | null;
/** A disabled account: named in old chats, never offered for a new one. */
disabled?: boolean;
}; };
/** A file of a chat message (`GET /api/chat/rooms/:id/files/:fileId`). */ /** A file of a chat message (`GET /api/chat/rooms/:id/files/:fileId`). */
export type ChatFile = { id: string; name: string; type: string; size: number }; export type ChatFile = { id: string; name: string; type: string; size: number };
+2 -2
View File
@@ -3502,7 +3502,7 @@ A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles s
The pet's window has a rail of chats beside the chat with the pet: direct chats (one per pair of users) and groups. Only a room's MEMBERS read or write it — an admin or analyst included; another user's room answers `404 ENOTFOUND` like a missing one. Local mode answers `400 ELOCALMODE`. Code: `server/lib/chat.js`, `server/routes/chat.js`, client `client/src/components/Sagi/ChatHub.tsx` + `useChat.ts`. The pet's window has a rail of chats beside the chat with the pet: direct chats (one per pair of users) and groups. Only a room's MEMBERS read or write it — an admin or analyst included; another user's room answers `404 ENOTFOUND` like a missing one. Local mode answers `400 ELOCALMODE`. Code: `server/lib/chat.js`, `server/routes/chat.js`, client `client/src/components/Sagi/ChatHub.tsx` + `useChat.ts`.
``` ```
GET /api/chat/people {people: [{id, username, displayName, avatarAt}]} — every enabled account but you GET /api/chat/people {people: [{id, username, displayName, avatarAt, disabled}]} — every account but you; a disabled one is named in old chats, never offered for a new one
GET /api/chat/rooms {rooms} — yours, newest first: members, lastMessage, lastReadId, clearedId, reads (every member's read mark — "Seen"), unread GET /api/chat/rooms {rooms} — yours, newest first: members, lastMessage, lastReadId, clearedId, reads (every member's read mark — "Seen"), unread
POST /api/chat/rooms {kind: "dm", userId} → the pair's one direct chat (201 new / 200 existing); {kind: "group", name?, userIds} → 201 POST /api/chat/rooms {kind: "dm", userId} → the pair's one direct chat (201 new / 200 existing); {kind: "group", name?, userIds} → 201
PATCH /api/chat/rooms/:id {name?, add?, remove?} — the group's creator only (403 EFORBIDDEN) PATCH /api/chat/rooms/:id {name?, add?, remove?} — the group's creator only (403 EFORBIDDEN)
@@ -3516,7 +3516,7 @@ POST /api/chat/rooms/:id/read {upTo} — read mark, never move
GET /api/chat/rooms/:id/files/:fileId The file, members only, `sandbox` CSP + `nosniff`; PNG/JPEG/GIF/WebP inline, else attachment GET /api/chat/rooms/:id/files/:fileId The file, members only, `sandbox` CSP + `nosniff`; PNG/JPEG/GIF/WebP inline, else attachment
``` ```
Files are stored under `<data dir>/chat/<room id>/<32 hex id>` — a name the server makes. Right-click menus in the window: a room (open, mark read, mute notifications — per browser —, copy name, group settings, leave), the rail's pet and **+** (mark all read), a message (copy text, reply, message the sender privately, delete your own), a file (open, download, copy name) and a group member (message privately, remove — the creator). **Deleting a chat** is per person: the messages so far vanish for the caller only (`chat_members.cleared_id`) and a direct chat leaves their list (`hidden`) until a new message arrives; the others keep theirs. What EVERY member has deleted is deleted for real — the messages up to the lowest `cleared_id` and their files on disk, and a direct chat both people deleted, whole (`purge`, also run when a member leaves or is removed). **Seen**: the room shows under your newest message "Seen" (direct chat), "Seen by …" or "Seen by everyone" (group), from `reads`. A group change adds a `system` message (`created`, `renamed`, `left`, `added:<ids>`, `removed:<ids>`). Every change is sent over `/ws` as `chat_message` (`{roomId, message, to}`), `chat_room` (`{roomId, to}`) or `chat_deleted` (`{roomId, messageId, to}`) or `chat_read` (`{roomId, userId, lastReadId, to}`) to exactly the user ids in `to` (`scope.exactAudience`), whatever their role. Files are stored under `<data dir>/chat/<room id>/<32 hex id>` — a name the server makes. Right-click menus in the window: a room (open, mark read, mute notifications — per browser —, copy name, group settings, leave), the rail's pet and **+** (mark all read), a message (copy text, reply, message the sender privately, delete your own), a file (open, download, copy name) and a group member (message privately, remove — the creator). **Deleting a chat** is per person: the messages so far vanish for the caller only (`chat_members.cleared_id`) and a direct chat leaves their list (`hidden`) until a new message arrives; the others keep theirs. What EVERY member has deleted is deleted for real — the messages up to the lowest `cleared_id` and their files on disk, and a direct chat both people deleted, whole (`purge`, also run when a member leaves or is removed). A group whose creator left or was deleted passes to its longest-standing member. Group changes (`system` lines) are sent live as `chat_message` and never count as unread. An upload is written to `<data dir>/chat/.incoming/` and moved into place — never held in memory whole. In the browser, only the first tab to see a message plays its sound or shows a system notification; a message from someone in a room not on screen shows as a card on the pet. With the pet hidden, a 💬 button in the bottom-right corner (`ChatDock.tsx`) opens the same chat window. **Seen**: the room shows under your newest message "Seen" (direct chat), "Seen by …" or "Seen by everyone" (group), from `reads`. A group change adds a `system` message (`created`, `renamed`, `left`, `added:<ids>`, `removed:<ids>`). Every change is sent over `/ws` as `chat_message` (`{roomId, message, to}`), `chat_room` (`{roomId, to}`) or `chat_deleted` (`{roomId, messageId, to}`) or `chat_read` (`{roomId, userId, lastReadId, to}`) to exactly the user ids in `to` (`scope.exactAudience`), whatever their role.
### Session snapshots ### Session snapshots
+35
View File
@@ -81,6 +81,7 @@ describe("chat", () => {
it("lists the other enabled users to chat with", async () => { it("lists the other enabled users to chat with", async () => {
const r = await call(2, "GET", "/people"); const r = await call(2, "GET", "/people");
assert.deepEqual(r.body.people.map((p) => p.username).sort(), ["binh", "boss", "chi"]); 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 () => { 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]); 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 () => { it("refuses an empty message", async () => {
assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400); assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400);
}); });
+78 -21
View File
@@ -15,7 +15,11 @@
* both people deleted, whole. "Seen": each room carries every member's read * both people deleted, whole. "Seen": each room carries every member's read
* mark (`reads`), and a read that moves is told to the members (`chat_read`) — * mark (`reads`), and a read that moves is told to the members (`chat_read`) —
* a mark never passes the room's newest message, so it cannot claim to have * a mark never passes the room's newest message, so it cannot claim to have
* seen one not yet written. A message is text (≤ 8000 chars) and up to * seen one not yet written. A group whose creator left or was deleted passes
* to its longest-standing member (`ensureOwner`). Group changes add `system`
* lines that are sent live like messages and never count as unread. An
* upload arrives on disk (`<data dir>/chat/.incoming/`) and is renamed into
* place — never held in memory whole. A message is text (≤ 8000 chars) and up to
* `MAX_FILES` files of ≤ `MAX_FILE_BYTES` each, stored once under * `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 * `<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 * from the request — and served back only to the room's members (the route
@@ -58,11 +62,11 @@ function activeUser(id) {
return sql("SELECT id FROM users WHERE id = ? AND COALESCE(disabled, 0) = 0").get(n) || null; return sql("SELECT id FROM users WHERE id = ? AND COALESCE(disabled, 0) = 0").get(n) || null;
} }
/** Every enabled account but the caller — who a chat can be started with. */ /** Every account but the caller, `disabled` marked — names for old chats, the enabled ones to start a new one with. */
function people(me) { function people(me) {
return sql( return sql(
`SELECT id, username, display_name, avatar_at FROM users `SELECT id, username, display_name, avatar_at, COALESCE(disabled, 0) AS disabled FROM users
WHERE COALESCE(disabled, 0) = 0 AND id != ? ORDER BY COALESCE(display_name, username)` WHERE id != ? ORDER BY COALESCE(display_name, username)`
) )
.all(me) .all(me)
.map((u) => ({ .map((u) => ({
@@ -70,6 +74,8 @@ function people(me) {
username: u.username, username: u.username,
displayName: u.display_name ?? null, displayName: u.display_name ?? null,
avatarAt: u.avatar_at ?? null, avatarAt: u.avatar_at ?? null,
// Still named in old chats; never offered for a new one.
disabled: Boolean(u.disabled),
})); }));
} }
@@ -90,7 +96,7 @@ function roomFor(me, roomId) {
).get(id, me) ).get(id, me)
: null; : null;
if (!row) throw err("ENOTFOUND", "no such chat", 404); if (!row) throw err("ENOTFOUND", "no such chat", 404);
return row; return ensureOwner(row);
} }
function filesOf(messageIds) { function filesOf(messageIds) {
@@ -120,8 +126,27 @@ function shapeMessages(rows) {
})); }));
} }
/**
* A group's creator who left or whose account was deleted hands it to the
* longest-standing member, so somebody can still manage it. Answers the room.
*/
function ensureOwner(r) {
if (r.kind !== "group") return r;
const holds =
r.created_by != null &&
sql("SELECT 1 FROM chat_members WHERE room_id = ? AND user_id = ?").get(r.id, r.created_by);
if (holds) return r;
const next = sql(
"SELECT user_id FROM chat_members WHERE room_id = ? ORDER BY joined_at, rowid LIMIT 1"
).get(r.id);
if (!next) return r;
sql("UPDATE chat_rooms SET created_by = ? WHERE id = ?").run(next.user_id, r.id);
return { ...r, created_by: next.user_id };
}
/** One room as `me` sees it: members, newest message, unread count. */ /** One room as `me` sees it: members, newest message, unread count. */
function shapeRoom(me, r) { function shapeRoom(me, r) {
r = ensureOwner(r);
const mine = sql( const mine = sql(
"SELECT last_read_id, cleared_id FROM chat_members WHERE room_id = ? AND user_id = ?" "SELECT last_read_id, cleared_id FROM chat_members WHERE room_id = ? AND user_id = ?"
).get(r.id, me); ).get(r.id, me);
@@ -130,7 +155,7 @@ function shapeRoom(me, r) {
"SELECT * FROM chat_messages WHERE room_id = ? AND id > ? ORDER BY id DESC LIMIT 1" "SELECT * FROM chat_messages WHERE room_id = ? AND id > ? ORDER BY id DESC LIMIT 1"
).get(r.id, cleared); ).get(r.id, cleared);
const unread = sql( const unread = sql(
"SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ? AND id > ? AND COALESCE(user_id, -1) != ?" "SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ? AND id > ? AND kind = 'text' AND COALESCE(user_id, -1) != ?"
).get(r.id, Math.max(mine ? mine.last_read_id : 0, cleared), me).n; ).get(r.id, Math.max(mine ? mine.last_read_id : 0, cleared), me).n;
return { return {
id: r.id, id: r.id,
@@ -204,15 +229,25 @@ function openDm(me, other) {
}; };
} }
/** Adds a `system` line; answers its id (the caller sends it live). */
function systemLine(roomId, me, body) { function systemLine(roomId, me, body) {
sql("INSERT INTO chat_messages (room_id, user_id, kind, body) VALUES (?, ?, 'system', ?)").run( const { lastInsertRowid } = sql(
roomId, "INSERT INTO chat_messages (room_id, user_id, kind, body) VALUES (?, ?, 'system', ?)"
me, ).run(roomId, me, body);
body
);
sql( sql(
"UPDATE chat_rooms SET last_message_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?" "UPDATE chat_rooms SET last_message_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
).run(roomId); ).run(roomId);
return lastInsertRowid;
}
/** The messages of these ids, shaped, to send live. */
function linesOf(ids) {
if (!ids.length) return [];
return shapeMessages(
sql(
`SELECT * FROM chat_messages WHERE id IN (${ids.map(() => "?").join(",")}) ORDER BY id`
).all(...ids)
);
} }
/** A new group of `me` plus `userIds`. */ /** A new group of `me` plus `userIds`. */
@@ -243,26 +278,28 @@ function updateGroup(me, roomId, { name, add, remove }) {
const before = memberIds(room.id); const before = memberIds(room.id);
const adds = userList(add, "add").filter((id) => !before.includes(id)); const adds = userList(add, "add").filter((id) => !before.includes(id));
const drops = userList(remove, "remove").filter((id) => id !== me && before.includes(id)); const drops = userList(remove, "remove").filter((id) => id !== me && before.includes(id));
const ids = [];
db().transaction(() => { db().transaction(() => {
if (name !== undefined) { if (name !== undefined) {
const title = cleanName(name); const title = cleanName(name);
if (title !== room.name) { if (title !== room.name) {
sql("UPDATE chat_rooms SET name = ? WHERE id = ?").run(title, room.id); sql("UPDATE chat_rooms SET name = ? WHERE id = ?").run(title, room.id);
systemLine(room.id, me, "renamed"); ids.push(systemLine(room.id, me, "renamed"));
} }
} }
const ins = sql("INSERT OR IGNORE INTO chat_members (room_id, user_id) VALUES (?, ?)"); const ins = sql("INSERT OR IGNORE INTO chat_members (room_id, user_id) VALUES (?, ?)");
for (const uid of adds) ins.run(room.id, uid); for (const uid of adds) ins.run(room.id, uid);
const del = sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?"); const del = sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?");
for (const uid of drops) del.run(room.id, uid); for (const uid of drops) del.run(room.id, uid);
if (adds.length) systemLine(room.id, me, `added:${adds.join(",")}`); if (adds.length) ids.push(systemLine(room.id, me, `added:${adds.join(",")}`));
if (drops.length) systemLine(room.id, me, `removed:${drops.join(",")}`); if (drops.length) ids.push(systemLine(room.id, me, `removed:${drops.join(",")}`));
})(); })();
// A removed member's mark no longer holds history back. // A removed member's mark no longer holds history back.
if (drops.length) purge(room.id); if (drops.length) purge(room.id);
return { return {
room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id)), room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id)),
notify: [...new Set([...before, ...adds])], notify: [...new Set([...before, ...adds])],
lines: linesOf(ids),
}; };
} }
@@ -271,12 +308,15 @@ function leaveGroup(me, roomId) {
const room = roomFor(me, roomId); const room = roomFor(me, roomId);
if (room.kind !== "group") throw err("EBADFIELD", "a direct chat cannot be left"); if (room.kind !== "group") throw err("EBADFIELD", "a direct chat cannot be left");
const before = memberIds(room.id); const before = memberIds(room.id);
let line = null;
db().transaction(() => { db().transaction(() => {
sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?").run(room.id, me); sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?").run(room.id, me);
if (before.length > 1) systemLine(room.id, me, "left"); if (before.length > 1) line = systemLine(room.id, me, "left");
})(); })();
purge(room.id); const gone = purge(room.id);
return { notify: before }; const rest = sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id);
if (rest) ensureOwner(rest);
return { notify: before, lines: gone || line == null ? [] : linesOf([line]) };
} }
/** A page of messages, oldest first: the newest `limit`, or those before `before`. */ /** A page of messages, oldest first: the newest `limit`, or those before `before`. */
@@ -322,19 +362,29 @@ function post(me, roomId, { body, files = [] }) {
if (text.length > MAX_BODY) if (text.length > MAX_BODY)
throw err("ETOOBIG", `a message is at most ${MAX_BODY} characters`, 413); throw err("ETOOBIG", `a message is at most ${MAX_BODY} characters`, 413);
if (files.length > MAX_FILES) throw err("ETOOBIG", `at most ${MAX_FILES} files`, 413); if (files.length > MAX_FILES) throw err("ETOOBIG", `at most ${MAX_FILES} files`, 413);
const sizeOf = (f) => (f.path ? fs.statSync(f.path).size : f.buffer.length);
for (const f of files) for (const f of files)
if (f.buffer.length > MAX_FILE_BYTES) throw err("ETOOBIG", "a file is at most 25 MB", 413); if (sizeOf(f) > MAX_FILE_BYTES) throw err("ETOOBIG", "a file is at most 25 MB", 413);
if (!text.trim() && !files.length) throw err("EBADFIELD", "an empty message"); if (!text.trim() && !files.length) throw err("EBADFIELD", "an empty message");
const dir = fileDir(room.id); const dir = fileDir(room.id);
const stored = files.map((f) => ({ const stored = files.map((f) => ({
id: crypto.randomBytes(16).toString("hex"), id: crypto.randomBytes(16).toString("hex"),
name: cleanFileName(f.name), name: cleanFileName(f.name),
type: cleanType(f.type), type: cleanType(f.type),
size: sizeOf(f),
from: f.path || null,
buffer: f.buffer, buffer: f.buffer,
})); }));
if (stored.length) { if (stored.length) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
for (const f of stored) fs.writeFileSync(path.join(dir, f.id), f.buffer, { mode: 0o600 }); for (const f of stored) {
const dest = path.join(dir, f.id);
// An upload already on disk (multer) is moved, never read into memory.
if (f.from) {
fs.renameSync(f.from, dest);
fs.chmodSync(dest, 0o600);
} else fs.writeFileSync(dest, f.buffer, { mode: 0o600 });
}
} }
let id; let id;
try { try {
@@ -345,8 +395,7 @@ function post(me, roomId, { body, files = [] }) {
const ins = sql( const ins = sql(
"INSERT INTO chat_files (id, message_id, room_id, name, type, size) VALUES (?, ?, ?, ?, ?, ?)" "INSERT INTO chat_files (id, message_id, room_id, name, type, size) VALUES (?, ?, ?, ?, ?, ?)"
); );
for (const f of stored) for (const f of stored) ins.run(f.id, lastInsertRowid, room.id, f.name, f.type, f.size);
ins.run(f.id, lastInsertRowid, room.id, f.name, f.type, f.buffer.length);
sql( sql(
"UPDATE chat_rooms SET last_message_at = (SELECT created_at FROM chat_messages WHERE id = ?) WHERE id = ?" "UPDATE chat_rooms SET last_message_at = (SELECT created_at FROM chat_messages WHERE id = ?) WHERE id = ?"
).run(lastInsertRowid, room.id); ).run(lastInsertRowid, room.id);
@@ -488,7 +537,15 @@ function file(me, roomId, fileId) {
return { path: p, name: f.name, type: f.type, inline: INLINE_TYPES.has(f.type) }; return { path: p, name: f.name, type: f.type, inline: INLINE_TYPES.has(f.type) };
} }
/** Where multer writes an upload before `post` moves it into its room. */
function incomingDir() {
const d = path.join(chatDir(), ".incoming");
fs.mkdirSync(d, { recursive: true, mode: 0o700 });
return d;
}
module.exports = { module.exports = {
incomingDir,
MAX_FILES, MAX_FILES,
MAX_FILE_BYTES, MAX_FILE_BYTES,
people, people,
+6 -1
View File
@@ -83,7 +83,8 @@ const paths = {
get: { get: {
tags: ["Chat"], tags: ["Chat"],
summary: "Users the caller can chat with", summary: "Users the caller can chat with",
description: "Every enabled account but the caller: id, username, display name, avatar time.", description:
"Every account but the caller: id, username, display name, avatar time, and `disabled` (not offered for a new chat).",
responses: { responses: {
200: { 200: {
description: "People", description: "People",
@@ -99,6 +100,10 @@ const paths = {
username: { type: "string" }, username: { type: "string" },
displayName: { type: "string", nullable: true }, displayName: { type: "string", nullable: true },
avatarAt: { type: "string", nullable: true }, avatarAt: { type: "string", nullable: true },
disabled: {
type: "boolean",
description: "A disabled account: still named in old chats, never offered for a new one.",
},
}, },
}, },
}, },
+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) { function upload(req, res, next) {
if (!req.is("multipart/form-data")) return next(); if (!req.is("multipart/form-data")) return next();
const multer = require("multer"); const multer = require("multer");
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 }, limits: { fileSize: chat.MAX_FILE_BYTES, files: chat.MAX_FILES },
}).array("files")(req, res, (e) => { }).array("files")(req, res, (e) => {
if (!e) return next(); if (!e) return next();
@@ -98,12 +103,14 @@ router.patch(
sameOriginGuard, sameOriginGuard,
handle((me, req, res) => { handle((me, req, res) => {
const b = req.body || {}; 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, name: b.name,
add: b.add, add: b.add,
remove: b.remove, remove: b.remove,
}); });
tell("chat_room", { roomId: room.id }, notify); 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 }); res.json({ room });
}) })
); );
@@ -112,8 +119,11 @@ router.post(
"/rooms/:id/leave", "/rooms/:id/leave",
sameOriginGuard, sameOriginGuard,
handle((me, req, res) => { 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); 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 }); 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. // multer reads names as latin1; browsers send UTF-8.
name: Buffer.from(f.originalname, "latin1").toString("utf8"), name: Buffer.from(f.originalname, "latin1").toString("utf8"),
type: f.mimetype, 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); tell("chat_message", { roomId: message.roomId, message }, notify);
res.status(201).json({ message }); res.status(201).json({ message });
})(req, res) })(req, res)