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:
@@ -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` frames go only to the ids in `to` (`exactAudience`). 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. 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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,7 +262,23 @@
|
||||
"sysLeft": "{{who}} left",
|
||||
"sysAdded": "{{who}} added {{names}}",
|
||||
"sysRemoved": "{{who}} removed {{names}}",
|
||||
"openFrom": "Open the chat with {{name}}"
|
||||
"openFrom": "Open the chat with {{name}}",
|
||||
"menuOpen": "Open",
|
||||
"menuRead": "Mark as read",
|
||||
"menuReadAll": "Mark all chats as read",
|
||||
"menuMute": "Mute notifications",
|
||||
"menuUnmute": "Unmute notifications",
|
||||
"menuCopyName": "Copy name",
|
||||
"menuPet": "Chat with {{name}}",
|
||||
"menuDm": "Message {{name}} privately",
|
||||
"menuOpenFile": "Open in a new tab",
|
||||
"menuDownload": "Download",
|
||||
"menuCopyFileName": "Copy file name",
|
||||
"menuCopyText": "Copy text",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
|
||||
@@ -262,7 +262,23 @@
|
||||
"sysLeft": "{{who}} đã rời nhóm",
|
||||
"sysAdded": "{{who}} đã thêm {{names}}",
|
||||
"sysRemoved": "{{who}} đã xoá {{names}}",
|
||||
"openFrom": "Mở chat với {{name}}"
|
||||
"openFrom": "Mở chat với {{name}}",
|
||||
"menuOpen": "Mở",
|
||||
"menuRead": "Đánh dấu đã đọc",
|
||||
"menuReadAll": "Đánh dấu đã đọc tất cả",
|
||||
"menuMute": "Tắt thông báo",
|
||||
"menuUnmute": "Bật lại thông báo",
|
||||
"menuCopyName": "Sao chép tên",
|
||||
"menuPet": "Chat với {{name}}",
|
||||
"menuDm": "Nhắn riêng cho {{name}}",
|
||||
"menuOpenFile": "Mở trong tab mới",
|
||||
"menuDownload": "Tải về",
|
||||
"menuCopyFileName": "Sao chép tên file",
|
||||
"menuCopyText": "Sao chép nội dung",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
|
||||
@@ -1645,6 +1645,9 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ upTo }),
|
||||
}),
|
||||
/** Your own message only (403 otherwise). */
|
||||
deleteMessage: (roomId: number, messageId: number) =>
|
||||
request<{ ok: true }>(`/chat/rooms/${roomId}/messages/${messageId}`, { method: "DELETE" }),
|
||||
fileUrl: (roomId: number, fileId: string) =>
|
||||
`${BASE}/chat/rooms/${roomId}/files/${encodeURIComponent(fileId)}`,
|
||||
},
|
||||
|
||||
@@ -1918,12 +1918,14 @@ export interface WSMessage {
|
||||
* remote_source.status → RemoteSourceStatusPayload;
|
||||
* remote_data.updated → RemoteDataUpdatedPayload;
|
||||
* lane_update → { lane?: Lane; removed?: number };
|
||||
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to }. */
|
||||
* chat_message → { roomId, message: ChatMessage, to }; chat_room → { roomId, to };
|
||||
* chat_deleted → { roomId, messageId, to }. */
|
||||
type:
|
||||
| "session_created"
|
||||
| "session_updated"
|
||||
| "chat_message"
|
||||
| "chat_room"
|
||||
| "chat_deleted"
|
||||
| "agent_created"
|
||||
| "agent_updated"
|
||||
| "new_event"
|
||||
@@ -1972,7 +1974,7 @@ export interface WSMessage {
|
||||
| LaneRuntimePayload
|
||||
| LaneHookResultPayload
|
||||
/** chat_message / chat_room (lib/api.ts ChatMessage). */
|
||||
| { roomId: number; message?: unknown; to?: number[] };
|
||||
| { roomId: number; message?: unknown; messageId?: number; to?: number[] };
|
||||
/** ISO timestamp the server broadcast this message (not necessarily the
|
||||
* same instant the underlying event occurred). */
|
||||
timestamp: string;
|
||||
|
||||
+2
-1
@@ -3509,11 +3509,12 @@ PATCH /api/chat/rooms/:id {name?, add?, remove?} — the g
|
||||
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
|
||||
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. 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}`) or `chat_room` (`{roomId, 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). 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.
|
||||
|
||||
### Session snapshots
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* @description Chat between users (server/lib/chat.js, server/routes/chat.js):
|
||||
* 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, local mode refused, and
|
||||
* files served only to members under a sandbox CSP, a message deleted only by
|
||||
* its author, 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>
|
||||
*/
|
||||
@@ -151,6 +152,22 @@ describe("chat", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lets only the author delete a message, its files too", async () => {
|
||||
const fd = new FormData();
|
||||
fd.append("body", "xoá tôi");
|
||||
fd.append("files", new Blob(["x"], { type: "text/plain" }), "a.txt");
|
||||
const m = (await call(2, "POST", `/rooms/${group}/messages`, fd)).body.message;
|
||||
assert.equal((await call(3, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 403);
|
||||
assert.equal((await call(1, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 404);
|
||||
assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${m.id}`)).status, 200);
|
||||
const left = (await call(2, "GET", `/rooms/${group}/messages`)).body.messages;
|
||||
assert.ok(!left.some((x) => x.id === m.id));
|
||||
assert.equal((await call(2, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 404);
|
||||
// A system line is nobody's to delete.
|
||||
const sys = left.find((x) => x.kind === "system");
|
||||
assert.equal((await call(2, "DELETE", `/rooms/${group}/messages/${sys.id}`)).status, 403);
|
||||
});
|
||||
|
||||
it("refuses an empty message", async () => {
|
||||
assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400);
|
||||
});
|
||||
|
||||
+27
-1
@@ -5,7 +5,7 @@
|
||||
* 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. A message is text (≤ 8000 chars) and up to
|
||||
* renames; any member may leave; anyone deletes their own messages (only). 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
|
||||
@@ -349,6 +349,31 @@ function markRead(me, roomId, upTo) {
|
||||
return { lastReadId: Math.max(n, 0) };
|
||||
}
|
||||
|
||||
/**
|
||||
* `me` deletes their OWN text message (and its files) — never anyone else's,
|
||||
* an admin included. Answers who to tell (every member).
|
||||
*/
|
||||
function removeMessage(me, roomId, messageId) {
|
||||
const room = roomFor(me, roomId);
|
||||
const id = Number(messageId);
|
||||
const m = Number.isSafeInteger(id)
|
||||
? sql("SELECT * FROM chat_messages WHERE id = ? AND room_id = ?").get(id, room.id)
|
||||
: null;
|
||||
if (!m) throw err("ENOTFOUND", "no such message", 404);
|
||||
if (m.user_id !== me || m.kind !== "text")
|
||||
throw err("EFORBIDDEN", "only your own messages can be deleted", 403);
|
||||
const files = sql("SELECT id FROM chat_files WHERE message_id = ?").all(id);
|
||||
db().transaction(() => {
|
||||
sql("DELETE FROM chat_messages WHERE id = ?").run(id);
|
||||
const last = sql("SELECT MAX(created_at) AS at FROM chat_messages WHERE room_id = ?").get(
|
||||
room.id
|
||||
);
|
||||
sql("UPDATE chat_rooms SET last_message_at = ? WHERE id = ?").run(last.at ?? null, room.id);
|
||||
})();
|
||||
for (const f of files) fs.rmSync(path.join(fileDir(room.id), f.id), { force: true });
|
||||
return { notify: memberIds(room.id) };
|
||||
}
|
||||
|
||||
/** A file of a room `me` is in: `{path, name, type, inline}`. */
|
||||
function file(me, roomId, fileId) {
|
||||
const room = roomFor(me, roomId);
|
||||
@@ -372,6 +397,7 @@ module.exports = {
|
||||
messages,
|
||||
post,
|
||||
markRead,
|
||||
removeMessage,
|
||||
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") {
|
||||
if (type === "chat_message" || type === "chat_room" || type === "chat_deleted") {
|
||||
return new Set(Array.isArray(d.to) ? d.to : []);
|
||||
}
|
||||
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
|
||||
|
||||
@@ -239,6 +239,25 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}/messages/{messageId}": {
|
||||
delete: {
|
||||
tags: ["Chat"],
|
||||
summary: "Delete your own message",
|
||||
description:
|
||||
"Only the message's author (`403 EFORBIDDEN`, an admin included); its files go with it. Broadcasts `chat_deleted` to the members.",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "messageId", in: "path", required: true, schema: { type: "integer" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Deleted",
|
||||
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
|
||||
},
|
||||
...fail([403, 404]),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}/read": {
|
||||
post: {
|
||||
tags: ["Chat"],
|
||||
|
||||
+12
-1
@@ -6,7 +6,7 @@
|
||||
* multipart `POST`), read marks and a room's files. Everything acts on the
|
||||
* CALLER's own rooms (`server/lib/chat.js`): a room they are not in is 404,
|
||||
* whatever their role. Local mode has one person and no chat (`400 ELOCALMODE`).
|
||||
* Each change is broadcast as `chat_message` / `chat_room` to exactly the
|
||||
* Each change is broadcast as `chat_message` / `chat_room` / `chat_deleted` to exactly the
|
||||
* users concerned (`to`, `scope.exactAudience`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -139,6 +139,17 @@ router.post("/rooms/:id/messages", sameOriginGuard, upload, (req, res) =>
|
||||
})(req, res)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/rooms/:id/messages/:messageId",
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => {
|
||||
const { notify } = chat.removeMessage(me, req.params.id, req.params.messageId);
|
||||
const roomId = Number(req.params.id);
|
||||
tell("chat_deleted", { roomId, messageId: Number(req.params.messageId) }, notify);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/rooms/:id/read",
|
||||
sameOriginGuard,
|
||||
|
||||
Reference in New Issue
Block a user