Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db2e61a74b | |||
| 03ebea1d92 | |||
| c797e9868e | |||
| 0016b6a427 | |||
| 0d3e92cfda |
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.65 (2026-11-03) — chat between users (direct + groups) in the pet window, members only._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.8.66 (2026-11-03) — chat: delete for yourself, purge what everyone deleted, Seen, chat button without the pet._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
@@ -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; "delete chat" is the caller's alone (`cleared_id`/`hidden`), and only what EVERY member deleted is purged for real (`purge` — messages, files, a direct chat both deleted). A read mark never passes the room's newest message (it is what others see as Seen). 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.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @file ChatDock.tsx
|
||||
* @description Chat between users for someone who hid the pet (server mode):
|
||||
* a round 💬 button in the bottom-right corner, above the status bar, with
|
||||
* the unread count, that opens the same chat window the pet has (`ChatHub.tsx`
|
||||
* — rooms rail without the pet, a room, New chat). A new message from someone
|
||||
* shows as the same card above the button while the window is closed. Mounted
|
||||
* by `SagiMount` only while the pet is off, so the two never both run.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MessagesSquare } from "lucide-react";
|
||||
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "./ChatHub";
|
||||
import { useChat } from "./useChat";
|
||||
|
||||
export function ChatDock() {
|
||||
const { t } = useTranslation("settings");
|
||||
const chat = useChat();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pane, setPane] = useState<"new" | number>("new");
|
||||
const [settingsAsk, setSettingsAsk] = useState(0);
|
||||
const room = typeof pane === "number" ? chat.rooms.find((r) => r.id === pane) : undefined;
|
||||
|
||||
// Opening with nothing picked: the newest chat, else New chat.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (typeof pane === "number" && !room) setPane(chat.rooms[0]?.id ?? "new");
|
||||
}, [open, pane, room, chat.rooms]);
|
||||
useEffect(() => {
|
||||
if (open && pane === "new" && chat.rooms[0]) setPane(chat.rooms[0].id);
|
||||
// Only when the window opens.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const { setActive } = chat;
|
||||
useEffect(() => {
|
||||
setActive(open && typeof pane === "number" ? pane : null);
|
||||
}, [open, pane, setActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
if (!chat.enabled) return null;
|
||||
return (
|
||||
<div className="fixed bottom-10 right-4 z-40 flex flex-col items-end gap-2">
|
||||
{open && (
|
||||
<div className="flex items-start gap-2">
|
||||
<ChatRail
|
||||
chat={chat}
|
||||
active={pane}
|
||||
onSelect={(v) => setPane(v === "pet" ? "new" : v)}
|
||||
onSettings={(id) => {
|
||||
setPane(id);
|
||||
setSettingsAsk((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
{room ? (
|
||||
<ChatRoomView
|
||||
key={`${room.id}:${room.clearedId ?? 0}`}
|
||||
chat={chat}
|
||||
room={room}
|
||||
onClose={() => setOpen(false)}
|
||||
onLeft={() => setPane("new")}
|
||||
onOpenRoom={setPane}
|
||||
showSettings={settingsAsk}
|
||||
/>
|
||||
) : (
|
||||
<NewChat chat={chat} onOpen={setPane} onClose={() => setOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!open && chat.notice && (
|
||||
<ChatNotice
|
||||
notice={chat.notice}
|
||||
onDismiss={chat.dismissNotice}
|
||||
onOpen={(roomId) => {
|
||||
chat.dismissNotice();
|
||||
setPane(roomId);
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="relative flex h-11 w-11 items-center justify-center rounded-full bg-accent-solid text-white shadow-lg transition-opacity hover:opacity-90"
|
||||
aria-label={
|
||||
chat.unread ? t("sagi.people.dockUnread", { n: chat.unread }) : t("sagi.people.dock")
|
||||
}
|
||||
title={t("sagi.people.dock")}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<MessagesSquare size={20} />
|
||||
{chat.unread > 0 && (
|
||||
<span className="absolute -right-1 -top-1 min-w-[1.25rem] rounded-full bg-status-danger px-1 text-center text-[10px] font-semibold leading-5 text-white">
|
||||
{chat.unread > 99 ? "99+" : chat.unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
* demand, text with links, pasted / dropped / attached files (images shown
|
||||
* inline, anything else a download, all served only to the room's members),
|
||||
* and for a group its settings — the creator renames it and adds or removes
|
||||
* people; anyone may leave. `NewChat` picks people: one person opens the
|
||||
* people; anyone may leave. `ChatNotice` is the card a new message raises on
|
||||
* the pet while its window is closed. `NewChat` picks people: one person opens the
|
||||
* direct chat with them, more (or a name) makes a group. Only a room's members
|
||||
* ever see it; an admin has no view of other people's chats.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -17,6 +18,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { flushSync } from "react-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
@@ -28,21 +30,30 @@ 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";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import i18n from "../../i18n";
|
||||
import type { ChatState } from "./useChat";
|
||||
|
||||
const MAX_FILES = 10;
|
||||
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/** Shown name of a person (display name, else login). */
|
||||
/** Shown name of a person (display name, else login); an account deleted since says so. */
|
||||
const personName = (p: Pick<ChatPerson, "username" | "displayName"> | undefined) =>
|
||||
p ? p.displayName || p.username : "?";
|
||||
p ? p.displayName || p.username : i18n.t("settings:sagi.people.deletedUser");
|
||||
|
||||
/** Everyone the view may need a name for: the people list plus the viewer. */
|
||||
function useDirectory(chat: ChatState) {
|
||||
@@ -61,9 +72,8 @@ function useDirectory(chat: ChatState) {
|
||||
}, [chat.people, self]);
|
||||
}
|
||||
|
||||
/** The other person of a direct chat. */
|
||||
const otherOf = (room: ChatRoom, me: number | null) =>
|
||||
room.members.find((id) => id !== me) ?? room.members[0] ?? -1;
|
||||
/** The other person of a direct chat (-1: their account was deleted). */
|
||||
const otherOf = (room: ChatRoom, me: number | null) => room.members.find((id) => id !== me) ?? -1;
|
||||
|
||||
function roomTitle(room: ChatRoom, me: number | null, dir: Map<number, ChatPerson>) {
|
||||
if (room.kind === "dm") return personName(dir.get(otherOf(room, me)));
|
||||
@@ -74,6 +84,95 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** "Delete chat" for the viewer only, after asking. True when done. */
|
||||
async function clearRoom(
|
||||
chat: ChatState,
|
||||
room: ChatRoom,
|
||||
title: string,
|
||||
t: (k: string, o?: Record<string, unknown>) => string
|
||||
): Promise<boolean> {
|
||||
const dm = room.kind === "dm";
|
||||
const ok = await confirmDialog({
|
||||
title: t(dm ? "sagi.people.clearDmTitle" : "sagi.people.clearGroupTitle"),
|
||||
message: t(dm ? "sagi.people.clearDmBody" : "sagi.people.clearGroupBody", { name: title }),
|
||||
confirmLabel: t(dm ? "sagi.people.menuClearDm" : "sagi.people.menuClearGroup"),
|
||||
});
|
||||
if (!ok) return false;
|
||||
try {
|
||||
await api.chat.clear(room.id);
|
||||
await chat.reload();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The group's creator deletes it for everyone, after asking. True when done. */
|
||||
async function disbandRoom(
|
||||
chat: ChatState,
|
||||
room: ChatRoom,
|
||||
title: string,
|
||||
t: (k: string, o?: Record<string, unknown>) => string
|
||||
): Promise<boolean> {
|
||||
const ok = await confirmDialog({
|
||||
title: t("sagi.people.disbandTitle"),
|
||||
message: t("sagi.people.disbandBody", { name: title }),
|
||||
confirmLabel: t("sagi.people.disband"),
|
||||
});
|
||||
if (!ok) return false;
|
||||
try {
|
||||
await api.chat.disband(room.id);
|
||||
await chat.reload();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every message of a room read, here and on the server. */
|
||||
function readAll(chat: ChatState, room: ChatRoom) {
|
||||
const upTo = room.lastMessage?.id;
|
||||
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,
|
||||
@@ -112,39 +211,94 @@ const Badge = ({ n }: { n: number }) =>
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
/** A new message from someone, on the pet while its window is closed: opens the room. */
|
||||
export function ChatNotice({
|
||||
notice,
|
||||
onOpen,
|
||||
onDismiss,
|
||||
}: {
|
||||
notice: NonNullable<ChatState["notice"]>;
|
||||
onOpen: (roomId: number) => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex w-72 items-start gap-2 rounded-2xl border border-border-light px-3 py-2.5 shadow-2xl animate-slide-up"
|
||||
role="status"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => onOpen(notice.roomId)}
|
||||
aria-label={t("sagi.people.openFrom", { name: notice.title })}
|
||||
>
|
||||
<span className="block truncate text-xs font-semibold text-fg-primary">
|
||||
💬 {notice.title}
|
||||
</span>
|
||||
<span className="mt-0.5 line-clamp-2 block break-words text-xs text-fg-secondary [overflow-wrap:anywhere]">
|
||||
{notice.text}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-full p-1 text-fg-muted hover:text-fg-primary"
|
||||
onClick={onDismiss}
|
||||
aria-label={t("sagi.people.close")}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The column beside the window: the pet, the rooms, and New chat. */
|
||||
export function ChatRail({
|
||||
chat,
|
||||
active,
|
||||
onSelect,
|
||||
petName,
|
||||
onSettings,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
/** `"pet"`, `"new"` or a room id. */
|
||||
active: "pet" | "new" | number;
|
||||
onSelect: (v: "pet" | "new" | number) => void;
|
||||
petName: string;
|
||||
/** The pet's name; absent when the pet is hidden (the chat dock): no pet item. */
|
||||
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 (
|
||||
<nav
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-14 flex-col items-center gap-2 overflow-y-auto rounded-3xl border border-border-light py-3 shadow-2xl"
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-14 flex-col items-center gap-2 overflow-y-auto overflow-x-hidden rounded-3xl border border-border-light py-3 shadow-2xl"
|
||||
aria-label={t("sagi.people.rail")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect("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}
|
||||
aria-pressed={active === "pet"}
|
||||
>
|
||||
<span aria-hidden>🐾</span>
|
||||
</button>
|
||||
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
|
||||
{petName && (
|
||||
<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}
|
||||
aria-pressed={active === "pet"}
|
||||
>
|
||||
<span aria-hidden>🐾</span>
|
||||
</button>
|
||||
)}
|
||||
{petName && <span className="h-px w-8 shrink-0 bg-border" aria-hidden />}
|
||||
{chat.rooms.map((r) => {
|
||||
const title = roomTitle(r, chat.me, dir);
|
||||
return (
|
||||
@@ -152,6 +306,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}
|
||||
@@ -159,18 +314,108 @@ 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>
|
||||
<MenuSeparator />
|
||||
{menuRoom.kind === "group" && (
|
||||
<MenuItem onSelect={pick(() => onSettings?.(menuRoom.id))}>
|
||||
{t("sagi.people.settings")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={pick(() => {
|
||||
void clearRoom(chat, menuRoom, menuTitle, t).then((done) => {
|
||||
if (done && menuRoom.kind === "dm" && active === menuRoom.id) onSelect("pet");
|
||||
});
|
||||
})}
|
||||
>
|
||||
{t(
|
||||
menuRoom.kind === "dm" ? "sagi.people.menuClearDm" : "sagi.people.menuClearGroup"
|
||||
)}
|
||||
</MenuItem>
|
||||
{menuRoom.kind === "group" && (
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={pick(() => {
|
||||
void leaveRoom(chat, menuRoom, menuTitle, t).then((left) => {
|
||||
if (left && active === menuRoom.id) onSelect("pet");
|
||||
});
|
||||
})}
|
||||
>
|
||||
{t("sagi.people.leave")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuRoom.kind === "group" && menuRoom.createdBy === chat.me && (
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={pick(() => {
|
||||
void disbandRoom(chat, menuRoom, menuTitle, t).then((done) => {
|
||||
if (done && active === menuRoom.id) onSelect("pet");
|
||||
});
|
||||
})}
|
||||
>
|
||||
{t("sagi.people.disband")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{petName && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -214,10 +459,13 @@ function PeoplePicker({
|
||||
people,
|
||||
picked,
|
||||
onToggle,
|
||||
inline = false,
|
||||
}: {
|
||||
people: ChatPerson[];
|
||||
picked: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
/** Inside a panel that scrolls itself: the list grows, no scroll of its own. */
|
||||
inline?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [q, setQ] = useState("");
|
||||
@@ -225,15 +473,17 @@ function PeoplePicker({
|
||||
`${p.displayName ?? ""} ${p.username}`.toLowerCase().includes(q.trim().toLowerCase())
|
||||
);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className={`flex flex-col gap-2 ${inline ? "" : "min-h-0 flex-1"}`}>
|
||||
<input
|
||||
className="input text-sm"
|
||||
className="input w-full min-w-0 text-sm"
|
||||
placeholder={t("sagi.people.search")}
|
||||
aria-label={t("sagi.people.search")}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||
<ul
|
||||
className={`space-y-0.5 overflow-x-hidden ${inline ? "" : "min-h-0 flex-1 overflow-y-auto"}`}
|
||||
>
|
||||
{shown.map((p) => (
|
||||
<li key={p.id}>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-4">
|
||||
@@ -271,6 +521,11 @@ export function NewChat({
|
||||
const { t } = useTranslation("settings");
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set());
|
||||
const [name, setName] = useState("");
|
||||
// Accounts made since the window opened.
|
||||
const { reloadPeople } = chat;
|
||||
useEffect(() => {
|
||||
void reloadPeople();
|
||||
}, [reloadPeople]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const group = picked.size > 1 || name.trim() !== "";
|
||||
const toggle = (id: number) =>
|
||||
@@ -307,10 +562,14 @@ export function NewChat({
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 px-3 py-2">
|
||||
<p className="text-[11px] text-fg-muted">{t("sagi.people.newHint")}</p>
|
||||
<PeoplePicker people={chat.people} picked={picked} onToggle={toggle} />
|
||||
<PeoplePicker
|
||||
people={chat.people.filter((p) => !p.disabled)}
|
||||
picked={picked}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
{picked.size > 1 && (
|
||||
<input
|
||||
className="input text-sm"
|
||||
className="input w-full min-w-0 text-sm"
|
||||
placeholder={t("sagi.people.groupName")}
|
||||
aria-label={t("sagi.people.groupName")}
|
||||
maxLength={80}
|
||||
@@ -375,13 +634,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}
|
||||
@@ -394,6 +667,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 />
|
||||
@@ -413,15 +687,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[] }) => {
|
||||
@@ -434,23 +715,11 @@ 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));
|
||||
const outside = chat.people.filter((p) => !p.disabled && !room.members.includes(p.id));
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -467,14 +736,14 @@ function GroupSettings({
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input flex-1 text-sm"
|
||||
className="input min-w-0 flex-1 text-sm"
|
||||
aria-label={t("sagi.people.groupName")}
|
||||
placeholder={t("sagi.people.groupName")}
|
||||
maxLength={80}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn-secondary" disabled={name === room.name}>
|
||||
<button type="submit" className="btn-secondary shrink-0" disabled={name === room.name}>
|
||||
{t("sagi.people.rename")}
|
||||
</button>
|
||||
</form>
|
||||
@@ -487,7 +756,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}
|
||||
@@ -519,11 +792,12 @@ function GroupSettings({
|
||||
</ul>
|
||||
</div>
|
||||
{owner && outside.length > 0 && (
|
||||
<div className="flex min-h-[10rem] flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.people.add")}
|
||||
</p>
|
||||
<PeoplePicker
|
||||
inline
|
||||
people={outside}
|
||||
picked={adding}
|
||||
onToggle={(id) =>
|
||||
@@ -547,11 +821,51 @@ function GroupSettings({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-sm text-status-danger hover:bg-status-danger/10"
|
||||
className="flex shrink-0 items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-sm text-status-danger hover:bg-status-danger/10"
|
||||
onClick={() => void leave()}
|
||||
>
|
||||
<LogOut size={14} /> {t("sagi.people.leave")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-sm text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
onClick={() => void clearRoom(chat, room, roomTitle(room, chat.me, dir), t)}
|
||||
>
|
||||
{t("sagi.people.menuClearGroup")}
|
||||
</button>
|
||||
{owner && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-sm text-status-danger hover:bg-status-danger/10"
|
||||
onClick={() =>
|
||||
void disbandRoom(chat, room, roomTitle(room, chat.me, dir), t).then((done) => {
|
||||
if (done) onLeft();
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("sagi.people.disband")}
|
||||
</button>
|
||||
)}
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="chat-member-menu">
|
||||
{ctx.menu.item !== chat.me && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -562,21 +876,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);
|
||||
@@ -607,6 +961,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]));
|
||||
@@ -615,6 +972,7 @@ export function ChatRoomView({
|
||||
return () => {
|
||||
live = false;
|
||||
chat.onMessage(null);
|
||||
chat.onDeleted(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room.id]);
|
||||
@@ -624,13 +982,45 @@ export function ChatRoomView({
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
// Back on a hidden tab with this room open: what arrived meanwhile is read now.
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
const last = messages[messages.length - 1];
|
||||
if (last && last.id > (room.reads?.[String(chat.me)] ?? room.lastReadId)) read(last.id);
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
}, [messages, room.reads, room.lastReadId, chat.me, read]);
|
||||
|
||||
// "Seen": under your newest message, who has read up to it.
|
||||
const lastOwn = [...messages].reverse().find((m) => m.userId === chat.me && m.kind === "text");
|
||||
const seenBy = lastOwn
|
||||
? room.members.filter((id) => id !== chat.me && (room.reads?.[String(id)] ?? 0) >= lastOwn.id)
|
||||
: [];
|
||||
const others = room.members.filter((id) => id !== chat.me);
|
||||
const seenText =
|
||||
!lastOwn || !seenBy.length
|
||||
? null
|
||||
: room.kind === "dm" || seenBy.length === others.length
|
||||
? room.kind === "dm"
|
||||
? t("sagi.people.seen")
|
||||
: t("sagi.people.seenAll")
|
||||
: t("sagi.people.seenBy", {
|
||||
names: seenBy.map((id) => personName(dir.get(id))).join(", "),
|
||||
});
|
||||
|
||||
const older = async () => {
|
||||
const first = messages[0];
|
||||
if (!first) return;
|
||||
try {
|
||||
const r = await api.chat.messages(room.id, first.id);
|
||||
stick.current = false;
|
||||
setMessages((prev) => [...r.messages, ...prev]);
|
||||
// Keep the message being read where it is: the page grows above it.
|
||||
const el = listRef.current;
|
||||
const fromBottom = el ? el.scrollHeight - el.scrollTop : 0;
|
||||
flushSync(() => setMessages((prev) => [...r.messages, ...prev]));
|
||||
if (el) el.scrollTop = el.scrollHeight - fromBottom;
|
||||
setHasMore(r.hasMore);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
@@ -695,12 +1085,13 @@ export function ChatRoomView({
|
||||
dir={dir}
|
||||
onBack={() => setSettings(false)}
|
||||
onLeft={onLeft}
|
||||
onOpenRoom={onOpenRoom}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={listRef}
|
||||
className={`flex-1 space-y-1.5 overflow-y-auto px-3 py-3 ${drag ? "ring-2 ring-inset ring-accent" : ""}`}
|
||||
className={`min-h-0 flex-1 space-y-1.5 overflow-y-auto overflow-x-hidden px-3 py-3 ${drag ? "ring-2 ring-inset ring-accent" : ""}`}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
@@ -748,16 +1139,24 @@ export function ChatRoomView({
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm shadow-sm ${
|
||||
className={`max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm shadow-sm [overflow-wrap:anywhere] ${
|
||||
own
|
||||
? "rounded-br-md bg-accent-solid text-white"
|
||||
: "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>
|
||||
{m.id === lastOwn?.id && seenText && (
|
||||
<span className="mt-0.5 px-1 text-[10px] text-fg-muted" data-testid="chat-seen">
|
||||
{seenText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -805,6 +1204,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"
|
||||
@@ -841,6 +1241,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { Heart } from "lucide-react";
|
||||
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
|
||||
import { ChatRail, ChatRoomView, NewChat } from "./ChatHub";
|
||||
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "./ChatHub";
|
||||
import { useChat } from "./useChat";
|
||||
|
||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||
@@ -246,7 +246,14 @@ 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;
|
||||
useEffect(() => {
|
||||
setActive(open && typeof pane === "number" ? pane : null);
|
||||
}, [open, pane, setActive]);
|
||||
// A room that went away (left, removed): back to the pet.
|
||||
useEffect(() => {
|
||||
if (typeof pane === "number" && people.rooms.length && !room) setPane("pet");
|
||||
@@ -431,16 +438,29 @@ 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)} />
|
||||
) : people.enabled && room ? (
|
||||
<ChatRoomView
|
||||
// Cleared for me: read the (now shorter) history again.
|
||||
key={`${room.id}:${room.clearedId ?? 0}`}
|
||||
chat={people}
|
||||
room={room}
|
||||
onClose={() => setOpen(false)}
|
||||
onLeft={() => setPane("pet")}
|
||||
onOpenRoom={setPane}
|
||||
showSettings={settingsAsk}
|
||||
/>
|
||||
) : (
|
||||
<PetChat
|
||||
@@ -503,7 +523,21 @@ function SagiBody() {
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && brain.bubble && (
|
||||
{!open && !menu && people.notice && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<ChatNotice
|
||||
notice={people.notice}
|
||||
onDismiss={people.dismissNotice}
|
||||
onOpen={(roomId) => {
|
||||
people.dismissNotice();
|
||||
setPane(roomId);
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && !people.notice && brain.bubble && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SpeechBubble
|
||||
bubble={brain.bubble}
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
* @description Gate for the Sagi mascot: follows the enabled preference
|
||||
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
|
||||
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
|
||||
* WebSocket reducer.
|
||||
* WebSocket reducer. While it is off, the chat between users still has a
|
||||
* way in: `ChatDock` (its own chunk, server mode only).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
import { useAuth } from "../AuthGate";
|
||||
|
||||
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
|
||||
const ChatDock = lazy(() => import("./ChatDock").then((m) => ({ default: m.ChatDock })));
|
||||
|
||||
/** The mascot's enabled preference, followed live (Settings, other tabs). */
|
||||
export function useSagiEnabled(): boolean {
|
||||
@@ -20,7 +23,16 @@ export function useSagiEnabled(): boolean {
|
||||
}
|
||||
|
||||
export function SagiMount() {
|
||||
if (!useSagiEnabled()) return null;
|
||||
const { state } = useAuth();
|
||||
if (!useSagiEnabled()) {
|
||||
// The pet hidden: chat with other users from a button of its own.
|
||||
if (state.mode !== "server") return null;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ChatDock />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Sagi />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file ChatDock.test.tsx
|
||||
* @description The chat button shown while the pet is hidden (`ChatDock.tsx`,
|
||||
* mounted by `SagiMount`): server mode only, the unread count on it, and a
|
||||
* click opens the newest chat with the rooms rail beside it (no pet item).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } } as unknown,
|
||||
}));
|
||||
const api = vi.hoisted(() => ({
|
||||
chat: {
|
||||
rooms: vi.fn(),
|
||||
people: vi.fn(),
|
||||
messages: vi.fn(),
|
||||
read: vi.fn(),
|
||||
fileUrl: () => "",
|
||||
},
|
||||
users: { avatarUrl: () => null },
|
||||
me: { avatarUrl: () => null },
|
||||
}));
|
||||
vi.mock("../../AuthGate", () => ({ useAuth: () => ({ state: auth.state }) }));
|
||||
vi.mock("../../../lib/api", async (orig) => ({ ...(await orig<Record<string, unknown>>()), api }));
|
||||
|
||||
import { ChatDock } from "../ChatDock";
|
||||
|
||||
const room = {
|
||||
id: 7,
|
||||
kind: "dm" as const,
|
||||
name: "",
|
||||
createdBy: 1,
|
||||
createdAt: "2026-11-03T01:00:00Z",
|
||||
lastMessageAt: null,
|
||||
members: [1, 2],
|
||||
lastMessage: null,
|
||||
lastReadId: 0,
|
||||
unread: 3,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
auth.state = { mode: "server", user: { id: 1, username: "me", role: "developer" } };
|
||||
api.chat.rooms.mockResolvedValue({ rooms: [room] });
|
||||
api.chat.people.mockResolvedValue({
|
||||
people: [{ id: 2, username: "an", displayName: "An", avatarAt: null }],
|
||||
});
|
||||
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
|
||||
api.chat.read.mockResolvedValue({ lastReadId: 0 });
|
||||
});
|
||||
|
||||
describe("ChatDock", () => {
|
||||
it("shows the unread count and opens the newest chat", async () => {
|
||||
render(<ChatDock />);
|
||||
const btn = await screen.findByRole("button", { name: "Chats, 3 unread" });
|
||||
fireEvent.click(btn);
|
||||
await waitFor(() => expect(screen.getByRole("dialog", { name: "Chat with An" })).toBeTruthy());
|
||||
expect(screen.queryByText("🐾")).toBeNull();
|
||||
});
|
||||
|
||||
it("is not there in local mode", () => {
|
||||
auth.state = { mode: "local" };
|
||||
render(<ChatDock />);
|
||||
expect(screen.queryByRole("button", { name: /Chats/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,12 @@
|
||||
* lists the pet then the rooms with their unread counts; New chat opens a
|
||||
* 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.
|
||||
* arrives live, and sends text with the attached files; the group settings
|
||||
* scroll only one way; a new message raises a notice that opens its room; and
|
||||
* the right-click menus of a room, a message, a file and a group member —
|
||||
* deleting a chat for yourself and disbanding a group (its creator) included;
|
||||
* "Seen" under your newest message; a disabled account never offered for a
|
||||
* new chat, a deleted one named as such; and the rail without the pet.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -22,6 +27,9 @@ const api = vi.hoisted(() => ({
|
||||
read: vi.fn(),
|
||||
updateGroup: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
deleteMessage: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
disband: vi.fn(),
|
||||
fileUrl: (r: number, f: string) => `/api/chat/rooms/${r}/files/${f}`,
|
||||
},
|
||||
users: { avatarUrl: () => null },
|
||||
@@ -31,13 +39,17 @@ 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" } },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ChatRail, ChatRoomView, NewChat } from "../ChatHub";
|
||||
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "../ChatHub";
|
||||
|
||||
const msg = (id: number, userId: number, body: string): ChatMessage => ({
|
||||
id,
|
||||
@@ -76,6 +88,13 @@ function state(over: Partial<ChatState> = {}): ChatState {
|
||||
upsert: vi.fn(),
|
||||
onMessage: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
setActive: vi.fn(),
|
||||
notice: null,
|
||||
dismissNotice: vi.fn(),
|
||||
onDeleted: vi.fn(),
|
||||
muted: new Set<number>(),
|
||||
toggleMute: vi.fn(),
|
||||
reloadPeople: vi.fn(async () => {}),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -117,7 +136,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);
|
||||
@@ -139,4 +166,224 @@ describe("ChatHub", () => {
|
||||
await waitFor(() => expect(api.chat.post).toHaveBeenCalledWith(7, "xem nè", [file]));
|
||||
expect(await screen.findByText("xem nè")).toBeTruthy();
|
||||
});
|
||||
|
||||
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={() => {}}
|
||||
onOpenRoom={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Group settings" }));
|
||||
const name = screen.getAllByLabelText("Group name (optional)")[0]!;
|
||||
expect(name.className).toContain("min-w-0");
|
||||
const panel = name.closest(".overflow-y-auto")!;
|
||||
expect(panel.className).toContain("overflow-x-hidden");
|
||||
// The add-people list grows inside that panel instead of scrolling itself.
|
||||
const lists = panel.querySelectorAll("ul.overflow-y-auto");
|
||||
expect(lists.length).toBe(0);
|
||||
});
|
||||
|
||||
it("shows a new message as a card that opens its room", () => {
|
||||
const onOpen = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<ChatNotice
|
||||
notice={{ key: 1, roomId: 7, title: "An · Team", text: "họp lúc 2h nhé" }}
|
||||
onOpen={onOpen}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("họp lúc 2h nhé")).toBeTruthy();
|
||||
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));
|
||||
});
|
||||
|
||||
it("deletes a chat for yourself, and lets the creator disband a group", async () => {
|
||||
const chat = state();
|
||||
const onSelect = vi.fn();
|
||||
api.chat.clear.mockResolvedValue({ hidden: true });
|
||||
api.chat.disband.mockResolvedValue({ ok: true });
|
||||
render(<ChatRail chat={chat} active={8} onSelect={onSelect} petName="Sagi" />);
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "An" }));
|
||||
expect(screen.queryByRole("menuitem", { name: "Disband group" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete chat" }));
|
||||
await waitFor(() => expect(api.chat.clear).toHaveBeenCalledWith(8));
|
||||
await waitFor(() => expect(onSelect).toHaveBeenCalledWith("pet"));
|
||||
expect(confirm.confirmDialog).toHaveBeenCalled();
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "Team, 2 unread" }));
|
||||
expect(screen.getByRole("menuitem", { name: "Delete chat history" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Disband group" }));
|
||||
await waitFor(() => expect(api.chat.disband).toHaveBeenCalledWith(7));
|
||||
});
|
||||
|
||||
it("offers Disband only to the group's creator", () => {
|
||||
render(<ChatRail chat={state({ me: 2 })} active="pet" onSelect={() => {}} petName="Sagi" />);
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: /Team/ }));
|
||||
expect(screen.queryByRole("menuitem", { name: "Disband group" })).toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: "Leave group" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows who has seen your newest message", async () => {
|
||||
api.chat.messages.mockResolvedValue({
|
||||
messages: [msg(1, 2, "của An"), msg(2, 1, "của tôi")],
|
||||
hasMore: false,
|
||||
});
|
||||
api.chat.read.mockResolvedValue({ lastReadId: 2 });
|
||||
const view = (room: ChatRoom) => (
|
||||
<ChatRoomView
|
||||
chat={state()}
|
||||
room={room}
|
||||
onClose={() => {}}
|
||||
onLeft={() => {}}
|
||||
onOpenRoom={() => {}}
|
||||
/>
|
||||
);
|
||||
// Direct chat, not read yet: nothing; read: "Seen".
|
||||
const { rerender } = render(view({ ...dm, id: 7, reads: { 1: 2, 2: 1 } }));
|
||||
await screen.findByText("của tôi");
|
||||
expect(screen.queryByTestId("chat-seen")).toBeNull();
|
||||
rerender(view({ ...dm, id: 7, reads: { 1: 2, 2: 2 } }));
|
||||
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen");
|
||||
// Group: by name, then everyone.
|
||||
rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 1 } }));
|
||||
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by An");
|
||||
rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 2 } }));
|
||||
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by everyone");
|
||||
});
|
||||
|
||||
it("never offers a disabled account, and names a deleted one", () => {
|
||||
const chat = state({
|
||||
people: [
|
||||
{ id: 2, username: "an", displayName: "An", avatarAt: null },
|
||||
{ id: 3, username: "binh", displayName: null, avatarAt: null, disabled: true },
|
||||
],
|
||||
// A direct chat whose other person's account was deleted.
|
||||
rooms: [{ ...dm, members: [1] }],
|
||||
});
|
||||
render(<NewChat chat={chat} onOpen={() => {}} onClose={() => {}} />);
|
||||
expect(screen.queryByRole("checkbox", { name: /binh/ })).toBeNull();
|
||||
expect(chat.reloadPeople).toHaveBeenCalled();
|
||||
render(<ChatRail chat={chat} active="new" onSelect={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: "Deleted user" })).toBeTruthy();
|
||||
// No pet name: no pet item.
|
||||
expect(screen.queryByText("🐾")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,7 +261,9 @@ describe("PetMenu", () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" })
|
||||
);
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
});
|
||||
|
||||
@@ -313,7 +315,9 @@ describe("PetSettings", () => {
|
||||
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" })
|
||||
);
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @file useChat.test.tsx
|
||||
* @description `useChat`'s live side: a message from someone else raises a
|
||||
* notice (sender, room) and bumps the room's unread count, plays the sound
|
||||
* only with notifications on, and stays quiet for the room on screen and for
|
||||
* the user's own messages; a `chat_read` frame moves that member's read mark
|
||||
* ("Seen"); a group change is no unread message; with several tabs only the
|
||||
* first to see a message plays its sound; nothing runs in local mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
import type { ChatMessage } from "../../../lib/api";
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } } as unknown,
|
||||
}));
|
||||
const api = vi.hoisted(() => ({
|
||||
chat: { rooms: vi.fn(), people: vi.fn() },
|
||||
}));
|
||||
const sound = vi.hoisted(() => ({ playSound: vi.fn() }));
|
||||
vi.mock("../../AuthGate", () => ({ useAuth: () => ({ state: auth.state }) }));
|
||||
vi.mock("../../../lib/api", async (orig) => ({ ...(await orig<Record<string, unknown>>()), api }));
|
||||
vi.mock("../../../lib/notify", () => sound);
|
||||
|
||||
import { useChat } from "../useChat";
|
||||
|
||||
const room = {
|
||||
id: 7,
|
||||
kind: "group" as const,
|
||||
name: "Team",
|
||||
createdBy: 1,
|
||||
createdAt: "2026-11-03T01:00:00Z",
|
||||
lastMessageAt: null,
|
||||
members: [1, 2],
|
||||
lastMessage: null,
|
||||
lastReadId: 0,
|
||||
unread: 0,
|
||||
};
|
||||
const msg = (id: number, userId: number): ChatMessage => ({
|
||||
id,
|
||||
roomId: 7,
|
||||
userId,
|
||||
kind: "text",
|
||||
body: `tin ${id}`,
|
||||
createdAt: `2026-11-03T02:00:0${id}Z`,
|
||||
files: [],
|
||||
});
|
||||
const frame = (m: ChatMessage) =>
|
||||
eventBus.publish({ type: "chat_message", data: { roomId: 7, message: m }, timestamp: "t" });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
auth.state = { mode: "server", user: { id: 1, username: "me", role: "developer" } };
|
||||
api.chat.rooms.mockResolvedValue({ rooms: [room] });
|
||||
api.chat.people.mockResolvedValue({
|
||||
people: [{ id: 2, username: "an", displayName: "An", avatarAt: null }],
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChat", () => {
|
||||
it("raises a notice for someone else's message in a room not on screen", async () => {
|
||||
const { result } = renderHook(() => useChat());
|
||||
await waitFor(() => expect(result.current.people.length).toBe(1));
|
||||
act(() => frame(msg(1, 2)));
|
||||
expect(result.current.notice).toMatchObject({ roomId: 7, title: "An · Team", text: "tin 1" });
|
||||
expect(result.current.unread).toBe(1);
|
||||
expect(sound.playSound).not.toHaveBeenCalled(); // notifications off by default
|
||||
});
|
||||
|
||||
it("plays the sound when notifications are on", async () => {
|
||||
localStorage.setItem(
|
||||
"agent-monitor-notifications",
|
||||
JSON.stringify({ enabled: true, sound: "pop" })
|
||||
);
|
||||
const { result } = renderHook(() => useChat());
|
||||
await waitFor(() => expect(result.current.rooms.length).toBe(1));
|
||||
act(() => frame(msg(2, 2)));
|
||||
expect(sound.playSound).toHaveBeenCalledWith("pop");
|
||||
});
|
||||
|
||||
it("stays quiet for the room on screen and for your own messages", async () => {
|
||||
const { result } = renderHook(() => useChat());
|
||||
await waitFor(() => expect(result.current.rooms.length).toBe(1));
|
||||
act(() => result.current.setActive(7));
|
||||
act(() => frame(msg(3, 2)));
|
||||
act(() => frame(msg(4, 1)));
|
||||
expect(result.current.notice).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing in local mode", () => {
|
||||
auth.state = { mode: "local" };
|
||||
const { result } = renderHook(() => useChat());
|
||||
expect(result.current.enabled).toBe(false);
|
||||
expect(api.chat.rooms).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves a member's read mark on a chat_read frame", async () => {
|
||||
const { result } = renderHook(() => useChat());
|
||||
await waitFor(() => expect(result.current.rooms.length).toBe(1));
|
||||
act(() =>
|
||||
eventBus.publish({
|
||||
type: "chat_read",
|
||||
data: { roomId: 7, userId: 2, lastReadId: 9 },
|
||||
timestamp: "t",
|
||||
})
|
||||
);
|
||||
expect(result.current.rooms[0]!.reads?.[2]).toBe(9);
|
||||
});
|
||||
|
||||
it("does not count a group change as unread", async () => {
|
||||
const { result } = renderHook(() => useChat());
|
||||
await waitFor(() => expect(result.current.rooms.length).toBe(1));
|
||||
act(() => frame({ ...msg(5, 2), kind: "system", body: "renamed" }));
|
||||
expect(result.current.unread).toBe(0);
|
||||
expect(result.current.notice).toBeNull();
|
||||
});
|
||||
|
||||
it("plays a message's sound in one tab only", async () => {
|
||||
localStorage.setItem(
|
||||
"agent-monitor-notifications",
|
||||
JSON.stringify({ enabled: true, sound: "pop" })
|
||||
);
|
||||
const a = renderHook(() => useChat());
|
||||
const b = renderHook(() => useChat());
|
||||
await waitFor(() => expect(a.result.current.rooms.length).toBe(1));
|
||||
await waitFor(() => expect(b.result.current.rooms.length).toBe(1));
|
||||
act(() => frame(msg(6, 2)));
|
||||
expect(sound.playSound).toHaveBeenCalledTimes(1);
|
||||
// Both still show the card.
|
||||
expect(a.result.current.notice).not.toBeNull();
|
||||
expect(b.result.current.notice).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,18 @@
|
||||
* for the badge on the pet. A `chat_message` frame updates its room's newest
|
||||
* message and unread count in place and is handed to the open room's view
|
||||
* (`onMessage`); a `chat_room` frame (made, renamed, members changed, left)
|
||||
* re-reads the list. Off in local mode — one person has no one to chat with.
|
||||
* re-reads the list. A message from someone else in a room that is not open
|
||||
* on screen raises a `notice` (the pet shows it as a card that opens the
|
||||
* 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). 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; a `chat_read` frame moves that member's read mark (`reads`, what the
|
||||
* room shows as Seen). With several tabs open, only the first to see a
|
||||
* message plays its sound / system notification (`agenthub.chat.notified`).
|
||||
* Off in local mode — one person has no one to chat with.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -14,8 +25,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api, type ChatMessage, type ChatPerson, type ChatRoom } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { loadNotifPrefs } from "../../lib/notifPrefs";
|
||||
import { playSound } from "../../lib/notify";
|
||||
import type { WSMessage } from "../../lib/types";
|
||||
|
||||
/** A new message to show on the pet. */
|
||||
export type ChatNotice = { key: number; roomId: number; title: string; text: string };
|
||||
|
||||
export type ChatState = {
|
||||
/** Server mode with a login: the chat exists. */
|
||||
enabled: boolean;
|
||||
@@ -30,8 +46,65 @@ export type ChatState = {
|
||||
onMessage: (fn: ((m: ChatMessage) => void) | null) => void;
|
||||
/** Mark a room read locally (the view also tells the server). */
|
||||
markRead: (roomId: number, upTo: number) => void;
|
||||
/** The room on screen now (null: none, or the window closed) — no notice for it. */
|
||||
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;
|
||||
/** Read the people list again (New chat, a room change). */
|
||||
reloadPeople: () => Promise<void>;
|
||||
/** Rooms whose new messages raise no notice or sound. */
|
||||
muted: Set<number>;
|
||||
toggleMute: (roomId: number) => void;
|
||||
};
|
||||
|
||||
const MUTED_KEY = "agenthub.chat.muted";
|
||||
const NOTIFIED_KEY = "agenthub.chat.notified";
|
||||
|
||||
/** True for the first tab of this browser to claim message `id`'s sound / notification. */
|
||||
function claimNotice(id: number): boolean {
|
||||
try {
|
||||
if (Number(localStorage.getItem(NOTIFIED_KEY) || 0) >= id) return false;
|
||||
localStorage.setItem(NOTIFIED_KEY, String(id));
|
||||
} catch {
|
||||
// No storage: every tab plays it.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
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, " ");
|
||||
if (text) return text.length > 140 ? `${text.slice(0, 139)}…` : text;
|
||||
return m.files.map((f) => `📎 ${f.name}`).join(", ");
|
||||
}
|
||||
|
||||
/** A system notification from this tab only (the service worker's, else the page's). */
|
||||
async function localNotification(title: string, body: string) {
|
||||
try {
|
||||
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
||||
if ("serviceWorker" in navigator) {
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
if (reg) {
|
||||
await reg.showNotification(title, { body, icon: "/favicon.svg", tag: `chat-${title}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
new Notification(title, { body, icon: "/favicon.svg" });
|
||||
} catch {
|
||||
// A browser that refuses: the card on the pet still shows it.
|
||||
}
|
||||
}
|
||||
|
||||
export function useChat(): ChatState {
|
||||
const { state } = useAuth();
|
||||
const me = state.mode === "server" && "user" in state && state.user ? state.user.id : null;
|
||||
@@ -39,6 +112,15 @@ export function useChat(): ChatState {
|
||||
const [rooms, setRooms] = useState<ChatRoom[]>([]);
|
||||
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 });
|
||||
lookup.current = { rooms, people };
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
@@ -49,19 +131,50 @@ export function useChat(): ChatState {
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
const reloadPeople = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
setPeople((await api.chat.people()).people);
|
||||
} catch {
|
||||
// Names fall back until the next read.
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void reload();
|
||||
api.chat
|
||||
.people()
|
||||
.then((r) => setPeople(r.people))
|
||||
.catch(() => undefined);
|
||||
}, [enabled, reload]);
|
||||
void reloadPeople();
|
||||
}, [enabled, reload, reloadPeople]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const off = eventBus.subscribe((msg: WSMessage) => {
|
||||
if (msg.type === "chat_room") {
|
||||
void reload();
|
||||
void reloadPeople();
|
||||
return;
|
||||
}
|
||||
if (msg.type === "chat_read") {
|
||||
const d = msg.data as { roomId: number; userId?: number; lastReadId?: number };
|
||||
if (d.userId == null || d.lastReadId == null) return;
|
||||
setRooms((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === d.roomId
|
||||
? {
|
||||
...r,
|
||||
reads: {
|
||||
...r.reads,
|
||||
[d.userId!]: Math.max(r.reads?.[d.userId!] ?? 0, d.lastReadId!),
|
||||
},
|
||||
}
|
||||
: r
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "chat_deleted") {
|
||||
const d = msg.data as { roomId: number; messageId?: number };
|
||||
if (d.messageId != null) deletedListener.current?.(d.roomId, d.messageId);
|
||||
void reload();
|
||||
return;
|
||||
}
|
||||
@@ -78,7 +191,8 @@ export function useChat(): ChatState {
|
||||
...r,
|
||||
lastMessage: m,
|
||||
lastMessageAt: m.createdAt,
|
||||
unread: own ? 0 : r.unread + 1,
|
||||
// A group change (system line) is not a message to read.
|
||||
unread: own ? 0 : m.kind === "text" ? r.unread + 1 : r.unread,
|
||||
lastReadId: own ? m.id : r.lastReadId,
|
||||
};
|
||||
});
|
||||
@@ -89,6 +203,22 @@ export function useChat(): ChatState {
|
||||
// A room this list has not seen yet (added a moment ago): read it all.
|
||||
if (!known) void reload();
|
||||
listener.current?.(m);
|
||||
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);
|
||||
const title = room && room.kind === "group" ? `${from} · ${room.name || "…"}` : from;
|
||||
const text = preview(m);
|
||||
setNotice({ key: m.id, roomId: m.roomId, title, text });
|
||||
// Several tabs open: only the first to see a message makes a sound or a
|
||||
// system notification (message ids only grow).
|
||||
if (!claimNotice(m.id)) return;
|
||||
const prefs = loadNotifPrefs();
|
||||
if (prefs.enabled) playSound(prefs.sound);
|
||||
if (hidden) void localNotification(title, text);
|
||||
});
|
||||
// Reconnected: frames may have been missed.
|
||||
const offConn = eventBus.onConnection((up) => {
|
||||
@@ -98,7 +228,7 @@ export function useChat(): ChatState {
|
||||
off();
|
||||
offConn();
|
||||
};
|
||||
}, [enabled, me, reload]);
|
||||
}, [enabled, me, reload, reloadPeople]);
|
||||
|
||||
const upsert = useCallback((room: ChatRoom) => {
|
||||
setRooms((prev) => [room, ...prev.filter((r) => r.id !== room.id)]);
|
||||
@@ -106,14 +236,70 @@ export function useChat(): ChatState {
|
||||
const onMessage = useCallback((fn: ((m: ChatMessage) => void) | null) => {
|
||||
listener.current = fn;
|
||||
}, []);
|
||||
const markRead = useCallback((roomId: number, upTo: number) => {
|
||||
setRooms((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === roomId ? { ...r, unread: 0, lastReadId: Math.max(r.lastReadId, upTo) } : r
|
||||
)
|
||||
);
|
||||
const markRead = useCallback(
|
||||
(roomId: number, upTo: number) => {
|
||||
setRooms((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === roomId
|
||||
? {
|
||||
...r,
|
||||
unread: 0,
|
||||
lastReadId: Math.max(r.lastReadId, upTo),
|
||||
reads:
|
||||
me != null ? { ...r.reads, [me]: Math.max(r.reads?.[me] ?? 0, upTo) } : r.reads,
|
||||
}
|
||||
: r
|
||||
)
|
||||
);
|
||||
},
|
||||
[me]
|
||||
);
|
||||
|
||||
const setActive = useCallback((roomId: number | null) => {
|
||||
active.current = roomId;
|
||||
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;
|
||||
const id = window.setTimeout(() => setNotice(null), 10000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [notice]);
|
||||
|
||||
const unread = useMemo(() => rooms.reduce((n, r) => n + r.unread, 0), [rooms]);
|
||||
return { enabled, me, rooms, people, unread, reload, upsert, onMessage, markRead };
|
||||
return {
|
||||
enabled,
|
||||
me,
|
||||
rooms,
|
||||
people,
|
||||
unread,
|
||||
reload,
|
||||
upsert,
|
||||
onMessage,
|
||||
markRead,
|
||||
setActive,
|
||||
notice,
|
||||
dismissNotice,
|
||||
onDeleted,
|
||||
reloadPeople,
|
||||
muted,
|
||||
toggleMute,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,7 +261,39 @@
|
||||
"sysRenamed": "{{who}} renamed the group",
|
||||
"sysLeft": "{{who}} left",
|
||||
"sysAdded": "{{who}} added {{names}}",
|
||||
"sysRemoved": "{{who}} removed {{names}}"
|
||||
"sysRemoved": "{{who}} removed {{names}}",
|
||||
"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.",
|
||||
"menuClearDm": "Delete chat",
|
||||
"menuClearGroup": "Delete chat history",
|
||||
"clearDmTitle": "Delete this chat?",
|
||||
"clearDmBody": "The chat with {{name}} and its history are deleted for you only — they keep theirs. It comes back, without the old messages, if either of you writes again.",
|
||||
"clearGroupTitle": "Delete this group's history?",
|
||||
"clearGroupBody": "Every message in {{name}} so far is deleted for you only. You stay in the group; the others keep theirs.",
|
||||
"disband": "Disband group",
|
||||
"disbandTitle": "Disband this group?",
|
||||
"disbandBody": "{{name}} and all its messages and files are deleted for every member. This cannot be undone.",
|
||||
"seen": "Seen",
|
||||
"seenAll": "Seen by everyone",
|
||||
"seenBy": "Seen by {{names}}",
|
||||
"deletedUser": "Deleted user",
|
||||
"dock": "Chats",
|
||||
"dockUnread": "Chats, {{n}} unread"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
|
||||
@@ -261,7 +261,39 @@
|
||||
"sysRenamed": "{{who}} đã đổi tên nhóm",
|
||||
"sysLeft": "{{who}} đã rời nhóm",
|
||||
"sysAdded": "{{who}} đã thêm {{names}}",
|
||||
"sysRemoved": "{{who}} đã xoá {{names}}"
|
||||
"sysRemoved": "{{who}} đã xoá {{names}}",
|
||||
"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.",
|
||||
"menuClearDm": "Xoá cuộc trò chuyện",
|
||||
"menuClearGroup": "Xoá lịch sử chat",
|
||||
"clearDmTitle": "Xoá cuộc trò chuyện này?",
|
||||
"clearDmBody": "Cuộc trò chuyện với {{name}} và lịch sử của nó chỉ bị xoá ở phía bạn — người kia vẫn giữ. Nếu một trong hai nhắn tiếp, cuộc chat sẽ hiện lại nhưng không còn tin cũ.",
|
||||
"clearGroupTitle": "Xoá lịch sử của nhóm này?",
|
||||
"clearGroupBody": "Mọi tin nhắn trong {{name}} đến giờ chỉ bị xoá ở phía bạn. Bạn vẫn ở trong nhóm; người khác vẫn giữ tin của họ.",
|
||||
"disband": "Giải tán nhóm",
|
||||
"disbandTitle": "Giải tán nhóm này?",
|
||||
"disbandBody": "{{name}} cùng toàn bộ tin nhắn và file sẽ bị xoá với mọi thành viên. Không thể hoàn tác.",
|
||||
"seen": "Đã xem",
|
||||
"seenAll": "Mọi người đã xem",
|
||||
"seenBy": "{{names}} đã xem",
|
||||
"deletedUser": "Người dùng đã xoá",
|
||||
"dock": "Trò chuyện",
|
||||
"dockUnread": "Trò chuyện, {{n}} tin chưa đọc"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
|
||||
@@ -553,6 +553,8 @@ export type ChatPerson = {
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarAt: string | null;
|
||||
/** A disabled account: named in old chats, never offered for a new one. */
|
||||
disabled?: boolean;
|
||||
};
|
||||
/** A file of a chat message (`GET /api/chat/rooms/:id/files/:fileId`). */
|
||||
export type ChatFile = { id: string; name: string; type: string; size: number };
|
||||
@@ -577,6 +579,10 @@ export type ChatRoom = {
|
||||
members: number[];
|
||||
lastMessage: ChatMessage | null;
|
||||
lastReadId: number;
|
||||
/** Messages up to this id were deleted by the viewer for themselves. */
|
||||
clearedId?: number;
|
||||
/** Every member's read mark by user id ("Seen"); absent from an older server. */
|
||||
reads?: Record<string, number>;
|
||||
unread: number;
|
||||
};
|
||||
/** One account in Settings → Users (`GET /api/users`). */
|
||||
@@ -1645,6 +1651,14 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ upTo }),
|
||||
}),
|
||||
/** Delete the chat for yourself only (a direct chat leaves your list). */
|
||||
clear: (id: number) =>
|
||||
request<{ hidden: boolean }>(`/chat/rooms/${id}/clear`, { method: "POST" }),
|
||||
/** The group's creator deletes it for everyone. */
|
||||
disband: (id: number) => request<{ ok: true }>(`/chat/rooms/${id}`, { method: "DELETE" }),
|
||||
/** Your own message only (403 otherwise). */
|
||||
deleteMessage: (roomId: number, messageId: number) =>
|
||||
request<{ ok: true }>(`/chat/rooms/${roomId}/messages/${messageId}`, { method: "DELETE" }),
|
||||
fileUrl: (roomId: number, fileId: string) =>
|
||||
`${BASE}/chat/rooms/${roomId}/files/${encodeURIComponent(fileId)}`,
|
||||
},
|
||||
|
||||
+12
-2
@@ -1918,12 +1918,15 @@ 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 }; chat_read → { roomId, userId, lastReadId, to }. */
|
||||
type:
|
||||
| "session_created"
|
||||
| "session_updated"
|
||||
| "chat_message"
|
||||
| "chat_room"
|
||||
| "chat_deleted"
|
||||
| "chat_read"
|
||||
| "agent_created"
|
||||
| "agent_updated"
|
||||
| "new_event"
|
||||
@@ -1972,7 +1975,14 @@ 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;
|
||||
userId?: number;
|
||||
lastReadId?: number;
|
||||
to?: number[];
|
||||
};
|
||||
/** ISO timestamp the server broadcast this message (not necessarily the
|
||||
* same instant the underlying event occurred). */
|
||||
timestamp: string;
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.65 (rebuild client to match)
|
||||
UI build v2.8.66 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+7
-4
@@ -3502,18 +3502,21 @@ A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles s
|
||||
The pet's window has a rail of chats beside the chat with the pet: direct chats (one per pair of users) and groups. Only a room's MEMBERS read or write it — an admin or analyst included; another user's room answers `404 ENOTFOUND` like a missing one. Local mode answers `400 ELOCALMODE`. Code: `server/lib/chat.js`, `server/routes/chat.js`, client `client/src/components/Sagi/ChatHub.tsx` + `useChat.ts`.
|
||||
|
||||
```
|
||||
GET /api/chat/people {people: [{id, username, displayName, avatarAt}]} — every enabled account but you
|
||||
GET /api/chat/rooms {rooms} — yours, newest first: members, lastMessage, lastReadId, unread
|
||||
GET /api/chat/people {people: [{id, username, displayName, avatarAt, disabled}]} — every account but you; a disabled one is named in old chats, never offered for a new one
|
||||
GET /api/chat/rooms {rooms} — yours, newest first: members, lastMessage, lastReadId, clearedId, reads (every member's read mark — "Seen"), unread
|
||||
POST /api/chat/rooms {kind: "dm", userId} → the pair's one direct chat (201 new / 200 existing); {kind: "group", name?, userIds} → 201
|
||||
PATCH /api/chat/rooms/:id {name?, add?, remove?} — the group's creator only (403 EFORBIDDEN)
|
||||
DELETE /api/chat/rooms/:id Disband a group — its creator only, for every member, files included
|
||||
POST /api/chat/rooms/:id/clear Delete the chat for YOURSELF: {hidden, purged} — see below
|
||||
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
|
||||
POST /api/chat/rooms/:id/read {upTo} — read mark, never moves back
|
||||
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 nor past the newest message; a move is sent as `chat_read`
|
||||
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). **Deleting a chat** is per person: the messages so far vanish for the caller only (`chat_members.cleared_id`) and a direct chat leaves their list (`hidden`) until a new message arrives; the others keep theirs. What EVERY member has deleted is deleted for real — the messages up to the lowest `cleared_id` and their files on disk, and a direct chat both people deleted, whole (`purge`, also run when a member leaves or is removed). A group whose creator left or was deleted passes to its longest-standing member. Group changes (`system` lines) are sent live as `chat_message` and never count as unread. An upload is written to `<data dir>/chat/.incoming/` and moved into place — never held in memory whole. In the browser, only the first tab to see a message plays its sound or shows a system notification; a message from someone in a room not on screen shows as a card on the pet. With the pet hidden, a 💬 button in the bottom-right corner (`ChatDock.tsx`) opens the same chat window. **Seen**: the room shows under your newest message "Seen" (direct chat), "Seen by …" or "Seen by everyone" (group), from `reads`. 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}`) or `chat_read` (`{roomId, userId, lastReadId, to}`) to exactly the user ids in `to` (`scope.exactAudience`), whatever their role.
|
||||
|
||||
### Session snapshots
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.65",
|
||||
"version": "2.8.66",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.65",
|
||||
"version": "2.8.66",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.65",
|
||||
"version": "2.8.66",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* @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, "delete chat" for the caller alone, a group disbanded only by its
|
||||
* creator, 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>
|
||||
*/
|
||||
@@ -79,6 +81,7 @@ describe("chat", () => {
|
||||
it("lists the other enabled users to chat with", async () => {
|
||||
const r = await call(2, "GET", "/people");
|
||||
assert.deepEqual(r.body.people.map((p) => p.username).sort(), ["binh", "boss", "chi"]);
|
||||
assert.ok(r.body.people.every((p) => p.disabled === false));
|
||||
});
|
||||
|
||||
it("opens one direct chat per pair, whoever starts it", async () => {
|
||||
@@ -151,6 +154,137 @@ 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("deletes a direct chat for the caller only, until a new message", async () => {
|
||||
await call(3, "POST", `/rooms/${dm}/messages`, { body: "cũ" });
|
||||
const c = await call(2, "POST", `/rooms/${dm}/clear`);
|
||||
assert.deepEqual(c.body, { hidden: true, purged: false });
|
||||
assert.ok(!(await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === dm));
|
||||
assert.deepEqual((await call(2, "GET", `/rooms/${dm}/messages`)).body.messages, []);
|
||||
// The other person keeps everything.
|
||||
assert.ok((await call(3, "GET", `/rooms/${dm}/messages`)).body.messages.length >= 2);
|
||||
await call(3, "POST", `/rooms/${dm}/messages`, { body: "mới" });
|
||||
const back = (await call(2, "GET", "/rooms")).body.rooms.find((r) => r.id === dm);
|
||||
assert.equal(back.unread, 1);
|
||||
assert.deepEqual(
|
||||
(await call(2, "GET", `/rooms/${dm}/messages`)).body.messages.map((m) => m.body),
|
||||
["mới"]
|
||||
);
|
||||
});
|
||||
|
||||
it("hides a cleared message's files from the one who cleared it", async () => {
|
||||
const fd = new FormData();
|
||||
fd.append("files", new Blob(["x"], { type: "text/plain" }), "b.txt");
|
||||
const m = (await call(3, "POST", `/rooms/${group}/messages`, fd)).body.message;
|
||||
await call(2, "POST", `/rooms/${group}/clear`);
|
||||
assert.equal((await call(2, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 404);
|
||||
assert.equal((await call(3, "GET", `/rooms/${group}/files/${m.files[0].id}`)).status, 200);
|
||||
// A group stays in the list after its history is cleared.
|
||||
assert.ok((await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === group));
|
||||
});
|
||||
|
||||
it("lets only a group's creator disband it, for everyone", async () => {
|
||||
const g = (await call(3, "POST", "/rooms", { kind: "group", name: "Tạm", userIds: [2] })).body
|
||||
.room.id;
|
||||
assert.equal((await call(2, "DELETE", `/rooms/${g}`)).status, 403);
|
||||
assert.equal((await call(3, "DELETE", `/rooms/${g}`)).status, 200);
|
||||
assert.equal((await call(2, "GET", `/rooms/${g}/messages`)).status, 404);
|
||||
assert.equal((await call(3, "DELETE", `/rooms/${dm}`)).status, 400);
|
||||
});
|
||||
|
||||
it("deletes for real what everyone deleted, and a direct chat both deleted whole", async () => {
|
||||
const { db } = require("../db");
|
||||
const fd = new FormData();
|
||||
fd.append("body", "bí mật");
|
||||
fd.append("files", new Blob(["y"], { type: "text/plain" }), "c.txt");
|
||||
const room = (await call(4, "POST", "/rooms", { kind: "dm", userId: 3 })).body.room.id;
|
||||
const m = (await call(4, "POST", `/rooms/${room}/messages`, fd)).body.message;
|
||||
const onDisk = path.join(TMP, "chat", String(room), m.files[0].id);
|
||||
assert.ok(fs.existsSync(onDisk));
|
||||
await call(4, "POST", `/rooms/${room}/clear`);
|
||||
// One side only: still stored for the other.
|
||||
assert.ok(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id));
|
||||
assert.ok(fs.existsSync(onDisk));
|
||||
const both = await call(3, "POST", `/rooms/${room}/clear`);
|
||||
assert.equal(both.body.purged, true);
|
||||
assert.equal(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id), undefined);
|
||||
assert.equal(db.prepare("SELECT 1 FROM chat_rooms WHERE id = ?").get(room), undefined);
|
||||
assert.ok(!fs.existsSync(onDisk));
|
||||
// Opening it again starts afresh.
|
||||
const again = (await call(4, "POST", "/rooms", { kind: "dm", userId: 3 })).body.room;
|
||||
assert.equal(again.lastMessage, null);
|
||||
});
|
||||
|
||||
it("purges a group's history once every member cleared it", async () => {
|
||||
const { db } = require("../db");
|
||||
const g = (await call(2, "POST", "/rooms", { kind: "group", userIds: [3] })).body.room.id;
|
||||
const m = (await call(2, "POST", `/rooms/${g}/messages`, { body: "x" })).body.message;
|
||||
await call(2, "POST", `/rooms/${g}/clear`);
|
||||
assert.ok(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id));
|
||||
await call(3, "POST", `/rooms/${g}/clear`);
|
||||
assert.equal(db.prepare("SELECT 1 FROM chat_messages WHERE id = ?").get(m.id), undefined);
|
||||
// The group itself stays.
|
||||
assert.ok((await call(2, "GET", "/rooms")).body.rooms.some((r) => r.id === g));
|
||||
});
|
||||
|
||||
it("tells every member's read mark (seen), never past the newest message", async () => {
|
||||
const r = await call(3, "POST", `/rooms/${group}/read`, { upTo: 1e9 });
|
||||
const room = (await call(2, "GET", "/rooms")).body.rooms.find((x) => x.id === group);
|
||||
assert.equal(room.reads[3], r.body.lastReadId);
|
||||
assert.equal(room.reads[3], room.lastMessage ? room.lastMessage.id : room.reads[3]);
|
||||
assert.ok(r.body.lastReadId < 1e9);
|
||||
assert.deepEqual([...scope.exactAudience("chat_read", { to: [2, 3] })], [2, 3]);
|
||||
});
|
||||
|
||||
it("hands a group to its longest-standing member when the creator leaves", async () => {
|
||||
const g = (await call(2, "POST", "/rooms", { kind: "group", name: "Kế", userIds: [3, 4] })).body
|
||||
.room.id;
|
||||
await call(2, "POST", `/rooms/${g}/leave`);
|
||||
const room = (await call(3, "GET", "/rooms")).body.rooms.find((r) => r.id === g);
|
||||
assert.equal(room.createdBy, 3);
|
||||
assert.equal((await call(3, "PATCH", `/rooms/${g}`, { name: "Kế 2" })).status, 200);
|
||||
// The "left" and "renamed" lines never count as unread.
|
||||
const four = (await call(4, "GET", "/rooms")).body.rooms.find((r) => r.id === g);
|
||||
assert.equal(four.unread, 0);
|
||||
});
|
||||
|
||||
it("still names a disabled user, and never offers them for a new chat", async () => {
|
||||
const { db } = require("../db");
|
||||
db.prepare("UPDATE users SET disabled = 1 WHERE id = 4").run();
|
||||
try {
|
||||
const chi = (await call(2, "GET", "/people")).body.people.find((p) => p.id === 4);
|
||||
assert.equal(chi.disabled, true);
|
||||
assert.equal((await call(2, "POST", "/rooms", { kind: "dm", userId: 4 })).status, 400);
|
||||
} finally {
|
||||
db.prepare("UPDATE users SET disabled = 0 WHERE id = 4").run();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves nothing behind in the upload folder", async () => {
|
||||
const fd = new FormData();
|
||||
fd.append("files", new Blob(["z"], { type: "text/plain" }), "d.txt");
|
||||
assert.equal((await call(2, "POST", `/rooms/${group}/messages`, fd)).status, 201);
|
||||
const fd2 = new FormData();
|
||||
fd2.append("files", new Blob(["z"], { type: "text/plain" }), "e.txt");
|
||||
assert.equal((await call(1, "POST", `/rooms/${group}/messages`, fd2)).status, 404);
|
||||
assert.deepEqual(fs.readdirSync(path.join(TMP, "chat", ".incoming")), []);
|
||||
});
|
||||
|
||||
it("refuses an empty message", async () => {
|
||||
assert.equal((await call(2, "POST", `/rooms/${dm}/messages`, { body: " " })).status, 400);
|
||||
});
|
||||
|
||||
@@ -1952,6 +1952,15 @@ db.exec(`
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_files_message ON chat_files(message_id);
|
||||
`);
|
||||
// A member's own "delete this chat": messages up to cleared_id are gone for
|
||||
// them (not for anyone else), and a hidden direct chat leaves their list until
|
||||
// a new message arrives. Additive.
|
||||
try {
|
||||
db.prepare("SELECT cleared_id, hidden FROM chat_members LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE chat_members ADD COLUMN cleared_id INTEGER NOT NULL DEFAULT 0").run();
|
||||
db.prepare("ALTER TABLE chat_members ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0").run();
|
||||
}
|
||||
|
||||
// An account's own agent instructions, put before every workspace's in each
|
||||
// pane it owns (server/lib/lane-sources.js, issue #67). Additive.
|
||||
|
||||
+229
-40
@@ -5,7 +5,21 @@
|
||||
* 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).
|
||||
* "Delete chat" is the caller's alone: the messages so far vanish for THEM
|
||||
* (`cleared_id`) and a direct chat leaves their list (`hidden`) until a new
|
||||
* message arrives — the other members keep everything. Only a group's creator
|
||||
* disbands it, for everyone, its files included. Once EVERY member has
|
||||
* deleted a stretch of history, it is deleted for real (`purge`): the
|
||||
* messages up to the lowest `cleared_id` and their files, and a direct chat
|
||||
* both people deleted, whole. "Seen": each room carries every member's read
|
||||
* mark (`reads`), and a read that moves is told to the members (`chat_read`) —
|
||||
* a mark never passes the room's newest message, so it cannot claim to have
|
||||
* seen one not yet written. A group whose creator left or was deleted passes
|
||||
* to its longest-standing member (`ensureOwner`). Group changes add `system`
|
||||
* lines that are sent live like messages and never count as unread. An
|
||||
* upload arrives on disk (`<data dir>/chat/.incoming/`) and is renamed into
|
||||
* place — never held in memory whole. 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
|
||||
@@ -48,11 +62,11 @@ function activeUser(id) {
|
||||
return sql("SELECT id FROM users WHERE id = ? AND COALESCE(disabled, 0) = 0").get(n) || null;
|
||||
}
|
||||
|
||||
/** Every enabled account but the caller — who a chat can be started with. */
|
||||
/** Every account but the caller, `disabled` marked — names for old chats, the enabled ones to start a new one with. */
|
||||
function people(me) {
|
||||
return sql(
|
||||
`SELECT id, username, display_name, avatar_at FROM users
|
||||
WHERE COALESCE(disabled, 0) = 0 AND id != ? ORDER BY COALESCE(display_name, username)`
|
||||
`SELECT id, username, display_name, avatar_at, COALESCE(disabled, 0) AS disabled FROM users
|
||||
WHERE id != ? ORDER BY COALESCE(display_name, username)`
|
||||
)
|
||||
.all(me)
|
||||
.map((u) => ({
|
||||
@@ -60,6 +74,8 @@ function people(me) {
|
||||
username: u.username,
|
||||
displayName: u.display_name ?? null,
|
||||
avatarAt: u.avatar_at ?? null,
|
||||
// Still named in old chats; never offered for a new one.
|
||||
disabled: Boolean(u.disabled),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -80,7 +96,7 @@ function roomFor(me, roomId) {
|
||||
).get(id, me)
|
||||
: null;
|
||||
if (!row) throw err("ENOTFOUND", "no such chat", 404);
|
||||
return row;
|
||||
return ensureOwner(row);
|
||||
}
|
||||
|
||||
function filesOf(messageIds) {
|
||||
@@ -110,18 +126,37 @@ function shapeMessages(rows) {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A group's creator who left or whose account was deleted hands it to the
|
||||
* longest-standing member, so somebody can still manage it. Answers the room.
|
||||
*/
|
||||
function ensureOwner(r) {
|
||||
if (r.kind !== "group") return r;
|
||||
const holds =
|
||||
r.created_by != null &&
|
||||
sql("SELECT 1 FROM chat_members WHERE room_id = ? AND user_id = ?").get(r.id, r.created_by);
|
||||
if (holds) return r;
|
||||
const next = sql(
|
||||
"SELECT user_id FROM chat_members WHERE room_id = ? ORDER BY joined_at, rowid LIMIT 1"
|
||||
).get(r.id);
|
||||
if (!next) return r;
|
||||
sql("UPDATE chat_rooms SET created_by = ? WHERE id = ?").run(next.user_id, r.id);
|
||||
return { ...r, created_by: next.user_id };
|
||||
}
|
||||
|
||||
/** One room as `me` sees it: members, newest message, unread count. */
|
||||
function shapeRoom(me, r) {
|
||||
const mine = sql("SELECT last_read_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
|
||||
r.id,
|
||||
me
|
||||
);
|
||||
const last = sql("SELECT * FROM chat_messages WHERE room_id = ? ORDER BY id DESC LIMIT 1").get(
|
||||
r.id
|
||||
);
|
||||
r = ensureOwner(r);
|
||||
const mine = sql(
|
||||
"SELECT last_read_id, cleared_id FROM chat_members WHERE room_id = ? AND user_id = ?"
|
||||
).get(r.id, me);
|
||||
const cleared = mine ? mine.cleared_id : 0;
|
||||
const last = sql(
|
||||
"SELECT * FROM chat_messages WHERE room_id = ? AND id > ? ORDER BY id DESC LIMIT 1"
|
||||
).get(r.id, cleared);
|
||||
const unread = sql(
|
||||
"SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ? AND id > ? AND COALESCE(user_id, -1) != ?"
|
||||
).get(r.id, mine ? mine.last_read_id : 0, me).n;
|
||||
"SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ? AND id > ? AND kind = 'text' AND COALESCE(user_id, -1) != ?"
|
||||
).get(r.id, Math.max(mine ? mine.last_read_id : 0, cleared), me).n;
|
||||
return {
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
@@ -132,6 +167,12 @@ function shapeRoom(me, r) {
|
||||
members: memberIds(r.id),
|
||||
lastMessage: last ? shapeMessages([last])[0] : null,
|
||||
lastReadId: mine ? mine.last_read_id : 0,
|
||||
clearedId: cleared,
|
||||
reads: Object.fromEntries(
|
||||
sql("SELECT user_id, last_read_id FROM chat_members WHERE room_id = ?")
|
||||
.all(r.id)
|
||||
.map((x) => [x.user_id, x.last_read_id])
|
||||
),
|
||||
unread,
|
||||
};
|
||||
}
|
||||
@@ -140,7 +181,8 @@ function shapeRoom(me, r) {
|
||||
function listRooms(me) {
|
||||
return sql(
|
||||
`SELECT r.* FROM chat_rooms r JOIN chat_members m ON m.room_id = r.id
|
||||
WHERE m.user_id = ? ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`
|
||||
WHERE m.user_id = ? AND m.hidden = 0
|
||||
ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`
|
||||
)
|
||||
.all(me)
|
||||
.map((r) => shapeRoom(me, r));
|
||||
@@ -167,7 +209,11 @@ function openDm(me, other) {
|
||||
if (!them || them.id === me) throw err("ENOUSER", "pick another user");
|
||||
const key = [me, them.id].sort((a, b) => a - b).join(":");
|
||||
const found = sql("SELECT * FROM chat_rooms WHERE dm_key = ?").get(key);
|
||||
if (found) return { room: shapeRoom(me, found), created: false };
|
||||
if (found) {
|
||||
// Opened again on purpose: back in the list (its cleared history stays gone).
|
||||
sql("UPDATE chat_members SET hidden = 0 WHERE room_id = ? AND user_id = ?").run(found.id, me);
|
||||
return { room: shapeRoom(me, found), created: false };
|
||||
}
|
||||
const id = db().transaction(() => {
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO chat_rooms (kind, dm_key, created_by) VALUES ('dm', ?, ?)"
|
||||
@@ -183,15 +229,25 @@ function openDm(me, other) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds a `system` line; answers its id (the caller sends it live). */
|
||||
function systemLine(roomId, me, body) {
|
||||
sql("INSERT INTO chat_messages (room_id, user_id, kind, body) VALUES (?, ?, 'system', ?)").run(
|
||||
roomId,
|
||||
me,
|
||||
body
|
||||
);
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO chat_messages (room_id, user_id, kind, body) VALUES (?, ?, 'system', ?)"
|
||||
).run(roomId, me, body);
|
||||
sql(
|
||||
"UPDATE chat_rooms SET last_message_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||
).run(roomId);
|
||||
return lastInsertRowid;
|
||||
}
|
||||
|
||||
/** The messages of these ids, shaped, to send live. */
|
||||
function linesOf(ids) {
|
||||
if (!ids.length) return [];
|
||||
return shapeMessages(
|
||||
sql(
|
||||
`SELECT * FROM chat_messages WHERE id IN (${ids.map(() => "?").join(",")}) ORDER BY id`
|
||||
).all(...ids)
|
||||
);
|
||||
}
|
||||
|
||||
/** A new group of `me` plus `userIds`. */
|
||||
@@ -222,24 +278,28 @@ function updateGroup(me, roomId, { name, add, remove }) {
|
||||
const before = memberIds(room.id);
|
||||
const adds = userList(add, "add").filter((id) => !before.includes(id));
|
||||
const drops = userList(remove, "remove").filter((id) => id !== me && before.includes(id));
|
||||
const ids = [];
|
||||
db().transaction(() => {
|
||||
if (name !== undefined) {
|
||||
const title = cleanName(name);
|
||||
if (title !== room.name) {
|
||||
sql("UPDATE chat_rooms SET name = ? WHERE id = ?").run(title, room.id);
|
||||
systemLine(room.id, me, "renamed");
|
||||
ids.push(systemLine(room.id, me, "renamed"));
|
||||
}
|
||||
}
|
||||
const ins = sql("INSERT OR IGNORE INTO chat_members (room_id, user_id) VALUES (?, ?)");
|
||||
for (const uid of adds) ins.run(room.id, uid);
|
||||
const del = sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?");
|
||||
for (const uid of drops) del.run(room.id, uid);
|
||||
if (adds.length) systemLine(room.id, me, `added:${adds.join(",")}`);
|
||||
if (drops.length) systemLine(room.id, me, `removed:${drops.join(",")}`);
|
||||
if (adds.length) ids.push(systemLine(room.id, me, `added:${adds.join(",")}`));
|
||||
if (drops.length) ids.push(systemLine(room.id, me, `removed:${drops.join(",")}`));
|
||||
})();
|
||||
// A removed member's mark no longer holds history back.
|
||||
if (drops.length) purge(room.id);
|
||||
return {
|
||||
room: shapeRoom(me, sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id)),
|
||||
notify: [...new Set([...before, ...adds])],
|
||||
lines: linesOf(ids),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -248,27 +308,35 @@ function leaveGroup(me, roomId) {
|
||||
const room = roomFor(me, roomId);
|
||||
if (room.kind !== "group") throw err("EBADFIELD", "a direct chat cannot be left");
|
||||
const before = memberIds(room.id);
|
||||
let line = null;
|
||||
db().transaction(() => {
|
||||
sql("DELETE FROM chat_members WHERE room_id = ? AND user_id = ?").run(room.id, me);
|
||||
if (before.length > 1) systemLine(room.id, me, "left");
|
||||
else sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
|
||||
if (before.length > 1) line = systemLine(room.id, me, "left");
|
||||
})();
|
||||
if (before.length <= 1) fs.rmSync(fileDir(room.id), { recursive: true, force: true });
|
||||
return { notify: before };
|
||||
const gone = purge(room.id);
|
||||
const rest = sql("SELECT * FROM chat_rooms WHERE id = ?").get(room.id);
|
||||
if (rest) ensureOwner(rest);
|
||||
return { notify: before, lines: gone || line == null ? [] : linesOf([line]) };
|
||||
}
|
||||
|
||||
/** A page of messages, oldest first: the newest `limit`, or those before `before`. */
|
||||
function messages(me, roomId, { before, limit } = {}) {
|
||||
const room = roomFor(me, roomId);
|
||||
const cleared = sql("SELECT cleared_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
|
||||
room.id,
|
||||
me
|
||||
).cleared_id;
|
||||
const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
|
||||
const b = parseInt(before, 10);
|
||||
const rows = Number.isSafeInteger(b)
|
||||
? sql("SELECT * FROM chat_messages WHERE room_id = ? AND id < ? ORDER BY id DESC LIMIT ?").all(
|
||||
? sql(
|
||||
"SELECT * FROM chat_messages WHERE room_id = ? AND id > ? AND id < ? ORDER BY id DESC LIMIT ?"
|
||||
).all(room.id, cleared, b, n)
|
||||
: sql("SELECT * FROM chat_messages WHERE room_id = ? AND id > ? ORDER BY id DESC LIMIT ?").all(
|
||||
room.id,
|
||||
b,
|
||||
cleared,
|
||||
n
|
||||
)
|
||||
: sql("SELECT * FROM chat_messages WHERE room_id = ? ORDER BY id DESC LIMIT ?").all(room.id, n);
|
||||
);
|
||||
return { messages: shapeMessages(rows.reverse()), hasMore: rows.length === n };
|
||||
}
|
||||
|
||||
@@ -294,19 +362,29 @@ function post(me, roomId, { body, files = [] }) {
|
||||
if (text.length > MAX_BODY)
|
||||
throw err("ETOOBIG", `a message is at most ${MAX_BODY} characters`, 413);
|
||||
if (files.length > MAX_FILES) throw err("ETOOBIG", `at most ${MAX_FILES} files`, 413);
|
||||
const sizeOf = (f) => (f.path ? fs.statSync(f.path).size : f.buffer.length);
|
||||
for (const f of files)
|
||||
if (f.buffer.length > MAX_FILE_BYTES) throw err("ETOOBIG", "a file is at most 25 MB", 413);
|
||||
if (sizeOf(f) > MAX_FILE_BYTES) throw err("ETOOBIG", "a file is at most 25 MB", 413);
|
||||
if (!text.trim() && !files.length) throw err("EBADFIELD", "an empty message");
|
||||
const dir = fileDir(room.id);
|
||||
const stored = files.map((f) => ({
|
||||
id: crypto.randomBytes(16).toString("hex"),
|
||||
name: cleanFileName(f.name),
|
||||
type: cleanType(f.type),
|
||||
size: sizeOf(f),
|
||||
from: f.path || null,
|
||||
buffer: f.buffer,
|
||||
}));
|
||||
if (stored.length) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
for (const f of stored) fs.writeFileSync(path.join(dir, f.id), f.buffer, { mode: 0o600 });
|
||||
for (const f of stored) {
|
||||
const dest = path.join(dir, f.id);
|
||||
// An upload already on disk (multer) is moved, never read into memory.
|
||||
if (f.from) {
|
||||
fs.renameSync(f.from, dest);
|
||||
fs.chmodSync(dest, 0o600);
|
||||
} else fs.writeFileSync(dest, f.buffer, { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
let id;
|
||||
try {
|
||||
@@ -317,11 +395,12 @@ function post(me, roomId, { body, files = [] }) {
|
||||
const ins = sql(
|
||||
"INSERT INTO chat_files (id, message_id, room_id, name, type, size) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
for (const f of stored)
|
||||
ins.run(f.id, lastInsertRowid, room.id, f.name, f.type, f.buffer.length);
|
||||
for (const f of stored) ins.run(f.id, lastInsertRowid, room.id, f.name, f.type, f.size);
|
||||
sql(
|
||||
"UPDATE chat_rooms SET last_message_at = (SELECT created_at FROM chat_messages WHERE id = ?) WHERE id = ?"
|
||||
).run(lastInsertRowid, room.id);
|
||||
// A new message brings the chat back to everyone who deleted it.
|
||||
sql("UPDATE chat_members SET hidden = 0 WHERE room_id = ? AND hidden = 1").run(room.id);
|
||||
// Your own message is read.
|
||||
sql("UPDATE chat_members SET last_read_id = ? WHERE room_id = ? AND user_id = ?").run(
|
||||
lastInsertRowid,
|
||||
@@ -343,10 +422,105 @@ function markRead(me, roomId, upTo) {
|
||||
const room = roomFor(me, roomId);
|
||||
const n = Number(upTo);
|
||||
if (!Number.isSafeInteger(n) || n < 0) throw err("EBADFIELD", "upTo: a message id");
|
||||
const top = sql("SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages WHERE room_id = ?").get(
|
||||
room.id
|
||||
).id;
|
||||
const { changes } = sql(
|
||||
"UPDATE chat_members SET last_read_id = ? WHERE room_id = ? AND user_id = ? AND last_read_id < ?"
|
||||
).run(Math.min(n, top), room.id, me, Math.min(n, top));
|
||||
const now = sql("SELECT last_read_id FROM chat_members WHERE room_id = ? AND user_id = ?").get(
|
||||
room.id,
|
||||
me
|
||||
).last_read_id;
|
||||
return { lastReadId: now, moved: changes > 0, notify: memberIds(room.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete for real what every member has deleted for themselves: messages up
|
||||
* to the lowest `cleared_id` and their files; a direct chat both people
|
||||
* deleted (hidden, nothing left) goes whole. Answers whether the room is gone.
|
||||
*/
|
||||
function purge(roomId) {
|
||||
const room = sql("SELECT * FROM chat_rooms WHERE id = ?").get(roomId);
|
||||
if (!room) return true;
|
||||
const low = sql(
|
||||
"SELECT MIN(cleared_id) AS low, MIN(hidden) AS allHidden FROM chat_members WHERE room_id = ?"
|
||||
).get(room.id);
|
||||
if (low.low == null) {
|
||||
// Nobody left.
|
||||
sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
|
||||
fs.rmSync(fileDir(room.id), { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
if (low.low > 0) {
|
||||
const files = sql("SELECT id FROM chat_files WHERE room_id = ? AND message_id <= ?").all(
|
||||
room.id,
|
||||
low.low
|
||||
);
|
||||
sql("DELETE FROM chat_messages WHERE room_id = ? AND id <= ?").run(room.id, low.low);
|
||||
for (const f of files) fs.rmSync(path.join(fileDir(room.id), f.id), { force: true });
|
||||
}
|
||||
const left = sql("SELECT COUNT(*) AS n FROM chat_messages WHERE room_id = ?").get(room.id).n;
|
||||
if (room.kind === "dm" && low.allHidden === 1 && left === 0) {
|
||||
sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
|
||||
fs.rmSync(fileDir(room.id), { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* `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) };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Delete chat" for `me` only: every message so far is gone for them; a
|
||||
* direct chat also leaves their list until a new message arrives. Nobody
|
||||
* else's copy changes.
|
||||
*/
|
||||
function clearChat(me, roomId) {
|
||||
const room = roomFor(me, roomId);
|
||||
const top = sql("SELECT COALESCE(MAX(id), 0) AS id FROM chat_messages WHERE room_id = ?").get(
|
||||
room.id
|
||||
).id;
|
||||
sql(
|
||||
"UPDATE chat_members SET last_read_id = MAX(last_read_id, ?) WHERE room_id = ? AND user_id = ?"
|
||||
).run(n, room.id, me);
|
||||
return { lastReadId: Math.max(n, 0) };
|
||||
`UPDATE chat_members SET cleared_id = ?, last_read_id = MAX(last_read_id, ?), hidden = ?
|
||||
WHERE room_id = ? AND user_id = ?`
|
||||
).run(top, top, room.kind === "dm" ? 1 : 0, room.id, me);
|
||||
const gone = purge(room.id);
|
||||
return { hidden: room.kind === "dm", purged: gone };
|
||||
}
|
||||
|
||||
/** The group's creator deletes it for everyone, files included. Answers who to tell. */
|
||||
function disbandGroup(me, roomId) {
|
||||
const room = roomFor(me, roomId);
|
||||
if (room.kind !== "group") throw err("EBADFIELD", "only a group can be disbanded");
|
||||
if (room.created_by !== me) throw err("EFORBIDDEN", "only the group's creator disbands it", 403);
|
||||
const before = memberIds(room.id);
|
||||
sql("DELETE FROM chat_rooms WHERE id = ?").run(room.id);
|
||||
fs.rmSync(fileDir(room.id), { recursive: true, force: true });
|
||||
return { notify: before };
|
||||
}
|
||||
|
||||
/** A file of a room `me` is in: `{path, name, type, inline}`. */
|
||||
@@ -354,13 +528,24 @@ function file(me, roomId, fileId) {
|
||||
const room = roomFor(me, roomId);
|
||||
if (typeof fileId !== "string" || !/^[0-9a-f]{32}$/.test(fileId))
|
||||
throw err("ENOTFOUND", "no such file", 404);
|
||||
const f = sql("SELECT * FROM chat_files WHERE id = ? AND room_id = ?").get(fileId, room.id);
|
||||
const f = sql(
|
||||
`SELECT f.* FROM chat_files f JOIN chat_members m ON m.room_id = f.room_id AND m.user_id = ?
|
||||
WHERE f.id = ? AND f.room_id = ? AND f.message_id > m.cleared_id`
|
||||
).get(me, fileId, room.id);
|
||||
const p = f && path.join(fileDir(room.id), f.id);
|
||||
if (!f || !fs.existsSync(p)) throw err("ENOTFOUND", "no such file", 404);
|
||||
return { path: p, name: f.name, type: f.type, inline: INLINE_TYPES.has(f.type) };
|
||||
}
|
||||
|
||||
/** Where multer writes an upload before `post` moves it into its room. */
|
||||
function incomingDir() {
|
||||
const d = path.join(chatDir(), ".incoming");
|
||||
fs.mkdirSync(d, { recursive: true, mode: 0o700 });
|
||||
return d;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
incomingDir,
|
||||
MAX_FILES,
|
||||
MAX_FILE_BYTES,
|
||||
people,
|
||||
@@ -372,6 +557,10 @@ module.exports = {
|
||||
messages,
|
||||
post,
|
||||
markRead,
|
||||
purge,
|
||||
removeMessage,
|
||||
clearChat,
|
||||
disbandGroup,
|
||||
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 (["chat_message", "chat_room", "chat_deleted", "chat_read"].includes(type)) {
|
||||
return new Set(Array.isArray(d.to) ? d.to : []);
|
||||
}
|
||||
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
|
||||
|
||||
@@ -59,7 +59,16 @@ const schemas = {
|
||||
members: { type: "array", items: { type: "integer" } },
|
||||
lastMessage: { allOf: [{ $ref: "#/components/schemas/ChatMessage" }], nullable: true },
|
||||
lastReadId: { type: "integer" },
|
||||
clearedId: {
|
||||
type: "integer",
|
||||
description: "Messages up to this id were deleted by the caller for themselves.",
|
||||
},
|
||||
unread: { type: "integer", description: "Messages of others after `lastReadId`." },
|
||||
reads: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "integer" },
|
||||
description: "Every member's read mark by user id — what the room shows as Seen.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -74,7 +83,8 @@ const paths = {
|
||||
get: {
|
||||
tags: ["Chat"],
|
||||
summary: "Users the caller can chat with",
|
||||
description: "Every enabled account but the caller: id, username, display name, avatar time.",
|
||||
description:
|
||||
"Every account but the caller: id, username, display name, avatar time, and `disabled` (not offered for a new chat).",
|
||||
responses: {
|
||||
200: {
|
||||
description: "People",
|
||||
@@ -90,6 +100,10 @@ const paths = {
|
||||
username: { type: "string" },
|
||||
displayName: { type: "string", nullable: true },
|
||||
avatarAt: { type: "string", nullable: true },
|
||||
disabled: {
|
||||
type: "boolean",
|
||||
description: "A disabled account: still named in old chats, never offered for a new one.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -145,6 +159,20 @@ const paths = {
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}": {
|
||||
delete: {
|
||||
tags: ["Chat"],
|
||||
summary: "Disband a group",
|
||||
description:
|
||||
"The group's creator only (`403 EFORBIDDEN`): deletes it for every member, its files included. Broadcasts `chat_room`.",
|
||||
parameters: [idParam],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Disbanded",
|
||||
...json({ type: "object", properties: { ok: { type: "boolean" } } }),
|
||||
},
|
||||
...fail([400, 403, 404]),
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Chat"],
|
||||
summary: "Rename a group, add or remove members",
|
||||
@@ -164,6 +192,31 @@ const paths = {
|
||||
responses: { 200: { description: "Updated", ...roomResp }, ...fail([400, 403, 404]) },
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}/clear": {
|
||||
post: {
|
||||
tags: ["Chat"],
|
||||
summary: "Delete a chat for yourself",
|
||||
description:
|
||||
"Every message so far is gone for the caller only (`clearedId`); a direct chat also leaves their list until a new message arrives. Nobody else's copy changes — but what EVERY member has deleted is deleted for real (messages, files, and a direct chat both people deleted, whole).",
|
||||
parameters: [idParam],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Cleared",
|
||||
...json({
|
||||
type: "object",
|
||||
properties: {
|
||||
hidden: { type: "boolean" },
|
||||
purged: {
|
||||
type: "boolean",
|
||||
description: "Every member has now deleted it: the chat is gone from the server.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
...fail([404]),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat/rooms/{id}/leave": {
|
||||
post: {
|
||||
tags: ["Chat"],
|
||||
@@ -239,10 +292,31 @@ 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"],
|
||||
summary: "Mark a room read up to a message",
|
||||
description:
|
||||
"Never moves back, never past the room's newest message. A mark that moved is told to the members as `chat_read` (`{roomId, userId, lastReadId}`).",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
+59
-8
@@ -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>
|
||||
*/
|
||||
@@ -48,12 +48,17 @@ function tell(type, data, to) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Multipart `files` + `body`, in memory, within the chat's limits. */
|
||||
/** Multipart `files` + `body`, written to disk (never held whole in memory), within the chat's limits. */
|
||||
function upload(req, res, next) {
|
||||
if (!req.is("multipart/form-data")) return next();
|
||||
const multer = require("multer");
|
||||
multer({
|
||||
storage: multer.memoryStorage(),
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, chat.incomingDir()),
|
||||
// A name of ours; the browser's goes only into the database.
|
||||
filename: (_req, _file, cb) =>
|
||||
cb(null, require("node:crypto").randomBytes(16).toString("hex")),
|
||||
}),
|
||||
limits: { fileSize: chat.MAX_FILE_BYTES, files: chat.MAX_FILES },
|
||||
}).array("files")(req, res, (e) => {
|
||||
if (!e) return next();
|
||||
@@ -98,12 +103,14 @@ router.patch(
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => {
|
||||
const b = req.body || {};
|
||||
const { room, notify } = chat.updateGroup(me, req.params.id, {
|
||||
const { room, notify, lines } = chat.updateGroup(me, req.params.id, {
|
||||
name: b.name,
|
||||
add: b.add,
|
||||
remove: b.remove,
|
||||
});
|
||||
tell("chat_room", { roomId: room.id }, notify);
|
||||
// The "X added Y" lines, live in every open view.
|
||||
for (const message of lines) tell("chat_message", { roomId: room.id, message }, room.members);
|
||||
res.json({ room });
|
||||
})
|
||||
);
|
||||
@@ -112,7 +119,28 @@ router.post(
|
||||
"/rooms/:id/leave",
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => {
|
||||
const { notify } = chat.leaveGroup(me, req.params.id);
|
||||
const { notify, lines } = chat.leaveGroup(me, req.params.id);
|
||||
tell("chat_room", { roomId: Number(req.params.id) }, notify);
|
||||
const stay = notify.filter((id) => id !== me);
|
||||
for (const message of lines)
|
||||
tell("chat_message", { roomId: Number(req.params.id), message }, stay);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
);
|
||||
|
||||
// Delete the chat for the caller only (their history; a direct chat leaves their list).
|
||||
router.post(
|
||||
"/rooms/:id/clear",
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => res.json(chat.clearChat(me, req.params.id)))
|
||||
);
|
||||
|
||||
// The group's creator deletes it for everyone.
|
||||
router.delete(
|
||||
"/rooms/:id",
|
||||
sameOriginGuard,
|
||||
handle((me, req, res) => {
|
||||
const { notify } = chat.disbandGroup(me, req.params.id);
|
||||
tell("chat_room", { roomId: Number(req.params.id) }, notify);
|
||||
res.json({ ok: true });
|
||||
})
|
||||
@@ -131,18 +159,41 @@ router.post("/rooms/:id/messages", sameOriginGuard, upload, (req, res) =>
|
||||
// multer reads names as latin1; browsers send UTF-8.
|
||||
name: Buffer.from(f.originalname, "latin1").toString("utf8"),
|
||||
type: f.mimetype,
|
||||
buffer: f.buffer,
|
||||
path: f.path,
|
||||
}));
|
||||
const { message, notify } = chat.post(me, req.params.id, { body: req.body?.body, files });
|
||||
let posted;
|
||||
try {
|
||||
posted = chat.post(me, req.params.id, { body: req.body?.body, files });
|
||||
} finally {
|
||||
// Moved into place on success; anything left over is a refused upload.
|
||||
for (const f of files) require("node:fs").rmSync(f.path, { force: true });
|
||||
}
|
||||
const { message, notify } = posted;
|
||||
tell("chat_message", { roomId: message.roomId, message }, notify);
|
||||
res.status(201).json({ message });
|
||||
})(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,
|
||||
handle((me, req, res) => res.json(chat.markRead(me, req.params.id, req.body?.upTo)))
|
||||
handle((me, req, res) => {
|
||||
const { lastReadId, moved, notify } = chat.markRead(me, req.params.id, req.body?.upTo);
|
||||
// "Seen": the other members' views move their read marks.
|
||||
if (moved) tell("chat_read", { roomId: Number(req.params.id), userId: me, lastReadId }, notify);
|
||||
res.json({ lastReadId });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
|
||||
Reference in New Issue
Block a user