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