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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user