Files
AgentHub/client/src/components/Sagi/useChat.ts
T
nntrivi2001 03ebea1d92 fix(chat): the review's gaps — owner hand-over, live group lines, names, scroll, one tab's sound, uploads on disk, a chat button without the pet
- 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 <data dir>/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.
2026-11-03 09:59:13 +07:00

306 lines
11 KiB
TypeScript

/**
* @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. 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>
*/
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;
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;
/** 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;
const enabled = me != null;
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;
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]);
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();
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;
}
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,
// 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,
};
});
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);
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) => {
if (up) void reload();
});
return () => {
off();
offConn();
};
}, [enabled, me, reload, reloadPeople]);
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),
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,
setActive,
notice,
dismissNotice,
onDeleted,
reloadPeople,
muted,
toggleMute,
};
}