feat(chat): direct chats and groups between users in the pet's window
Server mode: a rail beside the pet's chat lists the pet, then the user's direct chats (one per pair) and groups with unread counts, and + for a new chat. A room shows its messages (group senders named), loads older pages, and sends text, pasted / dropped / attached files (<= 10 x 25 MB; images inline). A group's creator renames it and adds or removes people; anyone may leave. A blue dot on the pet counts unread messages. Only a room's members read or write it, an admin included (roomFor → 404). Files are stored under server-made names in <data dir>/chat/<room>/ and served to members only under a sandbox CSP + nosniff. chat_message / chat_room frames reach exactly the ids in `to` (scope.exactAudience). New tables chat_rooms / chat_members / chat_messages / chat_files (additive).
This commit is contained in:
@@ -0,0 +1,846 @@
|
||||
/**
|
||||
* @file ChatHub.tsx
|
||||
* @description Chat between users inside the pet's window (server mode,
|
||||
* `/api/chat`, state from `useChat.ts`). `ChatRail` is the narrow column
|
||||
* beside the window: the pet itself first (its AI chat, `PetChat.tsx`), then
|
||||
* the user's direct chats and groups, newest first, each with its unread
|
||||
* count, and **+** for a new chat. `ChatRoomView` is one room, the same size as
|
||||
* the pet's chat: messages (a group shows who wrote each), older pages on
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
FileText,
|
||||
LogOut,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Settings2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, 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 type { ChatState } from "./useChat";
|
||||
|
||||
const MAX_FILES = 10;
|
||||
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/** Shown name of a person (display name, else login). */
|
||||
const personName = (p: Pick<ChatPerson, "username" | "displayName"> | undefined) =>
|
||||
p ? p.displayName || p.username : "?";
|
||||
|
||||
/** Everyone the view may need a name for: the people list plus the viewer. */
|
||||
function useDirectory(chat: ChatState) {
|
||||
const { state } = useAuth();
|
||||
const self = state.mode === "server" ? state.user : null;
|
||||
return useMemo(() => {
|
||||
const m = new Map<number, ChatPerson>(chat.people.map((p) => [p.id, p]));
|
||||
if (self)
|
||||
m.set(self.id, {
|
||||
id: self.id,
|
||||
username: self.username,
|
||||
displayName: self.displayName ?? null,
|
||||
avatarAt: self.avatarAt ?? null,
|
||||
});
|
||||
return m;
|
||||
}, [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;
|
||||
|
||||
function roomTitle(room: ChatRoom, me: number | null, dir: Map<number, ChatPerson>) {
|
||||
if (room.kind === "dm") return personName(dir.get(otherOf(room, me)));
|
||||
if (room.name) return room.name;
|
||||
return room.members
|
||||
.filter((id) => id !== me)
|
||||
.map((id) => personName(dir.get(id)))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function RoomIcon({
|
||||
room,
|
||||
me,
|
||||
dir,
|
||||
className,
|
||||
}: {
|
||||
room: ChatRoom;
|
||||
me: number | null;
|
||||
dir: Map<number, ChatPerson>;
|
||||
className: string;
|
||||
}) {
|
||||
if (room.kind === "dm") {
|
||||
const p = dir.get(otherOf(room, me));
|
||||
return (
|
||||
<UserAvatar
|
||||
name={personName(p)}
|
||||
userId={p?.id}
|
||||
avatarAt={p?.avatarAt}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`flex items-center justify-center rounded-full bg-surface-4 text-fg-secondary ${className}`}
|
||||
>
|
||||
<Users size={16} aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const Badge = ({ n }: { n: number }) =>
|
||||
n > 0 ? (
|
||||
<span className="absolute -right-0.5 -top-0.5 min-w-[1.1rem] rounded-full bg-status-danger px-1 text-center text-[10px] font-semibold leading-[1.1rem] text-white">
|
||||
{n > 99 ? "99+" : n}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
/** The column beside the window: the pet, the rooms, and New chat. */
|
||||
export function ChatRail({
|
||||
chat,
|
||||
active,
|
||||
onSelect,
|
||||
petName,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
/** `"pet"`, `"new"` or a room id. */
|
||||
active: "pet" | "new" | number;
|
||||
onSelect: (v: "pet" | "new" | number) => void;
|
||||
petName: string;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const dir = useDirectory(chat);
|
||||
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"
|
||||
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 />
|
||||
{chat.rooms.map((r) => {
|
||||
const title = roomTitle(r, chat.me, dir);
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(r.id)}
|
||||
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}
|
||||
aria-pressed={active === r.id}
|
||||
>
|
||||
<RoomIcon room={r} me={chat.me} dir={dir} className="h-9 w-9 text-[11px]" />
|
||||
<Badge n={r.unread} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect("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>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** People picker with checkboxes. */
|
||||
function PeoplePicker({
|
||||
people,
|
||||
picked,
|
||||
onToggle,
|
||||
}: {
|
||||
people: ChatPerson[];
|
||||
picked: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [q, setQ] = useState("");
|
||||
const shown = people.filter((p) =>
|
||||
`${p.displayName ?? ""} ${p.username}`.toLowerCase().includes(q.trim().toLowerCase())
|
||||
);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<input
|
||||
className="input 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">
|
||||
{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">
|
||||
<input type="checkbox" checked={picked.has(p.id)} onChange={() => onToggle(p.id)} />
|
||||
<UserAvatar
|
||||
name={personName(p)}
|
||||
userId={p.id}
|
||||
avatarAt={p.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
||||
{personName(p)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
{!shown.length && (
|
||||
<li className="px-2 py-4 text-center text-xs text-fg-muted">{t("sagi.people.none")}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Start a chat: one person → the direct chat; more, or a name → a group. */
|
||||
export function NewChat({
|
||||
chat,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
onOpen: (roomId: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set());
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const group = picked.size > 1 || name.trim() !== "";
|
||||
const toggle = (id: number) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const start = async () => {
|
||||
if (!picked.size) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { room } = group
|
||||
? await api.chat.createGroup(name.trim(), [...picked])
|
||||
: await api.chat.openDm([...picked][0]!);
|
||||
chat.upsert(room);
|
||||
onOpen(room.id);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Shell label={t("sagi.people.new")}>
|
||||
<div className="pet-chat-head flex items-center gap-1 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
|
||||
{t("sagi.people.new")}
|
||||
</div>
|
||||
<HeadButton label={t("sagi.people.close")} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</HeadButton>
|
||||
</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} />
|
||||
{picked.size > 1 && (
|
||||
<input
|
||||
className="input text-sm"
|
||||
placeholder={t("sagi.people.groupName")}
|
||||
aria-label={t("sagi.people.groupName")}
|
||||
maxLength={80}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="pet-chat-foot px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary w-full justify-center"
|
||||
disabled={!picked.size || busy}
|
||||
onClick={() => void start()}
|
||||
>
|
||||
{group ? t("sagi.people.createGroup", { n: picked.size }) : t("sagi.people.startDm")}
|
||||
</button>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const URL_RE = /(https?:\/\/[^\s<>"']+)/g;
|
||||
|
||||
/** Text with its http(s) links clickable (a new tab). */
|
||||
function Linkified({ text }: { text: string }) {
|
||||
const parts = text.split(URL_RE);
|
||||
return (
|
||||
<>
|
||||
{parts.map((p, i) =>
|
||||
i % 2 === 1 ? (
|
||||
<a key={i} href={p} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
{p}
|
||||
</a>
|
||||
) : (
|
||||
<span key={i}>{p}</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemLine({ m, dir }: { m: ChatMessage; dir: Map<number, ChatPerson> }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const who = personName(m.userId != null ? dir.get(m.userId) : undefined);
|
||||
const [kind, ids] = m.body.split(":");
|
||||
const names = (ids ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((id) => personName(dir.get(Number(id))))
|
||||
.join(", ");
|
||||
const text =
|
||||
kind === "added"
|
||||
? t("sagi.people.sysAdded", { who, names })
|
||||
: kind === "removed"
|
||||
? t("sagi.people.sysRemoved", { who, names })
|
||||
: kind === "left"
|
||||
? t("sagi.people.sysLeft", { who })
|
||||
: kind === "renamed"
|
||||
? t("sagi.people.sysRenamed", { who })
|
||||
: t("sagi.people.sysCreated", { who });
|
||||
return <p className="py-1 text-center text-[11px] text-fg-muted">{text}</p>;
|
||||
}
|
||||
|
||||
function Files({ m, own }: { m: ChatMessage; own: boolean }) {
|
||||
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">
|
||||
<img
|
||||
src={url}
|
||||
alt={f.name}
|
||||
loading="lazy"
|
||||
className="max-h-48 max-w-full rounded-xl object-contain"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
key={f.id}
|
||||
href={url}
|
||||
download={f.name}
|
||||
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 />
|
||||
<span className="min-w-0 flex-1 truncate">{f.name}</span>
|
||||
<span className="shrink-0 opacity-70">{formatBytes(f.size)}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A group's settings: name, members, Leave. */
|
||||
function GroupSettings({
|
||||
chat,
|
||||
room,
|
||||
dir,
|
||||
onBack,
|
||||
onLeft,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
room: ChatRoom;
|
||||
dir: Map<number, ChatPerson>;
|
||||
onBack: () => void;
|
||||
onLeft: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const owner = room.createdBy === chat.me;
|
||||
const [name, setName] = useState(room.name);
|
||||
const [adding, setAdding] = useState<Set<number>>(new Set());
|
||||
const save = async (patch: { name?: string; add?: number[]; remove?: number[] }) => {
|
||||
try {
|
||||
const r = await api.chat.updateGroup(room.id, patch);
|
||||
chat.upsert(r.room);
|
||||
setAdding(new Set());
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
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);
|
||||
}
|
||||
};
|
||||
const outside = chat.people.filter((p) => !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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1 self-start text-xs text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<ArrowLeft size={13} /> {t("sagi.people.back")}
|
||||
</button>
|
||||
{owner && (
|
||||
<form
|
||||
className="flex gap-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save({ name });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input 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}>
|
||||
{t("sagi.people.rename")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.people.members", { n: room.members.length })}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{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">
|
||||
<UserAvatar
|
||||
name={personName(p)}
|
||||
userId={id}
|
||||
avatarAt={p?.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
||||
{personName(p)}
|
||||
{id === room.createdBy && (
|
||||
<span className="ml-1 text-[10px] text-fg-muted">
|
||||
{t("sagi.people.creator")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{owner && id !== chat.me && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1 text-fg-muted hover:text-status-danger"
|
||||
aria-label={t("sagi.people.remove", { name: personName(p) })}
|
||||
title={t("sagi.people.remove", { name: personName(p) })}
|
||||
onClick={() => void save({ remove: [id] })}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{owner && outside.length > 0 && (
|
||||
<div className="flex min-h-[10rem] flex-col gap-1.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.people.add")}
|
||||
</p>
|
||||
<PeoplePicker
|
||||
people={outside}
|
||||
picked={adding}
|
||||
onToggle={(id) =>
|
||||
setAdding((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(id)) n.delete(id);
|
||||
else n.add(id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary justify-center"
|
||||
disabled={!adding.size}
|
||||
onClick={() => void save({ add: [...adding] })}
|
||||
>
|
||||
{t("sagi.people.addN", { n: adding.size })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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"
|
||||
onClick={() => void leave()}
|
||||
>
|
||||
<LogOut size={14} /> {t("sagi.people.leave")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One direct chat or group. */
|
||||
export function ChatRoomView({
|
||||
chat,
|
||||
room,
|
||||
onClose,
|
||||
onLeft,
|
||||
}: {
|
||||
chat: ChatState;
|
||||
room: ChatRoom;
|
||||
onClose: () => void;
|
||||
onLeft: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const dir = useDirectory(chat);
|
||||
const title = roomTitle(room, chat.me, dir);
|
||||
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);
|
||||
const [drag, setDrag] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const stick = useRef(true);
|
||||
const { markRead } = chat;
|
||||
|
||||
const read = useCallback(
|
||||
(upTo: number) => {
|
||||
markRead(room.id, upTo);
|
||||
api.chat.read(room.id, upTo).catch(() => undefined);
|
||||
},
|
||||
[room.id, markRead]
|
||||
);
|
||||
|
||||
// The room's newest page on open, then each new message as it arrives.
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setMessages([]);
|
||||
setSettings(false);
|
||||
stick.current = true;
|
||||
api.chat
|
||||
.messages(room.id)
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setMessages(r.messages);
|
||||
setHasMore(r.hasMore);
|
||||
const last = r.messages[r.messages.length - 1];
|
||||
if (last) read(last.id);
|
||||
})
|
||||
.catch((e) => toast.error(e));
|
||||
chat.onMessage((m) => {
|
||||
if (m.roomId !== room.id) return;
|
||||
setMessages((prev) => (prev.some((x) => x.id === m.id) ? prev : [...prev, m]));
|
||||
if (document.visibilityState === "visible") read(m.id);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
chat.onMessage(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
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]);
|
||||
setHasMore(r.hasMore);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const addFiles = (list: FileList | File[] | null) => {
|
||||
if (!list) return;
|
||||
const picked = [...list];
|
||||
const big = picked.find((f) => f.size > MAX_FILE_BYTES);
|
||||
if (big) toast.error(t("sagi.people.tooBig", { name: big.name }));
|
||||
setFiles((prev) =>
|
||||
[...prev, ...picked.filter((f) => f.size <= MAX_FILE_BYTES)].slice(0, MAX_FILES)
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if ((!draft.trim() && !files.length) || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const { message } = await api.chat.post(room.id, draft, files);
|
||||
stick.current = true;
|
||||
setMessages((prev) => (prev.some((x) => x.id === message.id) ? prev : [...prev, message]));
|
||||
setDraft("");
|
||||
setFiles([]);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell label={t("sagi.people.roomLabel", { name: title })}>
|
||||
<div className="pet-chat-head flex items-center gap-2 px-2.5 py-2">
|
||||
<RoomIcon room={room} me={chat.me} dir={dir} className="h-7 w-7 text-[10px]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-fg-primary" title={title}>
|
||||
{title}
|
||||
</div>
|
||||
{room.kind === "group" && (
|
||||
<div className="text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.people.members", { n: room.members.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{room.kind === "group" && (
|
||||
<HeadButton label={t("sagi.people.settings")} onClick={() => setSettings((v) => !v)}>
|
||||
<Settings2 size={15} />
|
||||
</HeadButton>
|
||||
)}
|
||||
<HeadButton label={t("sagi.people.close")} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</HeadButton>
|
||||
</div>
|
||||
|
||||
{settings && room.kind === "group" ? (
|
||||
<GroupSettings
|
||||
chat={chat}
|
||||
room={room}
|
||||
dir={dir}
|
||||
onBack={() => setSettings(false)}
|
||||
onLeft={onLeft}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={listRef}
|
||||
className={`flex-1 space-y-1.5 overflow-y-auto 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;
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
e.preventDefault();
|
||||
setDrag(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDrag(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDrag(false);
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
className="mx-auto block text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
onClick={() => void older()}
|
||||
>
|
||||
{t("sagi.people.older")}
|
||||
</button>
|
||||
)}
|
||||
{!messages.length && (
|
||||
<p className="py-10 text-center text-xs text-fg-muted">
|
||||
{t("sagi.people.empty", { name: title })}
|
||||
</p>
|
||||
)}
|
||||
{messages.map((m, i) => {
|
||||
if (m.kind === "system") return <SystemLine key={m.id} m={m} dir={dir} />;
|
||||
const own = m.userId === chat.me;
|
||||
const prev = messages[i - 1];
|
||||
const showName =
|
||||
!own &&
|
||||
room.kind === "group" &&
|
||||
(!prev || prev.userId !== m.userId || prev.kind === "system");
|
||||
return (
|
||||
<div key={m.id} className={`flex flex-col ${own ? "items-end" : "items-start"}`}>
|
||||
{showName && (
|
||||
<span className="mb-0.5 px-1 text-[10px] text-fg-muted">
|
||||
{personName(m.userId != null ? dir.get(m.userId) : undefined)}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm shadow-sm ${
|
||||
own
|
||||
? "rounded-br-md bg-accent-solid text-white"
|
||||
: "pet-chat-reply rounded-bl-md text-fg-primary"
|
||||
}`}
|
||||
title={formatTime(m.createdAt)}
|
||||
>
|
||||
{m.body && <Linkified text={m.body} />}
|
||||
{m.files.length > 0 && <Files m={m} own={own} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pet-chat-foot px-2.5 py-2.5">
|
||||
{files.length > 0 && (
|
||||
<ul className="mb-1.5 flex flex-wrap gap-1">
|
||||
{files.map((f, i) => (
|
||||
<li
|
||||
key={`${f.name}-${i}`}
|
||||
className="flex max-w-full items-center gap-1 rounded-full bg-surface-4 py-0.5 pl-2 pr-1 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<span className="max-w-[10rem] truncate">{f.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-0.5 hover:text-status-danger"
|
||||
aria-label={t("sagi.people.unattach", { name: f.name })}
|
||||
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
aria-label={t("sagi.people.attach")}
|
||||
title={t("sagi.people.attach")}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
<textarea
|
||||
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"
|
||||
placeholder={t("sagi.people.placeholder", { name: title })}
|
||||
aria-label={t("sagi.people.placeholder", { name: title })}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
if (e.clipboardData.files.length) {
|
||||
e.preventDefault();
|
||||
addFiles(e.clipboardData.files);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={(!draft.trim() && !files.length) || sending}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
* shares the single WebSocket.
|
||||
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
|
||||
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
|
||||
* itself is answered there, anything else goes to the AI.
|
||||
* itself is answered there, anything else goes to the AI. In server mode a
|
||||
* rail beside it (`ChatHub.tsx`, `useChat.ts`) switches to direct chats and
|
||||
* groups with other users, and a blue dot on the pet counts their unread
|
||||
* messages.
|
||||
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
@@ -100,6 +103,8 @@ 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 { useChat } from "./useChat";
|
||||
|
||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||
@@ -238,6 +243,14 @@ function SagiBody() {
|
||||
const navigate = useNavigate();
|
||||
const href = useSettingsHref();
|
||||
const brain = useSagiBrain();
|
||||
// Server mode: chat with other users beside the pet's own (ChatHub.tsx).
|
||||
const people = useChat();
|
||||
const [pane, setPane] = useState<"pet" | "new" | number>("pet");
|
||||
const room = typeof pane === "number" ? people.rooms.find((r) => r.id === pane) : undefined;
|
||||
// A room that went away (left, removed): back to the pet.
|
||||
useEffect(() => {
|
||||
if (typeof pane === "number" && people.rooms.length && !room) setPane("pet");
|
||||
}, [pane, room, people.rooms.length]);
|
||||
const place = useSagiPosition(size);
|
||||
|
||||
// Keep the pet and its size in sync with Settings / other tabs (enabled is
|
||||
@@ -416,47 +429,63 @@ function SagiBody() {
|
||||
re-attaches the same session. */}
|
||||
{open && (
|
||||
<SagiFlyout anchor={anchor} beside>
|
||||
<PetChat
|
||||
name={name}
|
||||
title={
|
||||
<PetNameButton
|
||||
<div className="flex items-start gap-2">
|
||||
{people.enabled && (
|
||||
<ChatRail chat={people} active={pane} onSelect={setPane} petName={name} />
|
||||
)}
|
||||
{people.enabled && pane === "new" ? (
|
||||
<NewChat chat={people} onOpen={setPane} onClose={() => setOpen(false)} />
|
||||
) : people.enabled && room ? (
|
||||
<ChatRoomView
|
||||
chat={people}
|
||||
room={room}
|
||||
onClose={() => setOpen(false)}
|
||||
onLeft={() => setPane("pet")}
|
||||
/>
|
||||
) : (
|
||||
<PetChat
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
title={
|
||||
<PetNameButton
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
/>
|
||||
}
|
||||
headerActions={
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={brain.pat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
}
|
||||
info={
|
||||
<SagiInfo
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
defaultOpen={brain.attention.length > 0}
|
||||
/>
|
||||
}
|
||||
onLocal={onLocal}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
}
|
||||
headerActions={
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={brain.pat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
}
|
||||
info={
|
||||
<SagiInfo
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
defaultOpen={brain.attention.length > 0}
|
||||
/>
|
||||
}
|
||||
onLocal={onLocal}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
@@ -550,6 +579,11 @@ function SagiBody() {
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{people.unread > 0 && (
|
||||
<span className="sagi-chat-dot" aria-hidden>
|
||||
{people.unread > 9 ? "9+" : people.unread}
|
||||
</span>
|
||||
)}
|
||||
{brain.status.errorCount > 0 ? (
|
||||
<span className="sagi-error-dot" aria-hidden>
|
||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @file ChatHub.test.tsx
|
||||
* @description The pet window's chat between users (`ChatHub.tsx`): the rail
|
||||
* 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.
|
||||
* @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";
|
||||
import type { ChatMessage, ChatRoom } from "../../../lib/api";
|
||||
import type { ChatState } from "../useChat";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
chat: {
|
||||
openDm: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
messages: vi.fn(),
|
||||
post: vi.fn(),
|
||||
read: vi.fn(),
|
||||
updateGroup: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
fileUrl: (r: number, f: string) => `/api/chat/rooms/${r}/files/${f}`,
|
||||
},
|
||||
users: { avatarUrl: () => null },
|
||||
me: { avatarUrl: () => null },
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (orig) => ({
|
||||
...(await orig<Record<string, unknown>>()),
|
||||
api,
|
||||
}));
|
||||
vi.mock("../../AuthGate", () => ({
|
||||
useAuth: () => ({
|
||||
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ChatRail, ChatRoomView, NewChat } from "../ChatHub";
|
||||
|
||||
const msg = (id: number, userId: number, body: string): ChatMessage => ({
|
||||
id,
|
||||
roomId: 7,
|
||||
userId,
|
||||
kind: "text",
|
||||
body,
|
||||
createdAt: "2026-11-03T02:00:00Z",
|
||||
files: [],
|
||||
});
|
||||
const group: ChatRoom = {
|
||||
id: 7,
|
||||
kind: "group",
|
||||
name: "Team",
|
||||
createdBy: 1,
|
||||
createdAt: "2026-11-03T01:00:00Z",
|
||||
lastMessageAt: null,
|
||||
members: [1, 2, 3],
|
||||
lastMessage: null,
|
||||
lastReadId: 0,
|
||||
unread: 2,
|
||||
};
|
||||
const dm: ChatRoom = { ...group, id: 8, kind: "dm", name: "", members: [1, 2], unread: 0 };
|
||||
|
||||
function state(over: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
enabled: true,
|
||||
me: 1,
|
||||
rooms: [group, dm],
|
||||
people: [
|
||||
{ id: 2, username: "an", displayName: "An", avatarAt: null },
|
||||
{ id: 3, username: "binh", displayName: null, avatarAt: null },
|
||||
],
|
||||
unread: 2,
|
||||
reload: vi.fn(async () => {}),
|
||||
upsert: vi.fn(),
|
||||
onMessage: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("ChatHub", () => {
|
||||
it("lists the pet, then each room with its unread count", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ChatRail chat={state()} active="pet" onSelect={onSelect} petName="Sagi" />);
|
||||
expect(screen.getByRole("button", { name: "Sagi" })).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Team, 2 unread" }));
|
||||
expect(onSelect).toHaveBeenCalledWith(7);
|
||||
// A direct chat is named after the other person.
|
||||
expect(screen.getByRole("button", { name: "An" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a direct chat for one person and a group for several", async () => {
|
||||
const chat = state();
|
||||
const onOpen = vi.fn();
|
||||
api.chat.openDm.mockResolvedValue({ room: dm });
|
||||
render(<NewChat chat={chat} onOpen={onOpen} onClose={() => {}} />);
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /An/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start chat" }));
|
||||
await waitFor(() => expect(onOpen).toHaveBeenCalledWith(8));
|
||||
expect(api.chat.openDm).toHaveBeenCalledWith(2);
|
||||
|
||||
api.chat.createGroup.mockResolvedValue({ room: group });
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /binh/ }));
|
||||
fireEvent.change(screen.getByLabelText("Group name (optional)"), { target: { value: "Team" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create group (2 people)" }));
|
||||
await waitFor(() => expect(api.chat.createGroup).toHaveBeenCalledWith("Team", [2, 3]));
|
||||
});
|
||||
|
||||
it("reads the room, names group senders, marks it read and sends with files", async () => {
|
||||
const chat = state();
|
||||
api.chat.messages.mockResolvedValue({
|
||||
messages: [msg(1, 2, "chào"), msg(2, 1, "hi")],
|
||||
hasMore: false,
|
||||
});
|
||||
api.chat.read.mockResolvedValue({ lastReadId: 2 });
|
||||
render(<ChatRoomView chat={chat} room={group} onClose={() => {}} onLeft={() => {}} />);
|
||||
expect(await screen.findByText("chào")).toBeTruthy();
|
||||
expect(screen.getByText("An")).toBeTruthy(); // sender of a group message
|
||||
expect(api.chat.read).toHaveBeenCalledWith(7, 2);
|
||||
expect(chat.markRead).toHaveBeenCalledWith(7, 2);
|
||||
|
||||
// A message arriving live is appended.
|
||||
const calls = vi.mocked(chat.onMessage).mock.calls;
|
||||
const push = calls[calls.length - 1]![0]!;
|
||||
push(msg(3, 3, "mới"));
|
||||
expect(await screen.findByText("mới")).toBeTruthy();
|
||||
|
||||
const file = new File(["x"], "a.png", { type: "image/png" });
|
||||
const box = screen.getByLabelText("Message Team");
|
||||
fireEvent.paste(box, { clipboardData: { files: [file] } });
|
||||
expect(screen.getByText("a.png")).toBeTruthy();
|
||||
fireEvent.change(box, { target: { value: "xem nè" } });
|
||||
api.chat.post.mockResolvedValue({ message: { ...msg(4, 1, "xem nè") } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
await waitFor(() => expect(api.chat.post).toHaveBeenCalledWith(7, "xem nè", [file]));
|
||||
expect(await screen.findByText("xem nè")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@
|
||||
* infinite animation makes the browser draw a new frame 60 times a second for
|
||||
* as long as the page is open (the drop-shadow filter re-rendered each time),
|
||||
* which made the whole desktop stutter. All motion is disabled under
|
||||
* [data-reduced="1"] and prefers-reduced-motion.
|
||||
* [data-reduced="1"] and prefers-reduced-motion. The blue dot counts unread
|
||||
* chat messages from other users (server mode).
|
||||
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
||||
* and .sagi-eye-blink for which reference box each one resolves against.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -406,6 +407,25 @@
|
||||
outline: 2px solid #818cf8;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
/* Unread chat messages from other users: top-left, opposite the error dot. */
|
||||
.sagi-chat-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 2px;
|
||||
border-radius: 9999px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #0c0c14;
|
||||
}
|
||||
|
||||
.sagi-error-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @file useChat.ts
|
||||
* @description The pet window's chat between users (server mode only): the
|
||||
* signed-in user's rooms (`GET /api/chat/rooms`), the people they can start a
|
||||
* chat with (`GET /api/chat/people`, read once), and the total unread count
|
||||
* 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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 type { WSMessage } from "../../lib/types";
|
||||
|
||||
export type ChatState = {
|
||||
/** Server mode with a login: the chat exists. */
|
||||
enabled: boolean;
|
||||
me: number | null;
|
||||
rooms: ChatRoom[];
|
||||
people: ChatPerson[];
|
||||
unread: number;
|
||||
reload: () => Promise<void>;
|
||||
/** A room changed here (opened, renamed, read): put it in the list now. */
|
||||
upsert: (room: ChatRoom) => void;
|
||||
/** The open room's view, told of each new message for it. */
|
||||
onMessage: (fn: ((m: ChatMessage) => void) | null) => void;
|
||||
/** Mark a room read locally (the view also tells the server). */
|
||||
markRead: (roomId: number, upTo: number) => void;
|
||||
};
|
||||
|
||||
export function useChat(): ChatState {
|
||||
const { state } = useAuth();
|
||||
const me = state.mode === "server" && "user" in state && state.user ? state.user.id : null;
|
||||
const enabled = me != null;
|
||||
const [rooms, setRooms] = useState<ChatRoom[]>([]);
|
||||
const [people, setPeople] = useState<ChatPerson[]>([]);
|
||||
const listener = useRef<((m: ChatMessage) => void) | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
setRooms((await api.chat.rooms()).rooms);
|
||||
} catch {
|
||||
// The pet's chat with the AI still works; the list retries on the next frame.
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void reload();
|
||||
api.chat
|
||||
.people()
|
||||
.then((r) => setPeople(r.people))
|
||||
.catch(() => undefined);
|
||||
}, [enabled, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const off = eventBus.subscribe((msg: WSMessage) => {
|
||||
if (msg.type === "chat_room") {
|
||||
void reload();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "chat_message") return;
|
||||
const m = (msg.data as { message?: ChatMessage }).message;
|
||||
if (!m) return;
|
||||
let known = false;
|
||||
setRooms((prev) => {
|
||||
const next = prev.map((r) => {
|
||||
if (r.id !== m.roomId) return r;
|
||||
known = true;
|
||||
const own = m.userId === me;
|
||||
return {
|
||||
...r,
|
||||
lastMessage: m,
|
||||
lastMessageAt: m.createdAt,
|
||||
unread: own ? 0 : r.unread + 1,
|
||||
lastReadId: own ? m.id : r.lastReadId,
|
||||
};
|
||||
});
|
||||
return next.sort((a, b) =>
|
||||
(b.lastMessageAt ?? b.createdAt).localeCompare(a.lastMessageAt ?? a.createdAt)
|
||||
);
|
||||
});
|
||||
// A room this list has not seen yet (added a moment ago): read it all.
|
||||
if (!known) void reload();
|
||||
listener.current?.(m);
|
||||
});
|
||||
// Reconnected: frames may have been missed.
|
||||
const offConn = eventBus.onConnection((up) => {
|
||||
if (up) void reload();
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
offConn();
|
||||
};
|
||||
}, [enabled, me, reload]);
|
||||
|
||||
const upsert = useCallback((room: ChatRoom) => {
|
||||
setRooms((prev) => [room, ...prev.filter((r) => r.id !== room.id)]);
|
||||
}, []);
|
||||
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 unread = useMemo(() => rooms.reduce((n, r) => n + r.unread, 0), [rooms]);
|
||||
return { enabled, me, rooms, people, unread, reload, upsert, onMessage, markRead };
|
||||
}
|
||||
Reference in New Issue
Block a user