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
+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;