feat(chat): delete a chat, disband a group, Seen — and purge what everyone deleted

Delete chat is the caller's alone: the history so far vanishes for them
(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 (purge): messages up to the lowest cleared_id and their files
on disk, and a direct chat both people deleted, whole. Purge also runs when a
member leaves or is removed. A group's creator disbands it for everyone
(DELETE /api/chat/rooms/:id).

Seen: rooms carry every member's read mark (reads); a mark that moves is told
to the members (chat_read, exactAudience) and never passes the newest
message. Under your newest message: Seen / Seen by … / Seen by everyone.
A room open on a tab you come back to is marked read then.
This commit is contained in:
2026-11-03 09:54:55 +07:00
parent 0016b6a427
commit c797e9868e
17 changed files with 600 additions and 59 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`/`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 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; "delete chat" is the caller's alone (`cleared_id`/`hidden`), and only what EVERY member deleted is purged for real (`purge` — messages, files, a direct chat both deleted). A read mark never passes the room's newest message (it is what others see as Seen). 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.
+140 -16
View File
@@ -106,6 +106,53 @@ async function leaveRoom(
}
}
/** "Delete chat" for the viewer only, after asking. True when done. */
async function clearRoom(
chat: ChatState,
room: ChatRoom,
title: string,
t: (k: string, o?: Record<string, unknown>) => string
): Promise<boolean> {
const dm = room.kind === "dm";
const ok = await confirmDialog({
title: t(dm ? "sagi.people.clearDmTitle" : "sagi.people.clearGroupTitle"),
message: t(dm ? "sagi.people.clearDmBody" : "sagi.people.clearGroupBody", { name: title }),
confirmLabel: t(dm ? "sagi.people.menuClearDm" : "sagi.people.menuClearGroup"),
});
if (!ok) return false;
try {
await api.chat.clear(room.id);
await chat.reload();
return true;
} catch (e) {
toast.error(e);
return false;
}
}
/** The group's creator deletes it for everyone, after asking. True when done. */
async function disbandRoom(
chat: ChatState,
room: ChatRoom,
title: string,
t: (k: string, o?: Record<string, unknown>) => string
): Promise<boolean> {
const ok = await confirmDialog({
title: t("sagi.people.disbandTitle"),
message: t("sagi.people.disbandBody", { name: title }),
confirmLabel: t("sagi.people.disband"),
});
if (!ok) return false;
try {
await api.chat.disband(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;
@@ -302,23 +349,47 @@ export function ChatRail({
<MenuItem onSelect={pick(() => void copyText(menuTitle))}>
{t("sagi.people.menuCopyName")}
</MenuItem>
<MenuSeparator />
{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(() => onSettings?.(menuRoom.id))}>
{t("sagi.people.settings")}
</MenuItem>
)}
<MenuItem
danger
onSelect={pick(() => {
void clearRoom(chat, menuRoom, menuTitle, t).then((done) => {
if (done && menuRoom.kind === "dm" && active === menuRoom.id) onSelect("pet");
});
})}
>
{t(
menuRoom.kind === "dm" ? "sagi.people.menuClearDm" : "sagi.people.menuClearGroup"
)}
</MenuItem>
{menuRoom.kind === "group" && (
<MenuItem
danger
onSelect={pick(() => {
void leaveRoom(chat, menuRoom, menuTitle, t).then((left) => {
if (left && active === menuRoom.id) onSelect("pet");
});
})}
>
{t("sagi.people.leave")}
</MenuItem>
)}
{menuRoom.kind === "group" && menuRoom.createdBy === chat.me && (
<MenuItem
danger
onSelect={pick(() => {
void disbandRoom(chat, menuRoom, menuTitle, t).then((done) => {
if (done && active === menuRoom.id) onSelect("pet");
});
})}
>
{t("sagi.people.disband")}
</MenuItem>
)}
</>
) : (
@@ -740,6 +811,26 @@ function GroupSettings({
>
<LogOut size={14} /> {t("sagi.people.leave")}
</button>
<button
type="button"
className="flex shrink-0 items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-sm text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
onClick={() => void clearRoom(chat, room, roomTitle(room, chat.me, dir), t)}
>
{t("sagi.people.menuClearGroup")}
</button>
{owner && (
<button
type="button"
className="flex shrink-0 items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-sm text-status-danger hover:bg-status-danger/10"
onClick={() =>
void disbandRoom(chat, room, roomTitle(room, chat.me, dir), t).then((done) => {
if (done) onLeft();
})
}
>
{t("sagi.people.disband")}
</button>
)}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="chat-member-menu">
{ctx.menu.item !== chat.me && (
@@ -876,6 +967,34 @@ export function ChatRoomView({
if (el && stick.current) el.scrollTop = el.scrollHeight;
}, [messages]);
// Back on a hidden tab with this room open: what arrived meanwhile is read now.
useEffect(() => {
const onVisible = () => {
if (document.visibilityState !== "visible") return;
const last = messages[messages.length - 1];
if (last && last.id > (room.reads?.[String(chat.me)] ?? room.lastReadId)) read(last.id);
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, [messages, room.reads, room.lastReadId, chat.me, read]);
// "Seen": under your newest message, who has read up to it.
const lastOwn = [...messages].reverse().find((m) => m.userId === chat.me && m.kind === "text");
const seenBy = lastOwn
? room.members.filter((id) => id !== chat.me && (room.reads?.[String(id)] ?? 0) >= lastOwn.id)
: [];
const others = room.members.filter((id) => id !== chat.me);
const seenText =
!lastOwn || !seenBy.length
? null
: room.kind === "dm" || seenBy.length === others.length
? room.kind === "dm"
? t("sagi.people.seen")
: t("sagi.people.seenAll")
: t("sagi.people.seenBy", {
names: seenBy.map((id) => personName(dir.get(id))).join(", "),
});
const older = async () => {
const first = messages[0];
if (!first) return;
@@ -1014,6 +1133,11 @@ export function ChatRoomView({
<Files m={m} own={own} onMenu={(e, f) => ctx.open(e, { m, file: f })} />
)}
</div>
{m.id === lastOwn?.id && seenText && (
<span className="mt-0.5 px-1 text-[10px] text-fg-muted" data-testid="chat-seen">
{seenText}
</span>
)}
</div>
);
})}
+2
View File
@@ -453,6 +453,8 @@ function SagiBody() {
<NewChat chat={people} onOpen={setPane} onClose={() => setOpen(false)} />
) : people.enabled && room ? (
<ChatRoomView
// Cleared for me: read the (now shorter) history again.
key={`${room.id}:${room.clearedId ?? 0}`}
chat={people}
room={room}
onClose={() => setOpen(false)}
@@ -6,7 +6,9 @@
* 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; 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;
* and "Seen" under your newest message.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -25,6 +27,8 @@ const api = vi.hoisted(() => ({
updateGroup: vi.fn(),
leave: vi.fn(),
deleteMessage: vi.fn(),
clear: vi.fn(),
disband: vi.fn(),
fileUrl: (r: number, f: string) => `/api/chat/rooms/${r}/files/${f}`,
},
users: { avatarUrl: () => null },
@@ -308,4 +312,58 @@ describe("ChatHub", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Message An privately" }));
await waitFor(() => expect(onOpenRoom).toHaveBeenCalledWith(8));
});
it("deletes a chat for yourself, and lets the creator disband a group", async () => {
const chat = state();
const onSelect = vi.fn();
api.chat.clear.mockResolvedValue({ hidden: true });
api.chat.disband.mockResolvedValue({ ok: true });
render(<ChatRail chat={chat} active={8} onSelect={onSelect} petName="Sagi" />);
fireEvent.contextMenu(screen.getByRole("button", { name: "An" }));
expect(screen.queryByRole("menuitem", { name: "Disband group" })).toBeNull();
fireEvent.click(screen.getByRole("menuitem", { name: "Delete chat" }));
await waitFor(() => expect(api.chat.clear).toHaveBeenCalledWith(8));
await waitFor(() => expect(onSelect).toHaveBeenCalledWith("pet"));
expect(confirm.confirmDialog).toHaveBeenCalled();
fireEvent.contextMenu(screen.getByRole("button", { name: "Team, 2 unread" }));
expect(screen.getByRole("menuitem", { name: "Delete chat history" })).toBeTruthy();
fireEvent.click(screen.getByRole("menuitem", { name: "Disband group" }));
await waitFor(() => expect(api.chat.disband).toHaveBeenCalledWith(7));
});
it("offers Disband only to the group's creator", () => {
render(<ChatRail chat={state({ me: 2 })} active="pet" onSelect={() => {}} petName="Sagi" />);
fireEvent.contextMenu(screen.getByRole("button", { name: /Team/ }));
expect(screen.queryByRole("menuitem", { name: "Disband group" })).toBeNull();
expect(screen.getByRole("menuitem", { name: "Leave group" })).toBeTruthy();
});
it("shows who has seen your newest message", async () => {
api.chat.messages.mockResolvedValue({
messages: [msg(1, 2, "của An"), msg(2, 1, "của tôi")],
hasMore: false,
});
api.chat.read.mockResolvedValue({ lastReadId: 2 });
const view = (room: ChatRoom) => (
<ChatRoomView
chat={state()}
room={room}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={() => {}}
/>
);
// Direct chat, not read yet: nothing; read: "Seen".
const { rerender } = render(view({ ...dm, id: 7, reads: { 1: 2, 2: 1 } }));
await screen.findByText("của tôi");
expect(screen.queryByTestId("chat-seen")).toBeNull();
rerender(view({ ...dm, id: 7, reads: { 1: 2, 2: 2 } }));
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen");
// Group: by name, then everyone.
rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 1 } }));
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by An");
rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 2 } }));
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by everyone");
});
});
@@ -3,7 +3,8 @@
* @description `useChat`'s live side: a message from someone else raises a
* 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
* the user's own messages; nothing runs in local mode.
* the user's own messages; a `chat_read` frame moves that member's read mark
* ("Seen"); nothing runs in local mode.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -95,4 +96,17 @@ describe("useChat", () => {
expect(result.current.enabled).toBe(false);
expect(api.chat.rooms).not.toHaveBeenCalled();
});
it("moves a member's read mark on a chat_read frame", async () => {
const { result } = renderHook(() => useChat());
await waitFor(() => expect(result.current.rooms.length).toBe(1));
act(() =>
eventBus.publish({
type: "chat_read",
data: { roomId: 7, userId: 2, lastReadId: 9 },
timestamp: "t",
})
);
expect(result.current.rooms[0]!.reads?.[2]).toBe(9);
});
});
+38 -8
View File
@@ -14,7 +14,8 @@
* 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.
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -130,6 +131,24 @@ export function useChat(): ChatState {
void reload();
return;
}
if (msg.type === "chat_read") {
const d = msg.data as { roomId: number; userId?: number; lastReadId?: number };
if (d.userId == null || d.lastReadId == null) return;
setRooms((prev) =>
prev.map((r) =>
r.id === d.roomId
? {
...r,
reads: {
...r.reads,
[d.userId!]: Math.max(r.reads?.[d.userId!] ?? 0, d.lastReadId!),
},
}
: r
)
);
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);
@@ -190,13 +209,24 @@ export function useChat(): ChatState {
const onMessage = useCallback((fn: ((m: ChatMessage) => void) | null) => {
listener.current = fn;
}, []);
const markRead = useCallback((roomId: number, upTo: number) => {
setRooms((prev) =>
prev.map((r) =>
r.id === roomId ? { ...r, unread: 0, lastReadId: Math.max(r.lastReadId, upTo) } : r
)
);
}, []);
const markRead = useCallback(
(roomId: number, upTo: number) => {
setRooms((prev) =>
prev.map((r) =>
r.id === roomId
? {
...r,
unread: 0,
lastReadId: Math.max(r.lastReadId, upTo),
reads:
me != null ? { ...r.reads, [me]: Math.max(r.reads?.[me] ?? 0, upTo) } : r.reads,
}
: r
)
);
},
[me]
);
const setActive = useCallback((roomId: number | null) => {
active.current = roomId;
+13 -1
View File
@@ -278,7 +278,19 @@
"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."
"deleteBody": "It is removed for everyone in the chat, with its files. This cannot be undone.",
"menuClearDm": "Delete chat",
"menuClearGroup": "Delete chat history",
"clearDmTitle": "Delete this chat?",
"clearDmBody": "The chat with {{name}} and its history are deleted for you only — they keep theirs. It comes back, without the old messages, if either of you writes again.",
"clearGroupTitle": "Delete this group's history?",
"clearGroupBody": "Every message in {{name}} so far is deleted for you only. You stay in the group; the others keep theirs.",
"disband": "Disband group",
"disbandTitle": "Disband this group?",
"disbandBody": "{{name}} and all its messages and files are deleted for every member. This cannot be undone.",
"seen": "Seen",
"seenAll": "Seen by everyone",
"seenBy": "Seen by {{names}}"
}
},
"pricing": {
+13 -1
View File
@@ -278,7 +278,19 @@
"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."
"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.",
"menuClearDm": "Xoá cuộc trò chuyện",
"menuClearGroup": "Xoá lịch sử chat",
"clearDmTitle": "Xoá cuộc trò chuyện này?",
"clearDmBody": "Cuộc trò chuyện với {{name}} và lịch sử của nó chỉ bị xoá ở phía bạn — người kia vẫn giữ. Nếu một trong hai nhắn tiếp, cuộc chat sẽ hiện lại nhưng không còn tin cũ.",
"clearGroupTitle": "Xoá lịch sử của nhóm này?",
"clearGroupBody": "Mọi tin nhắn trong {{name}} đến giờ chỉ bị xoá ở phía bạn. Bạn vẫn ở trong nhóm; người khác vẫn giữ tin của họ.",
"disband": "Giải tán nhóm",
"disbandTitle": "Giải tán nhóm này?",
"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",
"seenAll": "Mọi người đã xem",
"seenBy": "{{names}} đã xem"
}
},
"pricing": {
+9
View File
@@ -577,6 +577,10 @@ export type ChatRoom = {
members: number[];
lastMessage: ChatMessage | null;
lastReadId: number;
/** Messages up to this id were deleted by the viewer for themselves. */
clearedId?: number;
/** Every member's read mark by user id ("Seen"); absent from an older server. */
reads?: Record<string, number>;
unread: number;
};
/** One account in Settings → Users (`GET /api/users`). */
@@ -1645,6 +1649,11 @@ export const api = {
method: "POST",
body: JSON.stringify({ upTo }),
}),
/** Delete the chat for yourself only (a direct chat leaves your list). */
clear: (id: number) =>
request<{ hidden: boolean }>(`/chat/rooms/${id}/clear`, { method: "POST" }),
/** The group's creator deletes it for everyone. */
disband: (id: number) => request<{ ok: true }>(`/chat/rooms/${id}`, { method: "DELETE" }),
/** Your own message only (403 otherwise). */
deleteMessage: (roomId: number, messageId: number) =>
request<{ ok: true }>(`/chat/rooms/${roomId}/messages/${messageId}`, { method: "DELETE" }),
+10 -2
View File
@@ -1919,13 +1919,14 @@ export interface WSMessage {
* remote_data.updated → RemoteDataUpdatedPayload;
* lane_update → { lane?: Lane; removed?: number };
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to };
* chat_deleted → { roomId, messageId, to }. */
* chat_deleted → { roomId, messageId, to }; chat_read → { roomId, userId, lastReadId, to }. */
type:
| "session_created"
| "session_updated"
| "chat_message"
| "chat_room"
| "chat_deleted"
| "chat_read"
| "agent_created"
| "agent_updated"
| "new_event"
@@ -1974,7 +1975,14 @@ export interface WSMessage {
| LaneRuntimePayload
| LaneHookResultPayload
/** chat_message / chat_room (lib/api.ts ChatMessage). */
| { roomId: number; message?: unknown; messageId?: number; to?: number[] };
| {
roomId: number;
message?: unknown;
messageId?: number;
userId?: number;
lastReadId?: number;
to?: number[];
};
/** ISO timestamp the server broadcast this message (not necessarily the
* same instant the underlying event occurred). */
timestamp: string;
+5 -3
View File
@@ -3503,18 +3503,20 @@ The pet's window has a rail of chats beside the chat with the pet: direct chats
```
GET /api/chat/people {people: [{id, username, displayName, avatarAt}]} — every enabled account but you
GET /api/chat/rooms {rooms} — yours, newest first: members, lastMessage, lastReadId, 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
PATCH /api/chat/rooms/:id {name?, add?, remove?} — the group's creator only (403 EFORBIDDEN)
DELETE /api/chat/rooms/:id Disband a group — its creator only, for every member, files included
POST /api/chat/rooms/:id/clear Delete the chat for YOURSELF: {hidden, purged} — see below
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
POST /api/chat/rooms/:id/read {upTo} — read mark, never moves back nor past the newest message; a move is sent as `chat_read`
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). 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.
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.
### Session snapshots
+83 -1
View File
@@ -4,7 +4,8 @@
* 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, a message deleted only by
* its author, local mode refused, and
* its author, "delete chat" for the caller alone, a group disbanded only by its
* creator, 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>
*/
@@ -168,6 +169,87 @@ describe("chat", () => {
assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${sys.id}`)).status, 403);
});
it("deletes a direct chat for the caller only, until a new message", async () => {
await call(3, "POST", `/rooms/${dm}/messages`, { body: "cũ" });
const c = await call(2, "POST", `/rooms/${dm}/clear`);
assert.deepEqual(c.body, { hidden: true, purged: false });
assert.ok(!(await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === dm));
assert.deepEqual((await call(2, "GET", `/rooms/${dm}/messages`)).body.messages, []);
// The other person keeps everything.
assert.ok((await call(3, "GET", `/rooms/${dm}/messages`)).body.messages.length >= 2);
await call(3, "POST", `/rooms/${dm}/messages`, { body: "mới" });
const back = (await call(2, "GET", "/rooms")).body.rooms.find((r) => r.id === dm);
assert.equal(back.unread, 1);
assert.deepEqual(
(await call(2, "GET", `/rooms/${dm}/messages`)).body.messages.map((m) => m.body),
["mới"]
);
});
it("hides a cleared message's files from the one who cleared it", async () => {
const fd = new FormData();
fd.append("files", new Blob(["x"], { type: "text/plain" }), "b.txt");
const m = (await call(3, "POST", `/rooms/${group}/messages`, fd)).body.message;
await call(2, "POST", `/rooms/${group}/clear`);
assert.equal((await call(2, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 404);
assert.equal((await call(3, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 200);
// A group stays in the list after its history is cleared.
assert.ok((await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === group));
});
it("lets only a group's creator disband it, for everyone", async () => {
const g = (await call(3, "POST", "/rooms", { kind: "group", name: "Tạm", userIds: [2] })).body
.room.id;
assert.equal((await call(2, "DELETE", `/rooms/${g}`)).status, 403);
assert.equal((await call(3, "DELETE", `/rooms/${g}`)).status, 200);
assert.equal((await call(2, "GET", `/rooms/${g}/messages`)).status, 404);
assert.equal((await call(3, "DELETE", `/rooms/${dm}`)).status, 400);
});
it("deletes for real what everyone deleted, and a direct chat both deleted whole", async () => {
const { db } = require("../db");
const fd = new FormData();
fd.append("body", "bí mật");
fd.append("files", new Blob(["y"], { type: "text/plain" }), "c.txt");
const room = (await call(4, "POST", "/rooms", { kind: "dm", userId: 3 })).body.room.id;
const m = (await call(4, "POST", `/rooms/${room}/messages`, fd)).body.message;
const onDisk = path.join(TMP, "chat", String(room), m.files[0].id);
assert.ok(fs.existsSync(onDisk));
await call(4, "POST", `/rooms/${room}/clear`);
// One side only: still stored for the other.
assert.ok(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id));
assert.ok(fs.existsSync(onDisk));
const both = await call(3, "POST", `/rooms/${room}/clear`);
assert.equal(both.body.purged, true);
assert.equal(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id), undefined);
assert.equal(db.prepare("SELECT 1 FROM chat_rooms WHERE id = ?").get(room), undefined);
assert.ok(!fs.existsSync(onDisk));
// Opening it again starts afresh.
const again = (await call(4, "POST", "/rooms", { kind: "dm", userId: 3 })).body.room;
assert.equal(again.lastMessage, null);
});
it("purges a group's history once every member cleared it", async () => {
const { db } = require("../db");
const g = (await call(2, "POST", "/rooms", { kind: "group", userIds: [3] })).body.room.id;
const m = (await call(2, "POST", `/rooms/${g}/messages`, { body: "x" })).body.message;
await call(2, "POST", `/rooms/${g}/clear`);
assert.ok(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id));
await call(3, "POST", `/rooms/${g}/clear`);
assert.equal(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id), undefined);
// The group itself stays.
assert.ok((await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === g));
});
it("tells every member's read mark (seen), never past the newest message", async () => {
const r = await call(3, "POST", `/rooms/${group}/read`, { upTo: 1e9 });
const room = (await call(2, "GET", "/rooms")).body.rooms.find((x) => x.id === group);
assert.equal(room.reads[3], r.body.lastReadId);
assert.equal(room.reads[3], room.lastMessage ? room.lastMessage.id : room.reads[3]);
assert.ok(r.body.lastReadId < 1e9);
assert.deepEqual([...scope.exactAudience("chat_read", { to: [2, 3] })], [2, 3]);
});
it("refuses an empty message", async () => {
assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400);
});
+9
View File
@@ -1952,6 +1952,15 @@ db.exec(`
);
CREATE INDEX IF NOT EXISTS idx_chat_files_message ON chat_files(message_id);
`);
// A member's own "delete this chat": messages up to cleared_id are gone for
// them (not for anyone else), and a hidden direct chat leaves their list until
// a new message arrives. Additive.
try {
db.prepare("SELECT cleared_id, hidden FROM chat_members LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE chat_members ADD COLUMN cleared_id INTEGER NOT NULL DEFAULT 0").run();
db.prepare("ALTER TABLE chat_members ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0").run();
}
// An account's own agent instructions, put before every workspace's in each
// pane it owns (server/lib/lane-sources.js, issue #67). Additive.
+128 -22
View File
@@ -5,7 +5,17 @@
* 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; anyone deletes their own messages (only). A message is text (≤ 8000 chars) and up to
* renames; any member may leave; anyone deletes their own messages (only).
* "Delete chat" is the caller's alone: the messages so far vanish for THEM
* (`cleared_id`) and a direct chat leaves their list (`hidden`) until a new
* message arrives — the other members keep everything. Only a group's creator
* disbands it, for everyone, its files included. Once EVERY member has
* deleted a stretch of history, it is deleted for real (`purge`): the
* messages up to the lowest `cleared_id` and their files, and a direct chat
* 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`) —
* 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
* `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
@@ -112,16 +122,16 @@ function shapeMessages(rows) {
/** One room as `me` sees it: members, newest message, unread count. */
function shapeRoom(me, r) {
const mine = sql("SELECT last_read_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
r.id,
me
);
const last = sql("SELECT * FROM chat_messages WHERE room_id = ? ORDER BY id DESC LIMIT 1").get(
r.id
);
const mine = sql(
"SELECT last_read_id, cleared_id FROM chat_members WHERE room_id = ? AND user_id = ?"
).get(r.id, me);
const cleared = mine ? mine.cleared_id : 0;
const last = sql(
"SELECT * FROM chat_messages WHERE room_id = ? AND id > ? ORDER BY id DESC LIMIT 1"
).get(r.id, cleared);
const unread = sql(
"SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ? AND id > ? AND COALESCE(user_id, -1) != ?"
).get(r.id, mine ? mine.last_read_id : 0, me).n;
).get(r.id, Math.max(mine ? mine.last_read_id : 0, cleared), me).n;
return {
id: r.id,
kind: r.kind,
@@ -132,6 +142,12 @@ function shapeRoom(me, r) {
members: memberIds(r.id),
lastMessage: last ? shapeMessages([last])[0] : null,
lastReadId: mine ? mine.last_read_id : 0,
clearedId: cleared,
reads: Object.fromEntries(
sql("SELECT user_id, last_read_id FROM chat_members WHERE room_id = ?")
.all(r.id)
.map((x) => [x.user_id, x.last_read_id])
),
unread,
};
}
@@ -140,7 +156,8 @@ function shapeRoom(me, r) {
function listRooms(me) {
return sql(
`SELECT r.* FROM chat_rooms r JOIN chat_members m ON m.room_id = r.id
WHERE m.user_id = ? ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`
WHERE m.user_id = ? AND m.hidden = 0
ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`
)
.all(me)
.map((r) => shapeRoom(me, r));
@@ -167,7 +184,11 @@ function openDm(me, other) {
if (!them || them.id === me) throw err("ENOUSER", "pick another user");
const key = [me, them.id].sort((a, b) => a - b).join(":");
const found = sql("SELECT * FROM chat_rooms WHERE dm_key = ?").get(key);
if (found) return { room: shapeRoom(me, found), created: false };
if (found) {
// Opened again on purpose: back in the list (its cleared history stays gone).
sql("UPDATE chat_members SET hidden = 0 WHERE room_id = ? AND user_id = ?").run(found.id, me);
return { room: shapeRoom(me, found), created: false };
}
const id = db().transaction(() => {
const { lastInsertRowid } = sql(
"INSERT INTO chat_rooms (kind, dm_key, created_by) VALUES ('dm', ?, ?)"
@@ -237,6 +258,8 @@ function updateGroup(me, roomId, { name, add, remove }) {
if (adds.length) systemLine(room.id, me, `added:${adds.join(",")}`);
if (drops.length) systemLine(room.id, me, `removed:${drops.join(",")}`);
})();
// A removed member's mark no longer holds history back.
if (drops.length) purge(room.id);
return {
room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id)),
notify: [...new Set([...before, ...adds])],
@@ -251,24 +274,29 @@ function leaveGroup(me, roomId) {
db().transaction(() => {
sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?").run(room.id, me);
if (before.length > 1) systemLine(room.id, me, "left");
else sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
})();
if (before.length <= 1) fs.rmSync(fileDir(room.id), { recursive: true, force: true });
purge(room.id);
return { notify: before };
}
/** A page of messages, oldest first: the newest `limit`, or those before `before`. */
function messages(me, roomId, { before, limit } = {}) {
const room = roomFor(me, roomId);
const cleared = sql("SELECT cleared_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
room.id,
me
).cleared_id;
const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
const b = parseInt(before, 10);
const rows = Number.isSafeInteger(b)
? sql("SELECT * FROM chat_messages WHERE room_id = ? AND id < ? ORDER BY id DESC LIMIT ?").all(
? sql(
"SELECT * FROM chat_messages WHERE room_id = ? AND id > ? AND id < ? ORDER BY id DESC LIMIT ?"
).all(room.id, cleared, b, n)
: sql("SELECT * FROM chat_messages WHERE room_id = ? AND id > ? ORDER BY id DESC LIMIT ?").all(
room.id,
b,
cleared,
n
)
: sql("SELECT * FROM chat_messages WHERE room_id = ? ORDER BY id DESC LIMIT ?").all(room.id, n);
);
return { messages: shapeMessages(rows.reverse()), hasMore: rows.length === n };
}
@@ -322,6 +350,8 @@ function post(me, roomId, { body, files = [] }) {
sql(
"UPDATE chat_rooms SET last_message_at = (SELECT created_at FROM chat_messages WHERE id = ?) WHERE id = ?"
).run(lastInsertRowid, room.id);
// A new message brings the chat back to everyone who deleted it.
sql("UPDATE chat_members SET hidden = 0 WHERE room_id = ? AND hidden = 1").run(room.id);
// Your own message is read.
sql("UPDATE chat_members SET last_read_id = ? WHERE room_id = ? AND user_id = ?").run(
lastInsertRowid,
@@ -343,10 +373,51 @@ function markRead(me, roomId, upTo) {
const room = roomFor(me, roomId);
const n = Number(upTo);
if (!Number.isSafeInteger(n) || n < 0) throw err("EBADFIELD", "upTo: a message id");
sql(
"UPDATE chat_members SET last_read_id = MAX(last_read_id, ?) WHERE room_id = ? AND user_id = ?"
).run(n, room.id, me);
return { lastReadId: Math.max(n, 0) };
const top = sql("SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages WHERE room_id = ?").get(
room.id
).id;
const { changes } = sql(
"UPDATE chat_members SET last_read_id = ? WHERE room_id = ? AND user_id = ? AND last_read_id < ?"
).run(Math.min(n, top), room.id, me, Math.min(n, top));
const now = sql("SELECT last_read_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
room.id,
me
).last_read_id;
return { lastReadId: now, moved: changes > 0, notify: memberIds(room.id) };
}
/**
* Delete for real what every member has deleted for themselves: messages up
* to the lowest `cleared_id` and their files; a direct chat both people
* deleted (hidden, nothing left) goes whole. Answers whether the room is gone.
*/
function purge(roomId) {
const room = sql("SELECT * FROM chat_rooms WHERE id = ?").get(roomId);
if (!room) return true;
const low = sql(
"SELECT MIN(cleared_id) AS low, MIN(hidden) AS allHidden FROM chat_members WHERE room_id = ?"
).get(room.id);
if (low.low == null) {
// Nobody left.
sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
fs.rmSync(fileDir(room.id), { recursive: true, force: true });
return true;
}
if (low.low > 0) {
const files = sql("SELECT id FROM chat_files WHERE room_id = ? AND message_id <= ?").all(
room.id,
low.low
);
sql("DELETE FROM chat_messages WHERE room_id = ? AND id <= ?").run(room.id, low.low);
for (const f of files) fs.rmSync(path.join(fileDir(room.id), f.id), { force: true });
}
const left = sql("SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ?").get(room.id).n;
if (room.kind === "dm" && low.allHidden === 1 && left === 0) {
sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
fs.rmSync(fileDir(room.id), { recursive: true, force: true });
return true;
}
return false;
}
/**
@@ -374,12 +445,44 @@ function removeMessage(me, roomId, messageId) {
return { notify: memberIds(room.id) };
}
/**
* "Delete chat" for `me` only: every message so far is gone for them; a
* direct chat also leaves their list until a new message arrives. Nobody
* else's copy changes.
*/
function clearChat(me, roomId) {
const room = roomFor(me, roomId);
const top = sql("SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages WHERE room_id = ?").get(
room.id
).id;
sql(
`UPDATE chat_members SET cleared_id = ?, last_read_id = MAX(last_read_id, ?), hidden = ?
WHERE room_id = ? AND user_id = ?`
).run(top, top, room.kind === "dm" ? 1 : 0, room.id, me);
const gone = purge(room.id);
return { hidden: room.kind === "dm", purged: gone };
}
/** The group's creator deletes it for everyone, files included. Answers who to tell. */
function disbandGroup(me, roomId) {
const room = roomFor(me, roomId);
if (room.kind !== "group") throw err("EBADFIELD", "only a group can be disbanded");
if (room.created_by !== me) throw err("EFORBIDDEN", "only the group's creator disbands it", 403);
const before = memberIds(room.id);
sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
fs.rmSync(fileDir(room.id), { recursive: true, force: true });
return { notify: before };
}
/** A file of a room `me` is in: `{path, name, type, inline}`. */
function file(me, roomId, fileId) {
const room = roomFor(me, roomId);
if (typeof fileId !== "string" || !/^[0-9a-f]{32}$/.test(fileId))
throw err("ENOTFOUND", "no such file", 404);
const f = sql("SELECT * FROM chat_files WHERE id = ? AND room_id = ?").get(fileId, room.id);
const f = sql(
`SELECT f.* FROM chat_files f JOIN chat_members m ON m.room_id = f.room_id AND m.user_id = ?
WHERE f.id = ? AND f.room_id = ? AND f.message_id > m.cleared_id`
).get(me, fileId, room.id);
const p = f && path.join(fileDir(room.id), f.id);
if (!f || !fs.existsSync(p)) throw err("ENOTFOUND", "no such file", 404);
return { path: p, name: f.name, type: f.type, inline: INLINE_TYPES.has(f.type) };
@@ -397,7 +500,10 @@ module.exports = {
messages,
post,
markRead,
purge,
removeMessage,
clearChat,
disbandGroup,
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" || type === "chat_deleted") {
if (["chat_message", "chat_room", "chat_deleted", "chat_read"].includes(type)) {
return new Set(Array.isArray(d.to) ? d.to : []);
}
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
+50
View File
@@ -59,7 +59,16 @@ const schemas = {
members: { type: "array", items: { type: "integer" } },
lastMessage: { allOf: [{ $ref: "#/components/schemas/ChatMessage" }], nullable: true },
lastReadId: { type: "integer" },
clearedId: {
type: "integer",
description: "Messages up to this id were deleted by the caller for themselves.",
},
unread: { type: "integer", description: "Messages of others after `lastReadId`." },
reads: {
type: "object",
additionalProperties: { type: "integer" },
description: "Every member's read mark by user id — what the room shows as Seen.",
},
},
},
};
@@ -145,6 +154,20 @@ const paths = {
},
},
"/api/chat/rooms/{id}": {
delete: {
tags: ["Chat"],
summary: "Disband a group",
description:
"The group's creator only (`403 EFORBIDDEN`): deletes it for every member, its files included. Broadcasts `chat_room`.",
parameters: [idParam],
responses: {
200: {
description: "Disbanded",
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
},
...fail([400, 403, 404]),
},
},
patch: {
tags: ["Chat"],
summary: "Rename a group, add or remove members",
@@ -164,6 +187,31 @@ const paths = {
responses: { 200: { description: "Updated", ...roomResp }, ...fail([400, 403, 404]) },
},
},
"/api/chat/rooms/{id}/clear": {
post: {
tags: ["Chat"],
summary: "Delete a chat for yourself",
description:
"Every message so far is gone for the caller only (`clearedId`); a direct chat also leaves their list until a new message arrives. Nobody else's copy changes — but what EVERY member has deleted is deleted for real (messages, files, and a direct chat both people deleted, whole).",
parameters: [idParam],
responses: {
200: {
description: "Cleared",
...json({
type: "object",
properties: {
hidden: { type: "boolean" },
purged: {
type: "boolean",
description: "Every member has now deleted it: the chat is gone from the server.",
},
},
}),
},
...fail([404]),
},
},
},
"/api/chat/rooms/{id}/leave": {
post: {
tags: ["Chat"],
@@ -262,6 +310,8 @@ const paths = {
post: {
tags: ["Chat"],
summary: "Mark a room read up to a message",
description:
"Never moves back, never past the room's newest message. A mark that moved is told to the members as `chat_read` (`{roomId, userId, lastReadId}`).",
parameters: [idParam],
requestBody: {
required: true,
+24 -1
View File
@@ -118,6 +118,24 @@ router.post(
})
);
// Delete the chat for the caller only (their history; a direct chat leaves their list).
router.post(
"/rooms/:id/clear",
sameOriginGuard,
handle((me, req, res) => res.json(chat.clearChat(me, req.params.id)))
);
// The group's creator deletes it for everyone.
router.delete(
"/rooms/:id",
sameOriginGuard,
handle((me, req, res) => {
const { notify } = chat.disbandGroup(me, req.params.id);
tell("chat_room", { roomId: Number(req.params.id) }, notify);
res.json({ ok: true });
})
);
router.get(
"/rooms/:id/messages",
handle((me, req, res) =>
@@ -153,7 +171,12 @@ router.delete(
router.post(
"/rooms/:id/read",
sameOriginGuard,
handle((me, req, res) => res.json(chat.markRead(me, req.params.id, req.body?.upTo)))
handle((me, req, res) => {
const { lastReadId, moved, notify } = chat.markRead(me, req.params.id, req.body?.upTo);
// "Seen": the other members' views move their read marks.
if (moved) tell("chat_read", { roomId: Number(req.params.id), userId: me, lastReadId }, notify);
res.json({ lastReadId });
})
);
router.get(