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.
This commit is contained in:
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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 (
|
||||
<div className="fixed bottom-10 right-4 z-40 flex flex-col items-end gap-2">
|
||||
{open && (
|
||||
<div className="flex items-start gap-2">
|
||||
<ChatRail
|
||||
chat={chat}
|
||||
active={pane}
|
||||
onSelect={(v) => setPane(v === "pet" ? "new" : v)}
|
||||
onSettings={(id) => {
|
||||
setPane(id);
|
||||
setSettingsAsk((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
{room ? (
|
||||
<ChatRoomView
|
||||
key={`${room.id}:${room.clearedId ?? 0}`}
|
||||
chat={chat}
|
||||
room={room}
|
||||
onClose={() => setOpen(false)}
|
||||
onLeft={() => setPane("new")}
|
||||
onOpenRoom={setPane}
|
||||
showSettings={settingsAsk}
|
||||
/>
|
||||
) : (
|
||||
<NewChat chat={chat} onOpen={setPane} onClose={() => setOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!open && chat.notice && (
|
||||
<ChatNotice
|
||||
notice={chat.notice}
|
||||
onDismiss={chat.dismissNotice}
|
||||
onOpen={(roomId) => {
|
||||
chat.dismissNotice();
|
||||
setPane(roomId);
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="relative flex h-11 w-11 items-center justify-center rounded-full bg-accent-solid text-white shadow-lg transition-opacity hover:opacity-90"
|
||||
aria-label={
|
||||
chat.unread ? t("sagi.people.dockUnread", { n: chat.unread }) : t("sagi.people.dock")
|
||||
}
|
||||
title={t("sagi.people.dock")}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<MessagesSquare size={20} />
|
||||
{chat.unread > 0 && (
|
||||
<span className="absolute -right-1 -top-1 min-w-[1.25rem] rounded-full bg-status-danger px-1 text-center text-[10px] font-semibold leading-5 text-white">
|
||||
{chat.unread > 99 ? "99+" : chat.unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ChatPerson, "username" | "displayName"> | 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<number, ChatPerson>) {
|
||||
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")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect("pet")}
|
||||
onContextMenu={(e) => ctx.open(e, "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 />
|
||||
{petName && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect("pet")}
|
||||
onContextMenu={(e) => ctx.open(e, "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>
|
||||
)}
|
||||
{petName && <span className="h-px w-8 shrink-0 bg-border" aria-hidden />}
|
||||
{chat.rooms.map((r) => {
|
||||
const title = roomTitle(r, chat.me, dir);
|
||||
return (
|
||||
@@ -394,9 +398,11 @@ export function ChatRail({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem onSelect={pick(() => onSelect("pet"))}>
|
||||
{t("sagi.people.menuPet", { name: petName })}
|
||||
</MenuItem>
|
||||
{petName && (
|
||||
<MenuItem onSelect={pick(() => onSelect("pet"))}>
|
||||
{t("sagi.people.menuPet", { name: petName })}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(() => onSelect("new"))}>{t("sagi.people.new")}</MenuItem>
|
||||
<MenuItem
|
||||
disabled={!chat.unread}
|
||||
@@ -515,6 +521,11 @@ export function NewChat({
|
||||
const { t } = useTranslation("settings");
|
||||
const [picked, setPicked] = useState<Set<number>>(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({
|
||||
</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} />
|
||||
<PeoplePicker
|
||||
people={chat.people.filter((p) => !p.disabled)}
|
||||
picked={picked}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
{picked.size > 1 && (
|
||||
<input
|
||||
className="input w-full min-w-0 text-sm"
|
||||
@@ -702,7 +717,7 @@ function GroupSettings({
|
||||
const leave = async () => {
|
||||
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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden px-3 py-3">
|
||||
<button
|
||||
@@ -1001,7 +1016,11 @@ export function ChatRoomView({
|
||||
try {
|
||||
const r = await api.chat.messages(room.id, first.id);
|
||||
stick.current = false;
|
||||
setMessages((prev) => [...r.messages, ...prev]);
|
||||
// Keep the message being read where it is: the page grows above it.
|
||||
const el = listRef.current;
|
||||
const fromBottom = el ? el.scrollHeight - el.scrollTop : 0;
|
||||
flushSync(() => setMessages((prev) => [...r.messages, ...prev]));
|
||||
if (el) el.scrollTop = el.scrollHeight - fromBottom;
|
||||
setHasMore(r.hasMore);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
* @description Gate for the Sagi mascot: follows the enabled preference
|
||||
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
|
||||
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
|
||||
* WebSocket reducer.
|
||||
* WebSocket reducer. While it is off, the chat between users still has a
|
||||
* way in: `ChatDock` (its own chunk, server mode only).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
import { useAuth } from "../AuthGate";
|
||||
|
||||
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
|
||||
const ChatDock = lazy(() => import("./ChatDock").then((m) => ({ default: m.ChatDock })));
|
||||
|
||||
/** The mascot's enabled preference, followed live (Settings, other tabs). */
|
||||
export function useSagiEnabled(): boolean {
|
||||
@@ -20,7 +23,16 @@ export function useSagiEnabled(): boolean {
|
||||
}
|
||||
|
||||
export function SagiMount() {
|
||||
if (!useSagiEnabled()) return null;
|
||||
const { state } = useAuth();
|
||||
if (!useSagiEnabled()) {
|
||||
// The pet hidden: chat with other users from a button of its own.
|
||||
if (state.mode !== "server") return null;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ChatDock />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Sagi />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file ChatDock.test.tsx
|
||||
* @description The chat button shown while the pet is hidden (`ChatDock.tsx`,
|
||||
* mounted by `SagiMount`): server mode only, the unread count on it, and a
|
||||
* click opens the newest chat with the rooms rail beside it (no pet item).
|
||||
* @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";
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
state: { mode: "server", user: { id: 1, username: "me", role: "developer" } } as unknown,
|
||||
}));
|
||||
const api = vi.hoisted(() => ({
|
||||
chat: {
|
||||
rooms: vi.fn(),
|
||||
people: vi.fn(),
|
||||
messages: vi.fn(),
|
||||
read: vi.fn(),
|
||||
fileUrl: () => "",
|
||||
},
|
||||
users: { avatarUrl: () => null },
|
||||
me: { avatarUrl: () => null },
|
||||
}));
|
||||
vi.mock("../../AuthGate", () => ({ useAuth: () => ({ state: auth.state }) }));
|
||||
vi.mock("../../../lib/api", async (orig) => ({ ...(await orig<Record<string, unknown>>()), api }));
|
||||
|
||||
import { ChatDock } from "../ChatDock";
|
||||
|
||||
const room = {
|
||||
id: 7,
|
||||
kind: "dm" as const,
|
||||
name: "",
|
||||
createdBy: 1,
|
||||
createdAt: "2026-11-03T01:00:00Z",
|
||||
lastMessageAt: null,
|
||||
members: [1, 2],
|
||||
lastMessage: null,
|
||||
lastReadId: 0,
|
||||
unread: 3,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
auth.state = { mode: "server", user: { id: 1, username: "me", role: "developer" } };
|
||||
api.chat.rooms.mockResolvedValue({ rooms: [room] });
|
||||
api.chat.people.mockResolvedValue({
|
||||
people: [{ id: 2, username: "an", displayName: "An", avatarAt: null }],
|
||||
});
|
||||
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
|
||||
api.chat.read.mockResolvedValue({ lastReadId: 0 });
|
||||
});
|
||||
|
||||
describe("ChatDock", () => {
|
||||
it("shows the unread count and opens the newest chat", async () => {
|
||||
render(<ChatDock />);
|
||||
const btn = await screen.findByRole("button", { name: "Chats, 3 unread" });
|
||||
fireEvent.click(btn);
|
||||
await waitFor(() => expect(screen.getByRole("dialog", { name: "Chat with An" })).toBeTruthy());
|
||||
expect(screen.queryByText("🐾")).toBeNull();
|
||||
});
|
||||
|
||||
it("is not there in local mode", () => {
|
||||
auth.state = { mode: "local" };
|
||||
render(<ChatDock />);
|
||||
expect(screen.queryByRole("button", { name: /Chats/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,8 @@
|
||||
* scroll only one way; a new message raises a notice that opens its room; and
|
||||
* the right-click menus of a room, a message, a file and a group member —
|
||||
* deleting a chat for yourself and disbanding a group (its creator) included;
|
||||
* and "Seen" under your newest message.
|
||||
* "Seen" under your newest message; a disabled account never offered for a
|
||||
* new chat, a deleted one named as such; and the rail without the pet.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -93,6 +94,7 @@ function state(over: Partial<ChatState> = {}): ChatState {
|
||||
onDeleted: vi.fn(),
|
||||
muted: new Set<number>(),
|
||||
toggleMute: vi.fn(),
|
||||
reloadPeople: vi.fn(async () => {}),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -366,4 +368,22 @@ describe("ChatHub", () => {
|
||||
rerender(view({ ...group, reads: { 1: 2, 2: 2, 3: 2 } }));
|
||||
expect(screen.getByTestId("chat-seen").textContent).toBe("Seen by everyone");
|
||||
});
|
||||
|
||||
it("never offers a disabled account, and names a deleted one", () => {
|
||||
const chat = state({
|
||||
people: [
|
||||
{ id: 2, username: "an", displayName: "An", avatarAt: null },
|
||||
{ id: 3, username: "binh", displayName: null, avatarAt: null, disabled: true },
|
||||
],
|
||||
// A direct chat whose other person's account was deleted.
|
||||
rooms: [{ ...dm, members: [1] }],
|
||||
});
|
||||
render(<NewChat chat={chat} onOpen={() => {}} onClose={() => {}} />);
|
||||
expect(screen.queryByRole("checkbox", { name: /binh/ })).toBeNull();
|
||||
expect(chat.reloadPeople).toHaveBeenCalled();
|
||||
render(<ChatRail chat={chat} active="new" onSelect={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: "Deleted user" })).toBeTruthy();
|
||||
// No pet name: no pet item.
|
||||
expect(screen.queryByText("🐾")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* notice (sender, room) and bumps the room's unread count, plays the sound
|
||||
* only with notifications on, and stays quiet for the room on screen and for
|
||||
* the user's own messages; a `chat_read` frame moves that member's read mark
|
||||
* ("Seen"); nothing runs in local mode.
|
||||
* ("Seen"); a group change is no unread message; with several tabs only the
|
||||
* first to see a message plays its sound; nothing runs in local mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -109,4 +110,28 @@ describe("useChat", () => {
|
||||
);
|
||||
expect(result.current.rooms[0]!.reads?.[2]).toBe(9);
|
||||
});
|
||||
|
||||
it("does not count a group change as unread", async () => {
|
||||
const { result } = renderHook(() => useChat());
|
||||
await waitFor(() => expect(result.current.rooms.length).toBe(1));
|
||||
act(() => frame({ ...msg(5, 2), kind: "system", body: "renamed" }));
|
||||
expect(result.current.unread).toBe(0);
|
||||
expect(result.current.notice).toBeNull();
|
||||
});
|
||||
|
||||
it("plays a message's sound in one tab only", async () => {
|
||||
localStorage.setItem(
|
||||
"agent-monitor-notifications",
|
||||
JSON.stringify({ enabled: true, sound: "pop" })
|
||||
);
|
||||
const a = renderHook(() => useChat());
|
||||
const b = renderHook(() => useChat());
|
||||
await waitFor(() => expect(a.result.current.rooms.length).toBe(1));
|
||||
await waitFor(() => expect(b.result.current.rooms.length).toBe(1));
|
||||
act(() => frame(msg(6, 2)));
|
||||
expect(sound.playSound).toHaveBeenCalledTimes(1);
|
||||
// Both still show the card.
|
||||
expect(a.result.current.notice).not.toBeNull();
|
||||
expect(b.result.current.notice).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
* 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). Off in local mode — one person has no one to chat with.
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -50,12 +52,26 @@ export type ChatState = {
|
||||
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) || "[]");
|
||||
@@ -115,20 +131,27 @@ export function useChat(): ChatState {
|
||||
}
|
||||
}, [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();
|
||||
api.chat
|
||||
.people()
|
||||
.then((r) => setPeople(r.people))
|
||||
.catch(() => undefined);
|
||||
}, [enabled, 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") {
|
||||
@@ -168,7 +191,8 @@ export function useChat(): ChatState {
|
||||
...r,
|
||||
lastMessage: m,
|
||||
lastMessageAt: m.createdAt,
|
||||
unread: own ? 0 : r.unread + 1,
|
||||
// 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,
|
||||
};
|
||||
});
|
||||
@@ -189,6 +213,9 @@ export function useChat(): ChatState {
|
||||
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);
|
||||
@@ -201,7 +228,7 @@ export function useChat(): ChatState {
|
||||
off();
|
||||
offConn();
|
||||
};
|
||||
}, [enabled, me, reload]);
|
||||
}, [enabled, me, reload, reloadPeople]);
|
||||
|
||||
const upsert = useCallback((room: ChatRoom) => {
|
||||
setRooms((prev) => [room, ...prev.filter((r) => r.id !== room.id)]);
|
||||
@@ -271,6 +298,7 @@ export function useChat(): ChatState {
|
||||
notice,
|
||||
dismissNotice,
|
||||
onDeleted,
|
||||
reloadPeople,
|
||||
muted,
|
||||
toggleMute,
|
||||
};
|
||||
|
||||
@@ -290,7 +290,10 @@
|
||||
"disbandBody": "{{name}} and all its messages and files are deleted for every member. This cannot be undone.",
|
||||
"seen": "Seen",
|
||||
"seenAll": "Seen by everyone",
|
||||
"seenBy": "Seen by {{names}}"
|
||||
"seenBy": "Seen by {{names}}",
|
||||
"deletedUser": "Deleted user",
|
||||
"dock": "Chats",
|
||||
"dockUnread": "Chats, {{n}} unread"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
|
||||
@@ -290,7 +290,10 @@
|
||||
"disbandBody": "{{name}} cùng toàn bộ tin nhắn và file sẽ bị xoá với mọi thành viên. Không thể hoàn tác.",
|
||||
"seen": "Đã xem",
|
||||
"seenAll": "Mọi người đã xem",
|
||||
"seenBy": "{{names}} đã xem"
|
||||
"seenBy": "{{names}} đã xem",
|
||||
"deletedUser": "Người dùng đã xoá",
|
||||
"dock": "Trò chuyện",
|
||||
"dockUnread": "Trò chuyện, {{n}} tin chưa đọc"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
|
||||
@@ -553,6 +553,8 @@ export type ChatPerson = {
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarAt: string | null;
|
||||
/** A disabled account: named in old chats, never offered for a new one. */
|
||||
disabled?: boolean;
|
||||
};
|
||||
/** A file of a chat message (`GET /api/chat/rooms/:id/files/:fileId`). */
|
||||
export type ChatFile = { id: string; name: string; type: string; size: number };
|
||||
|
||||
Reference in New Issue
Block a user