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
+1 -1
View File
@@ -65,7 +65,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. Billing has one more owner: a session NO workspace owns belongs to the user of the Linux account it ran as (`sessions.account` — the hook's peer, else its folder; `server/lib/session-account.js`, the host's account → the admin marked `users.host_account`, else the only admin), so a script's `claude -p` counts in Today, the Users page and spend quotas — a workspace's ownership always wins over it.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
- **A chat between users is its members' alone.** `server/lib/chat.js` (`/api/chat`, server mode only): every read and write resolves the room through `roomFor(caller, id)` — not a member is `404`, an admin included, and there is no route listing another user's rooms; `chat_message`/`chat_room` frames go only to the ids in `to` (`exactAudience`). Files are stored under a server-made name in `<data dir>/chat/<room>/` and served only to members under a `sandbox` CSP + `nosniff` (raster images inline, anything else an attachment). Do not add an admin view of chats.
- **A chat between users is its members' alone.** `server/lib/chat.js` (`/api/chat`, server mode only): every read and write resolves the room through `roomFor(caller, id)` — not a member is `404`, an admin included, and there is no route listing another user's rooms; `chat_message`/`chat_room`/`chat_deleted` frames go only to the ids in `to` (`exactAudience`); a message is deleted only by its author. Files are stored under a server-made name in `<data dir>/chat/<room>/` and served only to members under a `sandbox` CSP + `nosniff` (raster images inline, anything else an attachment). Do not add an admin view of chats.
- **A web page is previewed by URL in the session panel's Preview tab, never on the dashboard's origin, and every link follows ONE rule.** `client/src/components/lanes/UrlPreview.tsx` shows an `<iframe src>` whose sandbox has no `allow-top-navigation*`; `normalizePreviewUrl` (`client/src/lib/openInPreview.ts`) takes http(s) only, no userinfo, and refuses the dashboard's own origin (with `allow-same-origin` that frame would BE the dashboard). A chat link, bare URL or terminal link to `localhost`/`127.0.0.1`/`0.0.0.0`/`[::1]` opens there only while a session panel answers the event and the dashboard itself is on loopback; anything else — and any Ctrl/Cmd click — opens a browser tab. Do not add a second preview surface or a per-place button, and do not proxy a page through the dashboard's origin.
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`, shown in a modal — `HtmlViewerModal.tsx`, Chat's `FilePreview modal`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
+288 -18
View File
@@ -29,7 +29,15 @@ import {
Users,
X,
} from "lucide-react";
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { copyText } from "../../lib/clipboard";
import {
api,
type ChatFile,
type ChatMessage,
type ChatPerson,
type ChatRoom,
} from "../../lib/api";
import { formatBytes, formatTime } from "../../lib/format";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
@@ -75,6 +83,48 @@ function roomTitle(room: ChatRoom, me: number | null, dir: Map<number, ChatPerso
.join(", ");
}
/** Leave a group, after asking. True when left. */
async function leaveRoom(
chat: ChatState,
room: ChatRoom,
title: string,
t: (k: string, o?: Record<string, unknown>) => string
): Promise<boolean> {
const ok = await confirmDialog({
title: t("sagi.people.leaveTitle"),
message: t("sagi.people.leaveBody", { name: title }),
confirmLabel: t("sagi.people.leave"),
});
if (!ok) return false;
try {
await api.chat.leave(room.id);
await chat.reload();
return true;
} catch (e) {
toast.error(e);
return false;
}
}
/** Every message of a room read, here and on the server. */
function readAll(chat: ChatState, room: ChatRoom) {
const upTo = room.lastMessage?.id;
if (!upTo) return;
chat.markRead(room.id, upTo);
api.chat.read(room.id, upTo).catch((e) => toast.error(e));
}
/** Open (or make) the direct chat with one person. */
async function openDirect(chat: ChatState, userId: number, onOpen: (roomId: number) => void) {
try {
const { room } = await api.chat.openDm(userId);
chat.upsert(room);
onOpen(room.id);
} catch (e) {
toast.error(e);
}
}
function RoomIcon({
room,
me,
@@ -160,15 +210,25 @@ export function ChatRail({
active,
onSelect,
petName,
onSettings,
}: {
chat: ChatState;
/** `"pet"`, `"new"` or a room id. */
active: "pet" | "new" | number;
onSelect: (v: "pet" | "new" | number) => void;
petName: string;
/** Open a group with its settings shown (its right-click menu). */
onSettings?: (roomId: number) => void;
}) {
const { t } = useTranslation("settings");
const dir = useDirectory(chat);
const ctx = useContextMenu<ChatRoom | "pet" | "new">();
const pick = (fn: () => void) => () => {
ctx.close();
fn();
};
const menuRoom = ctx.menu && typeof ctx.menu.item === "object" ? ctx.menu.item : null;
const menuTitle = menuRoom ? roomTitle(menuRoom, chat.me, dir) : "";
const ring = (on: boolean) =>
on ? "ring-2 ring-accent ring-offset-2 ring-offset-surface-2" : "opacity-80 hover:opacity-100";
return (
@@ -179,6 +239,7 @@ export function ChatRail({
<button
type="button"
onClick={() => onSelect("pet")}
onContextMenu={(e) => ctx.open(e, "pet")}
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-4 text-lg transition ${ring(active === "pet")}`}
title={petName}
aria-label={petName}
@@ -194,6 +255,7 @@ export function ChatRail({
key={r.id}
type="button"
onClick={() => onSelect(r.id)}
onContextMenu={(e) => ctx.open(e, r)}
className={`relative shrink-0 rounded-full transition ${ring(active === r.id)}`}
title={title}
aria-label={r.unread ? t("sagi.people.unreadIn", { name: title, n: r.unread }) : title}
@@ -201,18 +263,82 @@ export function ChatRail({
>
<RoomIcon room={r} me={chat.me} dir={dir} className="h-9 w-9 text-[11px]" />
<Badge n={r.unread} />
{chat.muted.has(r.id) && (
<span
className="absolute -bottom-0.5 -right-0.5 rounded-full bg-surface-2 text-[10px] leading-none"
aria-hidden
>
🔕
</span>
)}
</button>
);
})}
<button
type="button"
onClick={() => onSelect("new")}
onContextMenu={(e) => ctx.open(e, "new")}
className={`mt-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-dashed border-border text-fg-muted transition hover:text-fg-primary ${active === "new" ? "ring-2 ring-accent" : ""}`}
title={t("sagi.people.new")}
aria-label={t("sagi.people.new")}
>
<Plus size={16} />
</button>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="chat-rail-menu">
{menuRoom ? (
<>
<MenuItem onSelect={pick(() => onSelect(menuRoom.id))}>
{t("sagi.people.menuOpen")}
</MenuItem>
<MenuItem disabled={!menuRoom.unread} onSelect={pick(() => readAll(chat, menuRoom))}>
{t("sagi.people.menuRead")}
</MenuItem>
<MenuItem onSelect={pick(() => chat.toggleMute(menuRoom.id))}>
{chat.muted.has(menuRoom.id)
? t("sagi.people.menuUnmute")
: t("sagi.people.menuMute")}
</MenuItem>
<MenuItem onSelect={pick(() => void copyText(menuTitle))}>
{t("sagi.people.menuCopyName")}
</MenuItem>
{menuRoom.kind === "group" && (
<>
<MenuSeparator />
<MenuItem onSelect={pick(() => onSettings?.(menuRoom.id))}>
{t("sagi.people.settings")}
</MenuItem>
<MenuItem
danger
onSelect={pick(() => {
void leaveRoom(chat, menuRoom, menuTitle, t).then((left) => {
if (left && active === menuRoom.id) onSelect("pet");
});
})}
>
{t("sagi.people.leave")}
</MenuItem>
</>
)}
</>
) : (
<>
<MenuItem onSelect={pick(() => onSelect("pet"))}>
{t("sagi.people.menuPet", { name: petName })}
</MenuItem>
<MenuItem onSelect={pick(() => onSelect("new"))}>{t("sagi.people.new")}</MenuItem>
<MenuItem
disabled={!chat.unread}
onSelect={pick(() =>
chat.rooms.filter((r) => r.unread).forEach((r) => readAll(chat, r))
)}
>
{t("sagi.people.menuReadAll")}
</MenuItem>
</>
)}
</ContextMenu>
)}
</nav>
);
}
@@ -422,13 +548,27 @@ function SystemLine({ m, dir }: { m: ChatMessage; dir: Map<number, ChatPerson> }
return <p className="py-1 text-center text-[11px] text-fg-muted">{text}</p>;
}
function Files({ m, own }: { m: ChatMessage; own: boolean }) {
function Files({
m,
own,
onMenu,
}: {
m: ChatMessage;
own: boolean;
onMenu: (e: React.MouseEvent, f: ChatFile) => void;
}) {
return (
<div className="mt-1 flex flex-col gap-1">
{m.files.map((f) => {
const url = api.chat.fileUrl(m.roomId, f.id);
return f.type.startsWith("image/") && /png|jpeg|gif|webp/.test(f.type) ? (
<a key={f.id} href={url} target="_blank" rel="noopener noreferrer">
<a
key={f.id}
href={url}
target="_blank"
rel="noopener noreferrer"
onContextMenu={(e) => onMenu(e, f)}
>
<img
src={url}
alt={f.name}
@@ -441,6 +581,7 @@ function Files({ m, own }: { m: ChatMessage; own: boolean }) {
key={f.id}
href={url}
download={f.name}
onContextMenu={(e) => onMenu(e, f)}
className={`flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs ${own ? "bg-white/15" : "bg-surface-4"}`}
>
<FileText size={14} className="shrink-0" aria-hidden />
@@ -460,15 +601,22 @@ function GroupSettings({
dir,
onBack,
onLeft,
onOpenRoom,
}: {
chat: ChatState;
room: ChatRoom;
dir: Map<number, ChatPerson>;
onBack: () => void;
onLeft: () => void;
onOpenRoom: (roomId: number) => void;
}) {
const { t } = useTranslation("settings");
const owner = room.createdBy === chat.me;
const ctx = useContextMenu<number>();
const pick = (fn: () => void) => () => {
ctx.close();
fn();
};
const [name, setName] = useState(room.name);
const [adding, setAdding] = useState<Set<number>>(new Set());
const save = async (patch: { name?: string; add?: number[]; remove?: number[] }) => {
@@ -481,19 +629,7 @@ function GroupSettings({
}
};
const leave = async () => {
const ok = await confirmDialog({
title: t("sagi.people.leaveTitle"),
message: t("sagi.people.leaveBody", { name: roomTitle(room, chat.me, dir) }),
confirmLabel: t("sagi.people.leave"),
});
if (!ok) return;
try {
await api.chat.leave(room.id);
await chat.reload();
onLeft();
} catch (e) {
toast.error(e);
}
if (await leaveRoom(chat, room, roomTitle(room, chat.me, dir), t)) onLeft();
};
const outside = chat.people.filter((p) => !room.members.includes(p.id));
return (
@@ -534,7 +670,11 @@ function GroupSettings({
{room.members.map((id) => {
const p = dir.get(id);
return (
<li key={id} className="flex items-center gap-2 rounded-lg px-2 py-1">
<li
key={id}
className="flex items-center gap-2 rounded-lg px-2 py-1 hover:bg-surface-4"
onContextMenu={(e) => ctx.open(e, id)}
>
<UserAvatar
name={personName(p)}
userId={id}
@@ -600,6 +740,26 @@ function GroupSettings({
>
<LogOut size={14} /> {t("sagi.people.leave")}
</button>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="chat-member-menu">
{ctx.menu.item !== chat.me && (
<MenuItem onSelect={pick(() => void openDirect(chat, ctx.menu!.item, onOpenRoom))}>
{t("sagi.people.menuDm", { name: personName(dir.get(ctx.menu.item)) })}
</MenuItem>
)}
<MenuItem onSelect={pick(() => void copyText(personName(dir.get(ctx.menu!.item))))}>
{t("sagi.people.menuCopyName")}
</MenuItem>
{owner && ctx.menu.item !== chat.me && (
<>
<MenuSeparator />
<MenuItem danger onSelect={pick(() => void save({ remove: [ctx.menu!.item] }))}>
{t("sagi.people.remove", { name: personName(dir.get(ctx.menu.item)) })}
</MenuItem>
</>
)}
</ContextMenu>
)}
</div>
);
}
@@ -610,21 +770,61 @@ export function ChatRoomView({
room,
onClose,
onLeft,
onOpenRoom,
showSettings,
}: {
chat: ChatState;
room: ChatRoom;
onClose: () => void;
onLeft: () => void;
/** Switch the window to another room (a direct chat opened from a menu). */
onOpenRoom: (roomId: number) => void;
/** Changes when the rail's menu asks for this group's settings. */
showSettings?: number;
}) {
const { t } = useTranslation("settings");
const dir = useDirectory(chat);
const title = roomTitle(room, chat.me, dir);
const ctx = useContextMenu<{ m: ChatMessage; file?: ChatFile }>();
const pick = (fn: () => void) => () => {
ctx.close();
fn();
};
const textRef = useRef<HTMLTextAreaElement>(null);
const reply = (m: ChatMessage) => {
const who = personName(m.userId != null ? dir.get(m.userId) : undefined);
const quoted = (m.body || m.files.map((f) => f.name).join(", "))
.split("\n")
.slice(0, 4)
.map((l) => `> ${l}`)
.join("\n");
setDraft((d) => `${who}:\n${quoted}\n${d}`);
textRef.current?.focus();
};
const remove = async (m: ChatMessage) => {
const ok = await confirmDialog({
title: t("sagi.people.deleteTitle"),
message: t("sagi.people.deleteBody"),
confirmLabel: t("sagi.people.menuDelete"),
});
if (!ok) return;
try {
await api.chat.deleteMessage(room.id, m.id);
setMessages((prev) => prev.filter((x) => x.id !== m.id));
} catch (e) {
toast.error(e);
}
};
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [hasMore, setHasMore] = useState(false);
const [draft, setDraft] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [sending, setSending] = useState(false);
const [settings, setSettings] = useState(false);
// The rail's menu asked for the settings (after the room switch resets them).
useEffect(() => {
if (showSettings) setSettings(true);
}, [showSettings, room.id]);
const [drag, setDrag] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
@@ -655,6 +855,9 @@ export function ChatRoomView({
if (last) read(last.id);
})
.catch((e) => toast.error(e));
chat.onDeleted((roomId, messageId) => {
if (roomId === room.id) setMessages((prev) => prev.filter((x) => x.id !== messageId));
});
chat.onMessage((m) => {
if (m.roomId !== room.id) return;
setMessages((prev) => (prev.some((x) => x.id === m.id) ? prev : [...prev, m]));
@@ -663,6 +866,7 @@ export function ChatRoomView({
return () => {
live = false;
chat.onMessage(null);
chat.onDeleted(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room.id]);
@@ -743,6 +947,7 @@ export function ChatRoomView({
dir={dir}
onBack={() => setSettings(false)}
onLeft={onLeft}
onOpenRoom={onOpenRoom}
/>
) : (
<>
@@ -802,9 +1007,12 @@ export function ChatRoomView({
: "pet-chat-reply rounded-bl-md text-fg-primary"
}`}
title={formatTime(m.createdAt)}
onContextMenu={(e) => ctx.open(e, { m })}
>
{m.body && <Linkified text={m.body} />}
{m.files.length > 0 && <Files m={m} own={own} />}
{m.files.length > 0 && (
<Files m={m} own={own} onMenu={(e, f) => ctx.open(e, { m, file: f })} />
)}
</div>
</div>
);
@@ -853,6 +1061,7 @@ export function ChatRoomView({
<Paperclip size={16} />
</button>
<textarea
ref={textRef}
rows={1}
autoFocus
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
@@ -889,6 +1098,67 @@ export function ChatRoomView({
</div>
</>
)}
{ctx.menu &&
(() => {
const { m, file } = ctx.menu.item;
const own = m.userId === chat.me;
const sender = m.userId != null ? dir.get(m.userId) : undefined;
return (
<ContextMenu
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
testId="chat-message-menu"
>
{file && (
<>
<MenuItem
onSelect={pick(() =>
window.open(
api.chat.fileUrl(m.roomId, file.id),
"_blank",
"noopener,noreferrer"
)
)}
>
{t("sagi.people.menuOpenFile")}
</MenuItem>
<MenuItem
onSelect={pick(() => {
const a = document.createElement("a");
a.href = api.chat.fileUrl(m.roomId, file.id);
a.download = file.name;
a.click();
})}
>
{t("sagi.people.menuDownload")}
</MenuItem>
<MenuItem onSelect={pick(() => void copyText(file.name))}>
{t("sagi.people.menuCopyFileName")}
</MenuItem>
<MenuSeparator />
</>
)}
<MenuItem disabled={!m.body} onSelect={pick(() => void copyText(m.body))}>
{t("sagi.people.menuCopyText")}
</MenuItem>
<MenuItem onSelect={pick(() => reply(m))}>{t("sagi.people.menuReply")}</MenuItem>
{!own && room.kind === "group" && m.userId != null && (
<MenuItem onSelect={pick(() => void openDirect(chat, m.userId!, onOpenRoom))}>
{t("sagi.people.menuDm", { name: personName(sender) })}
</MenuItem>
)}
{own && (
<>
<MenuSeparator />
<MenuItem danger onSelect={pick(() => void remove(m))}>
{t("sagi.people.menuDelete")}
</MenuItem>
</>
)}
</ContextMenu>
);
})()}
</Shell>
);
}
+14 -1
View File
@@ -246,6 +246,8 @@ function SagiBody() {
// Server mode: chat with other users beside the pet's own (ChatHub.tsx).
const people = useChat();
const [pane, setPane] = useState<"pet" | "new" | number>("pet");
// Bumped by the rail's "Group settings": the room opens on its settings.
const [settingsAsk, setSettingsAsk] = useState(0);
const room = typeof pane === "number" ? people.rooms.find((r) => r.id === pane) : undefined;
// The room on screen: no notice for its messages.
const { setActive } = people;
@@ -436,7 +438,16 @@ function SagiBody() {
<SagiFlyout anchor={anchor} beside>
<div className="flex items-start gap-2">
{people.enabled && (
<ChatRail chat={people} active={pane} onSelect={setPane} petName={name} />
<ChatRail
chat={people}
active={pane}
onSelect={setPane}
petName={name}
onSettings={(id) => {
setPane(id);
setSettingsAsk((n) => n + 1);
}}
/>
)}
{people.enabled && pane === "new" ? (
<NewChat chat={people} onOpen={setPane} onClose={() => setOpen(false)} />
@@ -446,6 +457,8 @@ function SagiBody() {
room={room}
onClose={() => setOpen(false)}
onLeft={() => setPane("pet")}
onOpenRoom={setPane}
showSettings={settingsAsk}
/>
) : (
<PetChat
@@ -5,7 +5,8 @@
* direct chat for one person and makes a group for several; a room reads its
* messages (a group names who wrote each), marks them read, appends one that
* arrives live, and sends text with the attached files; the group settings
* scroll only one way; a new message raises a notice that opens its room.
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -23,6 +24,7 @@ const api = vi.hoisted(() => ({
read: vi.fn(),
updateGroup: vi.fn(),
leave: vi.fn(),
deleteMessage: vi.fn(),
fileUrl: (r: number, f: string) => `/api/chat/rooms/${r}/files/${f}`,
},
users: { avatarUrl: () => null },
@@ -32,6 +34,10 @@ vi.mock("../../../lib/api", async (orig) => ({
...(await orig<Record<string, unknown>>()),
api,
}));
const clip = vi.hoisted(() => ({ copyText: vi.fn(async () => true) }));
vi.mock("../../../lib/clipboard", () => clip);
const confirm = vi.hoisted(() => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/confirm", () => confirm);
vi.mock("../../AuthGate", () => ({
useAuth: () => ({
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } },
@@ -80,6 +86,9 @@ function state(over: Partial<ChatState> = {}): ChatState {
setActive: vi.fn(),
notice: null,
dismissNotice: vi.fn(),
onDeleted: vi.fn(),
muted: new Set<number>(),
toggleMute: vi.fn(),
...over,
};
}
@@ -121,7 +130,15 @@ describe("ChatHub", () => {
hasMore: false,
});
api.chat.read.mockResolvedValue({ lastReadId: 2 });
render(<ChatRoomView chat={chat} room={group} onClose={() => {}} onLeft={() => {}} />);
render(
<ChatRoomView
chat={chat}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={() => {}}
/>
);
expect(await screen.findByText("chào")).toBeTruthy();
expect(screen.getByText("An")).toBeTruthy(); // sender of a group message
expect(api.chat.read).toHaveBeenCalledWith(7, 2);
@@ -146,7 +163,15 @@ describe("ChatHub", () => {
it("keeps the group settings to one vertical scroll, the name field shrinking", async () => {
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
render(<ChatRoomView chat={state()} room={group} onClose={() => {}} onLeft={() => {}} />);
render(
<ChatRoomView
chat={state()}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={() => {}}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Group settings" }));
const name = screen.getAllByLabelText("Group name (optional)")[0]!;
expect(name.className).toContain("min-w-0");
@@ -171,4 +196,116 @@ describe("ChatHub", () => {
fireEvent.click(screen.getByRole("button", { name: "Open the chat with An · Team" }));
expect(onOpen).toHaveBeenCalledWith(7);
});
it("right-click on a room: mark read, mute, copy its name, settings and leave", async () => {
const chat = state({ rooms: [{ ...group, lastMessage: msg(5, 2, "x") }, dm] });
const onSelect = vi.fn();
const onSettings = vi.fn();
api.chat.read.mockResolvedValue({ lastReadId: 5 });
api.chat.leave.mockResolvedValue({ ok: true });
render(
<ChatRail chat={chat} active={7} onSelect={onSelect} petName="Sagi" onSettings={onSettings} />
);
const open = () =>
fireEvent.contextMenu(screen.getByRole("button", { name: "Team, 2 unread" }));
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Mark as read" }));
expect(chat.markRead).toHaveBeenCalledWith(7, 5);
expect(api.chat.read).toHaveBeenCalledWith(7, 5);
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Mute notifications" }));
expect(chat.toggleMute).toHaveBeenCalledWith(7);
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Copy name" }));
expect(clip.copyText).toHaveBeenCalledWith("Team");
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Group settings" }));
expect(onSettings).toHaveBeenCalledWith(7);
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Leave group" }));
await waitFor(() => expect(api.chat.leave).toHaveBeenCalledWith(7));
await waitFor(() => expect(onSelect).toHaveBeenCalledWith("pet"));
// A direct chat has no group items.
fireEvent.contextMenu(screen.getByRole("button", { name: "An" }));
expect(screen.queryByRole("menuitem", { name: "Leave group" })).toBeNull();
});
it("right-click on a message: copy, reply, message the sender, delete your own", async () => {
const chat = state();
const onOpenRoom = vi.fn();
api.chat.messages.mockResolvedValue({
messages: [
msg(1, 2, "của An"),
{
...msg(2, 1, "của tôi"),
files: [{ id: "f1", name: "a.pdf", type: "application/pdf", size: 9 }],
},
],
hasMore: false,
});
api.chat.read.mockResolvedValue({ lastReadId: 2 });
api.chat.openDm.mockResolvedValue({ room: dm });
api.chat.deleteMessage.mockResolvedValue({ ok: true });
render(
<ChatRoomView
chat={chat}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={onOpenRoom}
/>
);
fireEvent.contextMenu(await screen.findByText("của An"));
expect(screen.queryByRole("menuitem", { name: "Delete message" })).toBeNull();
fireEvent.click(screen.getByRole("menuitem", { name: "Copy text" }));
expect(clip.copyText).toHaveBeenCalledWith("của An");
fireEvent.contextMenu(screen.getByText("của An"));
fireEvent.click(screen.getByRole("menuitem", { name: "Reply" }));
expect((screen.getByLabelText("Message Team") as HTMLTextAreaElement).value).toBe(
"An:\n> của An\n"
);
fireEvent.contextMenu(screen.getByText("của An"));
fireEvent.click(screen.getByRole("menuitem", { name: "Message An privately" }));
await waitFor(() => expect(onOpenRoom).toHaveBeenCalledWith(8));
// A file has its own items.
fireEvent.contextMenu(screen.getByText("a.pdf"));
expect(screen.getByRole("menuitem", { name: "Download" })).toBeTruthy();
fireEvent.click(screen.getByRole("menuitem", { name: "Copy file name" }));
expect(clip.copyText).toHaveBeenCalledWith("a.pdf");
fireEvent.contextMenu(screen.getByText("của tôi"));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete message" }));
await waitFor(() => expect(api.chat.deleteMessage).toHaveBeenCalledWith(7, 2));
await waitFor(() => expect(screen.queryByText("của tôi")).toBeNull());
});
it("right-click on a group member: message them; the creator may remove them", async () => {
const onOpenRoom = vi.fn();
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
api.chat.openDm.mockResolvedValue({ room: dm });
api.chat.updateGroup.mockResolvedValue({ room: { ...group, members: [1, 3] } });
render(
<ChatRoomView
chat={state()}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={onOpenRoom}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Group settings" }));
const row = screen
.getAllByText("An")
.find((el) => el.closest("li"))!
.closest("li")!;
fireEvent.contextMenu(row);
fireEvent.click(screen.getByRole("menuitem", { name: "Remove An" }));
await waitFor(() => expect(api.chat.updateGroup).toHaveBeenCalledWith(7, { remove: [2] }));
fireEvent.contextMenu(row);
fireEvent.click(screen.getByRole("menuitem", { name: "Message An privately" }));
await waitFor(() => expect(onOpenRoom).toHaveBeenCalledWith(8));
});
});
+49 -1
View File
@@ -11,7 +11,10 @@
* room), plays the user's notification sound when notifications are on, and —
* while the tab is hidden and the browser allows it — shows a local system
* notification (never through the server's push relay, which would reach
* other users' devices). Off in local mode — one person has no one to chat with.
* other users' devices). A room can be muted (no notice, no sound — its
* 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
* list. Off in local mode — one person has no one to chat with.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -44,8 +47,23 @@ export type ChatState = {
setActive: (roomId: number | null) => void;
notice: ChatNotice | null;
dismissNotice: () => void;
/** The open room's view, told when a message of it is deleted. */
onDeleted: (fn: ((roomId: number, messageId: number) => void) | null) => void;
/** Rooms whose new messages raise no notice or sound. */
muted: Set<number>;
toggleMute: (roomId: number) => void;
};
const MUTED_KEY = "agenthub.chat.muted";
function readMuted(): Set<number> {
try {
const v = JSON.parse(localStorage.getItem(MUTED_KEY) || "[]");
return new Set(Array.isArray(v) ? v.filter((x) => Number.isInteger(x)) : []);
} catch {
return new Set();
}
}
/** What a notice says of a message: its text, else its files. */
function preview(m: ChatMessage): string {
const text = m.body.trim().replace(/\s+/g, " ");
@@ -78,6 +96,10 @@ export function useChat(): ChatState {
const [people, setPeople] = useState<ChatPerson[]>([]);
const listener = useRef<((m: ChatMessage) => void) | null>(null);
const active = useRef<number | null>(null);
const deletedListener = useRef<((roomId: number, messageId: number) => void) | null>(null);
const [muted, setMuted] = useState<Set<number>>(readMuted);
const mutedRef = useRef(muted);
mutedRef.current = muted;
const [notice, setNotice] = useState<ChatNotice | null>(null);
// Names for a notice, read inside the socket handler.
const lookup = useRef({ rooms, people });
@@ -108,6 +130,12 @@ export function useChat(): ChatState {
void reload();
return;
}
if (msg.type === "chat_deleted") {
const d = msg.data as { roomId: number; messageId?: number };
if (d.messageId != null) deletedListener.current?.(d.roomId, d.messageId);
void reload();
return;
}
if (msg.type !== "chat_message") return;
const m = (msg.data as { message?: ChatMessage }).message;
if (!m) return;
@@ -135,6 +163,7 @@ export function useChat(): ChatState {
if (m.userId === me || m.kind !== "text") return;
const hidden = document.visibilityState !== "visible";
if (active.current === m.roomId && !hidden) return;
if (mutedRef.current.has(m.roomId)) return;
const sender = lookup.current.people.find((p) => p.id === m.userId);
const from = sender ? sender.displayName || sender.username : "?";
const room = lookup.current.rooms.find((r) => r.id === m.roomId);
@@ -174,6 +203,22 @@ export function useChat(): ChatState {
if (roomId != null) setNotice((n) => (n && n.roomId === roomId ? null : n));
}, []);
const dismissNotice = useCallback(() => setNotice(null), []);
const onDeleted = useCallback((fn: ((roomId: number, messageId: number) => void) | null) => {
deletedListener.current = fn;
}, []);
const toggleMute = useCallback((roomId: number) => {
setMuted((prev) => {
const next = new Set(prev);
if (next.has(roomId)) next.delete(roomId);
else next.add(roomId);
try {
localStorage.setItem(MUTED_KEY, JSON.stringify([...next]));
} catch {
// private mode: muted for this tab only
}
return next;
});
}, []);
// A notice goes away by itself.
useEffect(() => {
if (!notice) return;
@@ -195,5 +240,8 @@ export function useChat(): ChatState {
setActive,
notice,
dismissNotice,
onDeleted,
muted,
toggleMute,
};
}
+17 -1
View File
@@ -262,7 +262,23 @@
"sysLeft": "{{who}} left",
"sysAdded": "{{who}} added {{names}}",
"sysRemoved": "{{who}} removed {{names}}",
"openFrom": "Open the chat with {{name}}"
"openFrom": "Open the chat with {{name}}",
"menuOpen": "Open",
"menuRead": "Mark as read",
"menuReadAll": "Mark all chats as read",
"menuMute": "Mute notifications",
"menuUnmute": "Unmute notifications",
"menuCopyName": "Copy name",
"menuPet": "Chat with {{name}}",
"menuDm": "Message {{name}} privately",
"menuOpenFile": "Open in a new tab",
"menuDownload": "Download",
"menuCopyFileName": "Copy file name",
"menuCopyText": "Copy text",
"menuReply": "Reply",
"menuDelete": "Delete message",
"deleteTitle": "Delete this message?",
"deleteBody": "It is removed for everyone in the chat, with its files. This cannot be undone."
}
},
"pricing": {
+17 -1
View File
@@ -262,7 +262,23 @@
"sysLeft": "{{who}} đã rời nhóm",
"sysAdded": "{{who}} đã thêm {{names}}",
"sysRemoved": "{{who}} đã xoá {{names}}",
"openFrom": "Mở chat với {{name}}"
"openFrom": "Mở chat với {{name}}",
"menuOpen": "Mở",
"menuRead": "Đánh dấu đã đọc",
"menuReadAll": "Đánh dấu đã đọc tất cả",
"menuMute": "Tắt thông báo",
"menuUnmute": "Bật lại thông báo",
"menuCopyName": "Sao chép tên",
"menuPet": "Chat với {{name}}",
"menuDm": "Nhắn riêng cho {{name}}",
"menuOpenFile": "Mở trong tab mới",
"menuDownload": "Tải về",
"menuCopyFileName": "Sao chép tên file",
"menuCopyText": "Sao chép nội dung",
"menuReply": "Trả lời",
"menuDelete": "Xoá tin nhắn",
"deleteTitle": "Xoá tin nhắn này?",
"deleteBody": "Tin nhắn và file đính kèm sẽ bị xoá với mọi người trong cuộc chat. Không thể hoàn tác."
}
},
"pricing": {
+3
View File
@@ -1645,6 +1645,9 @@ export const api = {
method: "POST",
body: JSON.stringify({ upTo }),
}),
/** Your own message only (403 otherwise). */
deleteMessage: (roomId: number, messageId: number) =>
request<{ ok: true }>(`/chat/rooms/${roomId}/messages/${messageId}`, { method: "DELETE" }),
fileUrl: (roomId: number, fileId: string) =>
`${BASE}/chat/rooms/${roomId}/files/${encodeURIComponent(fileId)}`,
},
+4 -2
View File
@@ -1918,12 +1918,14 @@ export interface WSMessage {
* remote_source.status → RemoteSourceStatusPayload;
* remote_data.updated → RemoteDataUpdatedPayload;
* lane_update → { lane?: Lane; removed?: number };
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to }. */
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to };
* chat_deleted → { roomId, messageId, to }. */
type:
| "session_created"
| "session_updated"
| "chat_message"
| "chat_room"
| "chat_deleted"
| "agent_created"
| "agent_updated"
| "new_event"
@@ -1972,7 +1974,7 @@ export interface WSMessage {
| LaneRuntimePayload
| LaneHookResultPayload
/** chat_message / chat_room (lib/api.ts ChatMessage). */
| { roomId: number; message?: unknown; to?: number[] };
| { roomId: number; message?: unknown; messageId?: number; to?: number[] };
/** ISO timestamp the server broadcast this message (not necessarily the
* same instant the underlying event occurred). */
timestamp: string;
+2 -1
View File
@@ -3509,11 +3509,12 @@ PATCH /api/chat/rooms/:id {name?, add?, remove?} — the g
POST /api/chat/rooms/:id/leave Leave a group; the last one out deletes it and its files
GET /api/chat/rooms/:id/messages?before=&limit= {messages (oldest first), hasMore} — 50 by default, ≤ 200
POST /api/chat/rooms/:id/messages JSON {body} or multipart `body` + ≤ 10 `files` of ≤ 25 MB; body ≤ 8000 chars
DELETE /api/chat/rooms/:id/messages/:messageId Delete your OWN text message and its files (403 EFORBIDDEN otherwise, an admin included)
POST /api/chat/rooms/:id/read {upTo} — read mark, never moves back
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. 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}`) or `chat_room` (`{roomId, 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). 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}`) to exactly the user ids in `to` (`scope.exactAudience`), whatever their role.
### Session snapshots
+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,