21a042dc2b
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).
847 lines
28 KiB
TypeScript
847 lines
28 KiB
TypeScript
/**
|
|
* @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>
|
|
);
|
|
}
|