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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user