From 03ebea1d928b5000a11b92b2ea401425443c77c7 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 3 Nov 2026 09:59:13 +0700 Subject: [PATCH] =?UTF-8?q?fix(chat):=20the=20review's=20gaps=20=E2=80=94?= =?UTF-8?q?=20owner=20hand-over,=20live=20group=20lines,=20names,=20scroll?= =?UTF-8?q?,=20one=20tab's=20sound,=20uploads=20on=20disk,=20a=20chat=20bu?= =?UTF-8?q?tton=20without=20the=20pet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A group whose creator left or was deleted passes to its longest-standing member (ensureOwner), so it can still be managed. - Group changes (added / removed / renamed / left) are sent live as chat_message and never count as unread, here or on the server. - A disabled account is still named in old chats (people carries `disabled`) and never offered for a new one; a deleted one reads 'Deleted user' instead of '?'. - Load older keeps the message being read in place. - With several tabs open only the first to see a message plays its sound / system notification (agenthub.chat.notified). - The people list is read again on New chat and on every room change. - Uploads go to /chat/.incoming/ (multer disk storage) and are renamed into place — never held in memory; a refused one is removed. - With the pet hidden, a chat button in the bottom-right corner (ChatDock, server mode) opens the same window, unread count and new-message card included. --- client/src/components/Sagi/ChatDock.tsx | 108 ++++++++++++++++++ client/src/components/Sagi/ChatHub.tsx | 67 +++++++---- client/src/components/Sagi/SagiMount.tsx | 16 ++- .../Sagi/__tests__/ChatDock.test.tsx | 69 +++++++++++ .../Sagi/__tests__/ChatHub.test.tsx | 22 +++- .../Sagi/__tests__/useChat.test.tsx | 27 ++++- client/src/components/Sagi/useChat.ts | 44 +++++-- client/src/i18n/locales/en/settings.json | 5 +- client/src/i18n/locales/vi/settings.json | 5 +- client/src/lib/api.ts | 2 + docs/API.md | 4 +- server/__tests__/chat.test.js | 35 ++++++ server/lib/chat.js | 99 ++++++++++++---- server/openapi-extra/chat.js | 7 +- server/routes/chat.js | 29 ++++- 15 files changed, 471 insertions(+), 68 deletions(-) create mode 100644 client/src/components/Sagi/ChatDock.tsx create mode 100644 client/src/components/Sagi/__tests__/ChatDock.test.tsx diff --git a/client/src/components/Sagi/ChatDock.tsx b/client/src/components/Sagi/ChatDock.tsx new file mode 100644 index 00000000..7e4e16d0 --- /dev/null +++ b/client/src/components/Sagi/ChatDock.tsx @@ -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ĩ + */ + +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 ( +
+ {open && ( +
+ setPane(v === "pet" ? "new" : v)} + onSettings={(id) => { + setPane(id); + setSettingsAsk((n) => n + 1); + }} + /> + {room ? ( + setOpen(false)} + onLeft={() => setPane("new")} + onOpenRoom={setPane} + showSettings={settingsAsk} + /> + ) : ( + setOpen(false)} /> + )} +
+ )} + {!open && chat.notice && ( + { + chat.dismissNotice(); + setPane(roomId); + setOpen(true); + }} + /> + )} + +
+ ); +} diff --git a/client/src/components/Sagi/ChatHub.tsx b/client/src/components/Sagi/ChatHub.tsx index 8aa8344e..977112c7 100644 --- a/client/src/components/Sagi/ChatHub.tsx +++ b/client/src/components/Sagi/ChatHub.tsx @@ -18,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, @@ -44,14 +45,15 @@ 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 | 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) { @@ -70,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) { if (room.kind === "dm") return personName(dir.get(otherOf(room, me))); @@ -263,7 +264,8 @@ export function ChatRail({ /** `"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; }) { @@ -283,18 +285,20 @@ export function ChatRail({ 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")} > - - + {petName && ( + + )} + {petName && } {chat.rooms.map((r) => { const title = roomTitle(r, chat.me, dir); return ( @@ -394,9 +398,11 @@ export function ChatRail({ ) : ( <> - onSelect("pet"))}> - {t("sagi.people.menuPet", { name: petName })} - + {petName && ( + onSelect("pet"))}> + {t("sagi.people.menuPet", { name: petName })} + + )} onSelect("new"))}>{t("sagi.people.new")} >(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) => @@ -551,7 +562,11 @@ export function NewChat({

{t("sagi.people.newHint")}

- + !p.disabled)} + picked={picked} + onToggle={toggle} + /> {picked.size > 1 && ( { 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 (