03ebea1d92
- 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.
306 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|