feat(chat): right-click menus everywhere in the chat, and deleting your own message

Rail: a room — open, mark as read, mute / unmute notifications (per browser,
the unread count still shows), copy name, group settings, leave; the pet and
+ — chat with the pet, new chat, mark all chats as read. A message — copy
text, reply (quotes it into the box), message the sender privately (group),
delete your own (asks first). A file — open in a new tab, download, copy file
name. A group member — message privately, copy name, remove (the creator).

DELETE /api/chat/rooms/:id/messages/:messageId deletes the caller's OWN text
message and its files (403 for anyone else, an admin included) and tells the
members with a chat_deleted frame (exactAudience).
This commit is contained in:
2026-11-03 09:47:32 +07:00
parent 0d3e92cfda
commit 0016b6a427
15 changed files with 612 additions and 33 deletions
+288 -18
View File
@@ -29,7 +29,15 @@ import {
Users,
X,
} from "lucide-react";
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { copyText } from "../../lib/clipboard";
import {
api,
type ChatFile,
type ChatMessage,
type ChatPerson,
type ChatRoom,
} from "../../lib/api";
import { formatBytes, formatTime } from "../../lib/format";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
@@ -75,6 +83,48 @@ function roomTitle(room: ChatRoom, me: number | null, dir: Map<number, ChatPerso
.join(", ");
}
/** Leave a group, after asking. True when left. */
async function leaveRoom(
chat: ChatState,
room: ChatRoom,
title: string,
t: (k: string, o?: Record<string, unknown>) => string
): Promise<boolean> {
const ok = await confirmDialog({
title: t("sagi.people.leaveTitle"),
message: t("sagi.people.leaveBody", { name: title }),
confirmLabel: t("sagi.people.leave"),
});
if (!ok) return false;
try {
await api.chat.leave(room.id);
await chat.reload();
return true;
} catch (e) {
toast.error(e);
return false;
}
}
/** Every message of a room read, here and on the server. */
function readAll(chat: ChatState, room: ChatRoom) {
const upTo = room.lastMessage?.id;
if (!upTo) return;
chat.markRead(room.id, upTo);
api.chat.read(room.id, upTo).catch((e) => toast.error(e));
}
/** Open (or make) the direct chat with one person. */
async function openDirect(chat: ChatState, userId: number, onOpen: (roomId: number) => void) {
try {
const { room } = await api.chat.openDm(userId);
chat.upsert(room);
onOpen(room.id);
} catch (e) {
toast.error(e);
}
}
function RoomIcon({
room,
me,
@@ -160,15 +210,25 @@ export function ChatRail({
active,
onSelect,
petName,
onSettings,
}: {
chat: ChatState;
/** `"pet"`, `"new"` or a room id. */
active: "pet" | "new" | number;
onSelect: (v: "pet" | "new" | number) => void;
petName: string;
/** Open a group with its settings shown (its right-click menu). */
onSettings?: (roomId: number) => void;
}) {
const { t } = useTranslation("settings");
const dir = useDirectory(chat);
const ctx = useContextMenu<ChatRoom | "pet" | "new">();
const pick = (fn: () => void) => () => {
ctx.close();
fn();
};
const menuRoom = ctx.menu && typeof ctx.menu.item === "object" ? ctx.menu.item : null;
const menuTitle = menuRoom ? roomTitle(menuRoom, chat.me, dir) : "";
const ring = (on: boolean) =>
on ? "ring-2 ring-accent ring-offset-2 ring-offset-surface-2" : "opacity-80 hover:opacity-100";
return (
@@ -179,6 +239,7 @@ export function ChatRail({
<button
type="button"
onClick={() => onSelect("pet")}
onContextMenu={(e) => ctx.open(e, "pet")}
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-4 text-lg transition ${ring(active === "pet")}`}
title={petName}
aria-label={petName}
@@ -194,6 +255,7 @@ export function ChatRail({
key={r.id}
type="button"
onClick={() => onSelect(r.id)}
onContextMenu={(e) => ctx.open(e, r)}
className={`relative shrink-0 rounded-full transition ${ring(active === r.id)}`}
title={title}
aria-label={r.unread ? t("sagi.people.unreadIn", { name: title, n: r.unread }) : title}
@@ -201,18 +263,82 @@ export function ChatRail({
>
<RoomIcon room={r} me={chat.me} dir={dir} className="h-9 w-9 text-[11px]" />
<Badge n={r.unread} />
{chat.muted.has(r.id) && (
<span
className="absolute -bottom-0.5 -right-0.5 rounded-full bg-surface-2 text-[10px] leading-none"
aria-hidden
>
🔕
</span>
)}
</button>
);
})}
<button
type="button"
onClick={() => onSelect("new")}
onContextMenu={(e) => ctx.open(e, "new")}
className={`mt-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-dashed border-border text-fg-muted transition hover:text-fg-primary ${active === "new" ? "ring-2 ring-accent" : ""}`}
title={t("sagi.people.new")}
aria-label={t("sagi.people.new")}
>
<Plus size={16} />
</button>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="chat-rail-menu">
{menuRoom ? (
<>
<MenuItem onSelect={pick(() => onSelect(menuRoom.id))}>
{t("sagi.people.menuOpen")}
</MenuItem>
<MenuItem disabled={!menuRoom.unread} onSelect={pick(() => readAll(chat, menuRoom))}>
{t("sagi.people.menuRead")}
</MenuItem>
<MenuItem onSelect={pick(() => chat.toggleMute(menuRoom.id))}>
{chat.muted.has(menuRoom.id)
? t("sagi.people.menuUnmute")
: t("sagi.people.menuMute")}
</MenuItem>
<MenuItem onSelect={pick(() => void copyText(menuTitle))}>
{t("sagi.people.menuCopyName")}
</MenuItem>
{menuRoom.kind === "group" && (
<>
<MenuSeparator />
<MenuItem onSelect={pick(() => onSettings?.(menuRoom.id))}>
{t("sagi.people.settings")}
</MenuItem>
<MenuItem
danger
onSelect={pick(() => {
void leaveRoom(chat, menuRoom, menuTitle, t).then((left) => {
if (left && active === menuRoom.id) onSelect("pet");
});
})}
>
{t("sagi.people.leave")}
</MenuItem>
</>
)}
</>
) : (
<>
<MenuItem onSelect={pick(() => onSelect("pet"))}>
{t("sagi.people.menuPet", { name: petName })}
</MenuItem>
<MenuItem onSelect={pick(() => onSelect("new"))}>{t("sagi.people.new")}</MenuItem>
<MenuItem
disabled={!chat.unread}
onSelect={pick(() =>
chat.rooms.filter((r) => r.unread).forEach((r) => readAll(chat, r))
)}
>
{t("sagi.people.menuReadAll")}
</MenuItem>
</>
)}
</ContextMenu>
)}
</nav>
);
}
@@ -422,13 +548,27 @@ function SystemLine({ m, dir }: { m: ChatMessage; dir: Map<number, ChatPerson> }
return <p className="py-1 text-center text-[11px] text-fg-muted">{text}</p>;
}
function Files({ m, own }: { m: ChatMessage; own: boolean }) {
function Files({
m,
own,
onMenu,
}: {
m: ChatMessage;
own: boolean;
onMenu: (e: React.MouseEvent, f: ChatFile) => void;
}) {
return (
<div className="mt-1 flex flex-col gap-1">
{m.files.map((f) => {
const url = api.chat.fileUrl(m.roomId, f.id);
return f.type.startsWith("image/") && /png|jpeg|gif|webp/.test(f.type) ? (
<a key={f.id} href={url} target="_blank" rel="noopener noreferrer">
<a
key={f.id}
href={url}
target="_blank"
rel="noopener noreferrer"
onContextMenu={(e) => onMenu(e, f)}
>
<img
src={url}
alt={f.name}
@@ -441,6 +581,7 @@ function Files({ m, own }: { m: ChatMessage; own: boolean }) {
key={f.id}
href={url}
download={f.name}
onContextMenu={(e) => onMenu(e, f)}
className={`flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs ${own ? "bg-white/15" : "bg-surface-4"}`}
>
<FileText size={14} className="shrink-0" aria-hidden />
@@ -460,15 +601,22 @@ function GroupSettings({
dir,
onBack,
onLeft,
onOpenRoom,
}: {
chat: ChatState;
room: ChatRoom;
dir: Map<number, ChatPerson>;
onBack: () => void;
onLeft: () => void;
onOpenRoom: (roomId: number) => void;
}) {
const { t } = useTranslation("settings");
const owner = room.createdBy === chat.me;
const ctx = useContextMenu<number>();
const pick = (fn: () => void) => () => {
ctx.close();
fn();
};
const [name, setName] = useState(room.name);
const [adding, setAdding] = useState<Set<number>>(new Set());
const save = async (patch: { name?: string; add?: number[]; remove?: number[] }) => {
@@ -481,19 +629,7 @@ function GroupSettings({
}
};
const leave = async () => {
const ok = await confirmDialog({
title: t("sagi.people.leaveTitle"),
message: t("sagi.people.leaveBody", { name: roomTitle(room, chat.me, dir) }),
confirmLabel: t("sagi.people.leave"),
});
if (!ok) return;
try {
await api.chat.leave(room.id);
await chat.reload();
onLeft();
} catch (e) {
toast.error(e);
}
if (await leaveRoom(chat, room, roomTitle(room, chat.me, dir), t)) onLeft();
};
const outside = chat.people.filter((p) => !room.members.includes(p.id));
return (
@@ -534,7 +670,11 @@ function GroupSettings({
{room.members.map((id) => {
const p = dir.get(id);
return (
<li key={id} className="flex items-center gap-2 rounded-lg px-2 py-1">
<li
key={id}
className="flex items-center gap-2 rounded-lg px-2 py-1 hover:bg-surface-4"
onContextMenu={(e) => ctx.open(e, id)}
>
<UserAvatar
name={personName(p)}
userId={id}
@@ -600,6 +740,26 @@ function GroupSettings({
>
<LogOut size={14} /> {t("sagi.people.leave")}
</button>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="chat-member-menu">
{ctx.menu.item !== chat.me && (
<MenuItem onSelect={pick(() => void openDirect(chat, ctx.menu!.item, onOpenRoom))}>
{t("sagi.people.menuDm", { name: personName(dir.get(ctx.menu.item)) })}
</MenuItem>
)}
<MenuItem onSelect={pick(() => void copyText(personName(dir.get(ctx.menu!.item))))}>
{t("sagi.people.menuCopyName")}
</MenuItem>
{owner && ctx.menu.item !== chat.me && (
<>
<MenuSeparator />
<MenuItem danger onSelect={pick(() => void save({ remove: [ctx.menu!.item] }))}>
{t("sagi.people.remove", { name: personName(dir.get(ctx.menu.item)) })}
</MenuItem>
</>
)}
</ContextMenu>
)}
</div>
);
}
@@ -610,21 +770,61 @@ export function ChatRoomView({
room,
onClose,
onLeft,
onOpenRoom,
showSettings,
}: {
chat: ChatState;
room: ChatRoom;
onClose: () => void;
onLeft: () => void;
/** Switch the window to another room (a direct chat opened from a menu). */
onOpenRoom: (roomId: number) => void;
/** Changes when the rail's menu asks for this group's settings. */
showSettings?: number;
}) {
const { t } = useTranslation("settings");
const dir = useDirectory(chat);
const title = roomTitle(room, chat.me, dir);
const ctx = useContextMenu<{ m: ChatMessage; file?: ChatFile }>();
const pick = (fn: () => void) => () => {
ctx.close();
fn();
};
const textRef = useRef<HTMLTextAreaElement>(null);
const reply = (m: ChatMessage) => {
const who = personName(m.userId != null ? dir.get(m.userId) : undefined);
const quoted = (m.body || m.files.map((f) => f.name).join(", "))
.split("\n")
.slice(0, 4)
.map((l) => `> ${l}`)
.join("\n");
setDraft((d) => `${who}:\n${quoted}\n${d}`);
textRef.current?.focus();
};
const remove = async (m: ChatMessage) => {
const ok = await confirmDialog({
title: t("sagi.people.deleteTitle"),
message: t("sagi.people.deleteBody"),
confirmLabel: t("sagi.people.menuDelete"),
});
if (!ok) return;
try {
await api.chat.deleteMessage(room.id, m.id);
setMessages((prev) => prev.filter((x) => x.id !== m.id));
} catch (e) {
toast.error(e);
}
};
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [hasMore, setHasMore] = useState(false);
const [draft, setDraft] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [sending, setSending] = useState(false);
const [settings, setSettings] = useState(false);
// The rail's menu asked for the settings (after the room switch resets them).
useEffect(() => {
if (showSettings) setSettings(true);
}, [showSettings, room.id]);
const [drag, setDrag] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
@@ -655,6 +855,9 @@ export function ChatRoomView({
if (last) read(last.id);
})
.catch((e) => toast.error(e));
chat.onDeleted((roomId, messageId) => {
if (roomId === room.id) setMessages((prev) => prev.filter((x) => x.id !== messageId));
});
chat.onMessage((m) => {
if (m.roomId !== room.id) return;
setMessages((prev) => (prev.some((x) => x.id === m.id) ? prev : [...prev, m]));
@@ -663,6 +866,7 @@ export function ChatRoomView({
return () => {
live = false;
chat.onMessage(null);
chat.onDeleted(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room.id]);
@@ -743,6 +947,7 @@ export function ChatRoomView({
dir={dir}
onBack={() => setSettings(false)}
onLeft={onLeft}
onOpenRoom={onOpenRoom}
/>
) : (
<>
@@ -802,9 +1007,12 @@ export function ChatRoomView({
: "pet-chat-reply rounded-bl-md text-fg-primary"
}`}
title={formatTime(m.createdAt)}
onContextMenu={(e) => ctx.open(e, { m })}
>
{m.body && <Linkified text={m.body} />}
{m.files.length > 0 && <Files m={m} own={own} />}
{m.files.length > 0 && (
<Files m={m} own={own} onMenu={(e, f) => ctx.open(e, { m, file: f })} />
)}
</div>
</div>
);
@@ -853,6 +1061,7 @@ export function ChatRoomView({
<Paperclip size={16} />
</button>
<textarea
ref={textRef}
rows={1}
autoFocus
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
@@ -889,6 +1098,67 @@ export function ChatRoomView({
</div>
</>
)}
{ctx.menu &&
(() => {
const { m, file } = ctx.menu.item;
const own = m.userId === chat.me;
const sender = m.userId != null ? dir.get(m.userId) : undefined;
return (
<ContextMenu
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
testId="chat-message-menu"
>
{file && (
<>
<MenuItem
onSelect={pick(() =>
window.open(
api.chat.fileUrl(m.roomId, file.id),
"_blank",
"noopener,noreferrer"
)
)}
>
{t("sagi.people.menuOpenFile")}
</MenuItem>
<MenuItem
onSelect={pick(() => {
const a = document.createElement("a");
a.href = api.chat.fileUrl(m.roomId, file.id);
a.download = file.name;
a.click();
})}
>
{t("sagi.people.menuDownload")}
</MenuItem>
<MenuItem onSelect={pick(() => void copyText(file.name))}>
{t("sagi.people.menuCopyFileName")}
</MenuItem>
<MenuSeparator />
</>
)}
<MenuItem disabled={!m.body} onSelect={pick(() => void copyText(m.body))}>
{t("sagi.people.menuCopyText")}
</MenuItem>
<MenuItem onSelect={pick(() => reply(m))}>{t("sagi.people.menuReply")}</MenuItem>
{!own && room.kind === "group" && m.userId != null && (
<MenuItem onSelect={pick(() => void openDirect(chat, m.userId!, onOpenRoom))}>
{t("sagi.people.menuDm", { name: personName(sender) })}
</MenuItem>
)}
{own && (
<>
<MenuSeparator />
<MenuItem danger onSelect={pick(() => void remove(m))}>
{t("sagi.people.menuDelete")}
</MenuItem>
</>
)}
</ContextMenu>
);
})()}
</Shell>
);
}
+14 -1
View File
@@ -246,6 +246,8 @@ function SagiBody() {
// Server mode: chat with other users beside the pet's own (ChatHub.tsx).
const people = useChat();
const [pane, setPane] = useState<"pet" | "new" | number>("pet");
// Bumped by the rail's "Group settings": the room opens on its settings.
const [settingsAsk, setSettingsAsk] = useState(0);
const room = typeof pane === "number" ? people.rooms.find((r) => r.id === pane) : undefined;
// The room on screen: no notice for its messages.
const { setActive } = people;
@@ -436,7 +438,16 @@ function SagiBody() {
<SagiFlyout anchor={anchor} beside>
<div className="flex items-start gap-2">
{people.enabled && (
<ChatRail chat={people} active={pane} onSelect={setPane} petName={name} />
<ChatRail
chat={people}
active={pane}
onSelect={setPane}
petName={name}
onSettings={(id) => {
setPane(id);
setSettingsAsk((n) => n + 1);
}}
/>
)}
{people.enabled && pane === "new" ? (
<NewChat chat={people} onOpen={setPane} onClose={() => setOpen(false)} />
@@ -446,6 +457,8 @@ function SagiBody() {
room={room}
onClose={() => setOpen(false)}
onLeft={() => setPane("pet")}
onOpenRoom={setPane}
showSettings={settingsAsk}
/>
) : (
<PetChat
@@ -5,7 +5,8 @@
* direct chat for one person and makes a group for several; a room reads its
* messages (a group names who wrote each), marks them read, appends one that
* arrives live, and sends text with the attached files; the group settings
* scroll only one way; a new message raises a notice that opens its room.
* scroll only one way; a new message raises a notice that opens its room; and
* the right-click menus of a room, a message, a file and a group member.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -23,6 +24,7 @@ const api = vi.hoisted(() => ({
read: vi.fn(),
updateGroup: vi.fn(),
leave: vi.fn(),
deleteMessage: vi.fn(),
fileUrl: (r: number, f: string) => `/api/chat/rooms/${r}/files/${f}`,
},
users: { avatarUrl: () => null },
@@ -32,6 +34,10 @@ vi.mock("../../../lib/api", async (orig) => ({
...(await orig<Record<string, unknown>>()),
api,
}));
const clip = vi.hoisted(() => ({ copyText: vi.fn(async () => true) }));
vi.mock("../../../lib/clipboard", () => clip);
const confirm = vi.hoisted(() => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/confirm", () => confirm);
vi.mock("../../AuthGate", () => ({
useAuth: () => ({
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } },
@@ -80,6 +86,9 @@ function state(over: Partial<ChatState> = {}): ChatState {
setActive: vi.fn(),
notice: null,
dismissNotice: vi.fn(),
onDeleted: vi.fn(),
muted: new Set<number>(),
toggleMute: vi.fn(),
...over,
};
}
@@ -121,7 +130,15 @@ describe("ChatHub", () => {
hasMore: false,
});
api.chat.read.mockResolvedValue({ lastReadId: 2 });
render(<ChatRoomView chat={chat} room={group} onClose={() => {}} onLeft={() => {}} />);
render(
<ChatRoomView
chat={chat}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={() => {}}
/>
);
expect(await screen.findByText("chào")).toBeTruthy();
expect(screen.getByText("An")).toBeTruthy(); // sender of a group message
expect(api.chat.read).toHaveBeenCalledWith(7, 2);
@@ -146,7 +163,15 @@ describe("ChatHub", () => {
it("keeps the group settings to one vertical scroll, the name field shrinking", async () => {
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
render(<ChatRoomView chat={state()} room={group} onClose={() => {}} onLeft={() => {}} />);
render(
<ChatRoomView
chat={state()}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={() => {}}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Group settings" }));
const name = screen.getAllByLabelText("Group name (optional)")[0]!;
expect(name.className).toContain("min-w-0");
@@ -171,4 +196,116 @@ describe("ChatHub", () => {
fireEvent.click(screen.getByRole("button", { name: "Open the chat with An · Team" }));
expect(onOpen).toHaveBeenCalledWith(7);
});
it("right-click on a room: mark read, mute, copy its name, settings and leave", async () => {
const chat = state({ rooms: [{ ...group, lastMessage: msg(5, 2, "x") }, dm] });
const onSelect = vi.fn();
const onSettings = vi.fn();
api.chat.read.mockResolvedValue({ lastReadId: 5 });
api.chat.leave.mockResolvedValue({ ok: true });
render(
<ChatRail chat={chat} active={7} onSelect={onSelect} petName="Sagi" onSettings={onSettings} />
);
const open = () =>
fireEvent.contextMenu(screen.getByRole("button", { name: "Team, 2 unread" }));
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Mark as read" }));
expect(chat.markRead).toHaveBeenCalledWith(7, 5);
expect(api.chat.read).toHaveBeenCalledWith(7, 5);
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Mute notifications" }));
expect(chat.toggleMute).toHaveBeenCalledWith(7);
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Copy name" }));
expect(clip.copyText).toHaveBeenCalledWith("Team");
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Group settings" }));
expect(onSettings).toHaveBeenCalledWith(7);
open();
fireEvent.click(screen.getByRole("menuitem", { name: "Leave group" }));
await waitFor(() => expect(api.chat.leave).toHaveBeenCalledWith(7));
await waitFor(() => expect(onSelect).toHaveBeenCalledWith("pet"));
// A direct chat has no group items.
fireEvent.contextMenu(screen.getByRole("button", { name: "An" }));
expect(screen.queryByRole("menuitem", { name: "Leave group" })).toBeNull();
});
it("right-click on a message: copy, reply, message the sender, delete your own", async () => {
const chat = state();
const onOpenRoom = vi.fn();
api.chat.messages.mockResolvedValue({
messages: [
msg(1, 2, "của An"),
{
...msg(2, 1, "của tôi"),
files: [{ id: "f1", name: "a.pdf", type: "application/pdf", size: 9 }],
},
],
hasMore: false,
});
api.chat.read.mockResolvedValue({ lastReadId: 2 });
api.chat.openDm.mockResolvedValue({ room: dm });
api.chat.deleteMessage.mockResolvedValue({ ok: true });
render(
<ChatRoomView
chat={chat}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={onOpenRoom}
/>
);
fireEvent.contextMenu(await screen.findByText("của An"));
expect(screen.queryByRole("menuitem", { name: "Delete message" })).toBeNull();
fireEvent.click(screen.getByRole("menuitem", { name: "Copy text" }));
expect(clip.copyText).toHaveBeenCalledWith("của An");
fireEvent.contextMenu(screen.getByText("của An"));
fireEvent.click(screen.getByRole("menuitem", { name: "Reply" }));
expect((screen.getByLabelText("Message Team") as HTMLTextAreaElement).value).toBe(
"An:\n> của An\n"
);
fireEvent.contextMenu(screen.getByText("của An"));
fireEvent.click(screen.getByRole("menuitem", { name: "Message An privately" }));
await waitFor(() => expect(onOpenRoom).toHaveBeenCalledWith(8));
// A file has its own items.
fireEvent.contextMenu(screen.getByText("a.pdf"));
expect(screen.getByRole("menuitem", { name: "Download" })).toBeTruthy();
fireEvent.click(screen.getByRole("menuitem", { name: "Copy file name" }));
expect(clip.copyText).toHaveBeenCalledWith("a.pdf");
fireEvent.contextMenu(screen.getByText("của tôi"));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete message" }));
await waitFor(() => expect(api.chat.deleteMessage).toHaveBeenCalledWith(7, 2));
await waitFor(() => expect(screen.queryByText("của tôi")).toBeNull());
});
it("right-click on a group member: message them; the creator may remove them", async () => {
const onOpenRoom = vi.fn();
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
api.chat.openDm.mockResolvedValue({ room: dm });
api.chat.updateGroup.mockResolvedValue({ room: { ...group, members: [1, 3] } });
render(
<ChatRoomView
chat={state()}
room={group}
onClose={() => {}}
onLeft={() => {}}
onOpenRoom={onOpenRoom}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Group settings" }));
const row = screen
.getAllByText("An")
.find((el) => el.closest("li"))!
.closest("li")!;
fireEvent.contextMenu(row);
fireEvent.click(screen.getByRole("menuitem", { name: "Remove An" }));
await waitFor(() => expect(api.chat.updateGroup).toHaveBeenCalledWith(7, { remove: [2] }));
fireEvent.contextMenu(row);
fireEvent.click(screen.getByRole("menuitem", { name: "Message An privately" }));
await waitFor(() => expect(onOpenRoom).toHaveBeenCalledWith(8));
});
});
+49 -1
View File
@@ -11,7 +11,10 @@
* room), plays the user's notification sound when notifications are on, and —
* while the tab is hidden and the browser allows it — shows a local system
* notification (never through the server's push relay, which would reach
* other users' devices). Off in local mode — one person has no one to chat with.
* other users' devices). A room can be muted (no notice, no sound — its
* unread count still shows), kept per browser in `agenthub.chat.muted`; a
* `chat_deleted` frame drops a message from the open view and re-reads the
* list. Off in local mode — one person has no one to chat with.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -44,8 +47,23 @@ export type ChatState = {
setActive: (roomId: number | null) => void;
notice: ChatNotice | null;
dismissNotice: () => void;
/** The open room's view, told when a message of it is deleted. */
onDeleted: (fn: ((roomId: number, messageId: number) => void) | null) => void;
/** Rooms whose new messages raise no notice or sound. */
muted: Set<number>;
toggleMute: (roomId: number) => void;
};
const MUTED_KEY = "agenthub.chat.muted";
function readMuted(): Set<number> {
try {
const v = JSON.parse(localStorage.getItem(MUTED_KEY) || "[]");
return new Set(Array.isArray(v) ? v.filter((x) => Number.isInteger(x)) : []);
} catch {
return new Set();
}
}
/** What a notice says of a message: its text, else its files. */
function preview(m: ChatMessage): string {
const text = m.body.trim().replace(/\s+/g, " ");
@@ -78,6 +96,10 @@ export function useChat(): ChatState {
const [people, setPeople] = useState<ChatPerson[]>([]);
const listener = useRef<((m: ChatMessage) => void) | null>(null);
const active = useRef<number | null>(null);
const deletedListener = useRef<((roomId: number, messageId: number) => void) | null>(null);
const [muted, setMuted] = useState<Set<number>>(readMuted);
const mutedRef = useRef(muted);
mutedRef.current = muted;
const [notice, setNotice] = useState<ChatNotice | null>(null);
// Names for a notice, read inside the socket handler.
const lookup = useRef({ rooms, people });
@@ -108,6 +130,12 @@ export function useChat(): ChatState {
void reload();
return;
}
if (msg.type === "chat_deleted") {
const d = msg.data as { roomId: number; messageId?: number };
if (d.messageId != null) deletedListener.current?.(d.roomId, d.messageId);
void reload();
return;
}
if (msg.type !== "chat_message") return;
const m = (msg.data as { message?: ChatMessage }).message;
if (!m) return;
@@ -135,6 +163,7 @@ export function useChat(): ChatState {
if (m.userId === me || m.kind !== "text") return;
const hidden = document.visibilityState !== "visible";
if (active.current === m.roomId && !hidden) return;
if (mutedRef.current.has(m.roomId)) return;
const sender = lookup.current.people.find((p) => p.id === m.userId);
const from = sender ? sender.displayName || sender.username : "?";
const room = lookup.current.rooms.find((r) => r.id === m.roomId);
@@ -174,6 +203,22 @@ export function useChat(): ChatState {
if (roomId != null) setNotice((n) => (n && n.roomId === roomId ? null : n));
}, []);
const dismissNotice = useCallback(() => setNotice(null), []);
const onDeleted = useCallback((fn: ((roomId: number, messageId: number) => void) | null) => {
deletedListener.current = fn;
}, []);
const toggleMute = useCallback((roomId: number) => {
setMuted((prev) => {
const next = new Set(prev);
if (next.has(roomId)) next.delete(roomId);
else next.add(roomId);
try {
localStorage.setItem(MUTED_KEY, JSON.stringify([...next]));
} catch {
// private mode: muted for this tab only
}
return next;
});
}, []);
// A notice goes away by itself.
useEffect(() => {
if (!notice) return;
@@ -195,5 +240,8 @@ export function useChat(): ChatState {
setActive,
notice,
dismissNotice,
onDeleted,
muted,
toggleMute,
};
}