feat(chat): a new message shows on the pet; chat panels scroll one way only
A message from someone else in a room not on screen raises a card on the pet (sender · group, the text or its files; click opens the room, gone after 10 s), plays the notification sound when notifications are on, and while the tab is hidden shows a local system notification — never through the server's push relay, which would reach other users' devices. Group settings scrolled both ways (the name field and Rename overflowed, the add-people list scrolled inside the panel): one vertical scroll now, the field shrinks, the list grows in place. Every chat list hides horizontal overflow and a long word or link wraps.
This commit is contained in:
@@ -9,7 +9,8 @@
|
||||
* 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
|
||||
* people; anyone may leave. `ChatNotice` is the card a new message raises on
|
||||
* the pet while its window is closed. `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>
|
||||
@@ -112,6 +113,47 @@ const Badge = ({ n }: { n: number }) =>
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
/** A new message from someone, on the pet while its window is closed: opens the room. */
|
||||
export function ChatNotice({
|
||||
notice,
|
||||
onOpen,
|
||||
onDismiss,
|
||||
}: {
|
||||
notice: NonNullable<ChatState["notice"]>;
|
||||
onOpen: (roomId: number) => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex w-72 items-start gap-2 rounded-2xl border border-border-light px-3 py-2.5 shadow-2xl animate-slide-up"
|
||||
role="status"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => onOpen(notice.roomId)}
|
||||
aria-label={t("sagi.people.openFrom", { name: notice.title })}
|
||||
>
|
||||
<span className="block truncate text-xs font-semibold text-fg-primary">
|
||||
💬 {notice.title}
|
||||
</span>
|
||||
<span className="mt-0.5 line-clamp-2 block break-words text-xs text-fg-secondary [overflow-wrap:anywhere]">
|
||||
{notice.text}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-full p-1 text-fg-muted hover:text-fg-primary"
|
||||
onClick={onDismiss}
|
||||
aria-label={t("sagi.people.close")}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The column beside the window: the pet, the rooms, and New chat. */
|
||||
export function ChatRail({
|
||||
chat,
|
||||
@@ -131,7 +173,7 @@ export function ChatRail({
|
||||
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"
|
||||
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
|
||||
@@ -214,10 +256,13 @@ function PeoplePicker({
|
||||
people,
|
||||
picked,
|
||||
onToggle,
|
||||
inline = false,
|
||||
}: {
|
||||
people: ChatPerson[];
|
||||
picked: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
/** Inside a panel that scrolls itself: the list grows, no scroll of its own. */
|
||||
inline?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [q, setQ] = useState("");
|
||||
@@ -225,15 +270,17 @@ function PeoplePicker({
|
||||
`${p.displayName ?? ""} ${p.username}`.toLowerCase().includes(q.trim().toLowerCase())
|
||||
);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className={`flex flex-col gap-2 ${inline ? "" : "min-h-0 flex-1"}`}>
|
||||
<input
|
||||
className="input text-sm"
|
||||
className="input w-full min-w-0 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">
|
||||
<ul
|
||||
className={`space-y-0.5 overflow-x-hidden ${inline ? "" : "min-h-0 flex-1 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">
|
||||
@@ -310,7 +357,7 @@ export function NewChat({
|
||||
<PeoplePicker people={chat.people} picked={picked} onToggle={toggle} />
|
||||
{picked.size > 1 && (
|
||||
<input
|
||||
className="input text-sm"
|
||||
className="input w-full min-w-0 text-sm"
|
||||
placeholder={t("sagi.people.groupName")}
|
||||
aria-label={t("sagi.people.groupName")}
|
||||
maxLength={80}
|
||||
@@ -450,7 +497,7 @@ function GroupSettings({
|
||||
};
|
||||
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">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -467,14 +514,14 @@ function GroupSettings({
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input flex-1 text-sm"
|
||||
className="input min-w-0 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}>
|
||||
<button type="submit" className="btn-secondary shrink-0" disabled={name === room.name}>
|
||||
{t("sagi.people.rename")}
|
||||
</button>
|
||||
</form>
|
||||
@@ -519,11 +566,12 @@ function GroupSettings({
|
||||
</ul>
|
||||
</div>
|
||||
{owner && outside.length > 0 && (
|
||||
<div className="flex min-h-[10rem] flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[11px] uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.people.add")}
|
||||
</p>
|
||||
<PeoplePicker
|
||||
inline
|
||||
people={outside}
|
||||
picked={adding}
|
||||
onToggle={(id) =>
|
||||
@@ -547,7 +595,7 @@ function GroupSettings({
|
||||
)}
|
||||
<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"
|
||||
className="flex shrink-0 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")}
|
||||
@@ -700,7 +748,7 @@ export function ChatRoomView({
|
||||
<>
|
||||
<div
|
||||
ref={listRef}
|
||||
className={`flex-1 space-y-1.5 overflow-y-auto px-3 py-3 ${drag ? "ring-2 ring-inset ring-accent" : ""}`}
|
||||
className={`min-h-0 flex-1 space-y-1.5 overflow-y-auto overflow-x-hidden 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;
|
||||
@@ -748,7 +796,7 @@ export function ChatRoomView({
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm shadow-sm ${
|
||||
className={`max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm shadow-sm [overflow-wrap:anywhere] ${
|
||||
own
|
||||
? "rounded-br-md bg-accent-solid text-white"
|
||||
: "pet-chat-reply rounded-bl-md text-fg-primary"
|
||||
|
||||
@@ -103,7 +103,7 @@ import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { Heart } from "lucide-react";
|
||||
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
|
||||
import { ChatRail, ChatRoomView, NewChat } from "./ChatHub";
|
||||
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "./ChatHub";
|
||||
import { useChat } from "./useChat";
|
||||
|
||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||
@@ -247,6 +247,11 @@ function SagiBody() {
|
||||
const people = useChat();
|
||||
const [pane, setPane] = useState<"pet" | "new" | number>("pet");
|
||||
const room = typeof pane === "number" ? people.rooms.find((r) => r.id === pane) : undefined;
|
||||
// The room on screen: no notice for its messages.
|
||||
const { setActive } = people;
|
||||
useEffect(() => {
|
||||
setActive(open && typeof pane === "number" ? pane : null);
|
||||
}, [open, pane, setActive]);
|
||||
// A room that went away (left, removed): back to the pet.
|
||||
useEffect(() => {
|
||||
if (typeof pane === "number" && people.rooms.length && !room) setPane("pet");
|
||||
@@ -503,7 +508,21 @@ function SagiBody() {
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && brain.bubble && (
|
||||
{!open && !menu && people.notice && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<ChatNotice
|
||||
notice={people.notice}
|
||||
onDismiss={people.dismissNotice}
|
||||
onOpen={(roomId) => {
|
||||
people.dismissNotice();
|
||||
setPane(roomId);
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && !people.notice && brain.bubble && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SpeechBubble
|
||||
bubble={brain.bubble}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* lists the pet then the rooms with their unread counts; New chat opens a
|
||||
* direct chat for one person and makes a group for several; a room reads its
|
||||
* messages (a group names who wrote each), marks them read, appends one that
|
||||
* arrives live, and sends text with the attached files.
|
||||
* arrives live, and sends text with the attached files; the group settings
|
||||
* scroll only one way; a new message raises a notice that opens its room.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -37,7 +38,7 @@ vi.mock("../../AuthGate", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ChatRail, ChatRoomView, NewChat } from "../ChatHub";
|
||||
import { ChatNotice, ChatRail, ChatRoomView, NewChat } from "../ChatHub";
|
||||
|
||||
const msg = (id: number, userId: number, body: string): ChatMessage => ({
|
||||
id,
|
||||
@@ -76,6 +77,9 @@ function state(over: Partial<ChatState> = {}): ChatState {
|
||||
upsert: vi.fn(),
|
||||
onMessage: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
setActive: vi.fn(),
|
||||
notice: null,
|
||||
dismissNotice: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -139,4 +143,32 @@ describe("ChatHub", () => {
|
||||
await waitFor(() => expect(api.chat.post).toHaveBeenCalledWith(7, "xem nè", [file]));
|
||||
expect(await screen.findByText("xem nè")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the group settings to one vertical scroll, the name field shrinking", async () => {
|
||||
api.chat.messages.mockResolvedValue({ messages: [], hasMore: false });
|
||||
render(<ChatRoomView chat={state()} room={group} onClose={() => {}} onLeft={() => {}} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Group settings" }));
|
||||
const name = screen.getAllByLabelText("Group name (optional)")[0]!;
|
||||
expect(name.className).toContain("min-w-0");
|
||||
const panel = name.closest(".overflow-y-auto")!;
|
||||
expect(panel.className).toContain("overflow-x-hidden");
|
||||
// The add-people list grows inside that panel instead of scrolling itself.
|
||||
const lists = panel.querySelectorAll("ul.overflow-y-auto");
|
||||
expect(lists.length).toBe(0);
|
||||
});
|
||||
|
||||
it("shows a new message as a card that opens its room", () => {
|
||||
const onOpen = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<ChatNotice
|
||||
notice={{ key: 1, roomId: 7, title: "An · Team", text: "họp lúc 2h nhé" }}
|
||||
onOpen={onOpen}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("họp lúc 2h nhé")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open the chat with An · Team" }));
|
||||
expect(onOpen).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,7 +261,9 @@ describe("PetMenu", () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" })
|
||||
);
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
});
|
||||
|
||||
@@ -313,7 +315,9 @@ describe("PetSettings", () => {
|
||||
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" })
|
||||
);
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file useChat.test.tsx
|
||||
* @description `useChat`'s live side: a message from someone else raises a
|
||||
* 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; nothing runs in local mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
import type { ChatMessage } from "../../../lib/api";
|
||||
|
||||
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() },
|
||||
}));
|
||||
const sound = vi.hoisted(() => ({ playSound: vi.fn() }));
|
||||
vi.mock("../../AuthGate", () => ({ useAuth: () => ({ state: auth.state }) }));
|
||||
vi.mock("../../../lib/api", async (orig) => ({ ...(await orig<Record<string, unknown>>()), api }));
|
||||
vi.mock("../../../lib/notify", () => sound);
|
||||
|
||||
import { useChat } from "../useChat";
|
||||
|
||||
const room = {
|
||||
id: 7,
|
||||
kind: "group" as const,
|
||||
name: "Team",
|
||||
createdBy: 1,
|
||||
createdAt: "2026-11-03T01:00:00Z",
|
||||
lastMessageAt: null,
|
||||
members: [1, 2],
|
||||
lastMessage: null,
|
||||
lastReadId: 0,
|
||||
unread: 0,
|
||||
};
|
||||
const msg = (id: number, userId: number): ChatMessage => ({
|
||||
id,
|
||||
roomId: 7,
|
||||
userId,
|
||||
kind: "text",
|
||||
body: `tin ${id}`,
|
||||
createdAt: `2026-11-03T02:00:0${id}Z`,
|
||||
files: [],
|
||||
});
|
||||
const frame = (m: ChatMessage) =>
|
||||
eventBus.publish({ type: "chat_message", data: { roomId: 7, message: m }, timestamp: "t" });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
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 }],
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChat", () => {
|
||||
it("raises a notice for someone else's message in a room not on screen", async () => {
|
||||
const { result } = renderHook(() => useChat());
|
||||
await waitFor(() => expect(result.current.people.length).toBe(1));
|
||||
act(() => frame(msg(1, 2)));
|
||||
expect(result.current.notice).toMatchObject({ roomId: 7, title: "An · Team", text: "tin 1" });
|
||||
expect(result.current.unread).toBe(1);
|
||||
expect(sound.playSound).not.toHaveBeenCalled(); // notifications off by default
|
||||
});
|
||||
|
||||
it("plays the sound when notifications are on", async () => {
|
||||
localStorage.setItem(
|
||||
"agent-monitor-notifications",
|
||||
JSON.stringify({ enabled: true, sound: "pop" })
|
||||
);
|
||||
const { result } = renderHook(() => useChat());
|
||||
await waitFor(() => expect(result.current.rooms.length).toBe(1));
|
||||
act(() => frame(msg(2, 2)));
|
||||
expect(sound.playSound).toHaveBeenCalledWith("pop");
|
||||
});
|
||||
|
||||
it("stays quiet for the room on screen and for your own messages", async () => {
|
||||
const { result } = renderHook(() => useChat());
|
||||
await waitFor(() => expect(result.current.rooms.length).toBe(1));
|
||||
act(() => result.current.setActive(7));
|
||||
act(() => frame(msg(3, 2)));
|
||||
act(() => frame(msg(4, 1)));
|
||||
expect(result.current.notice).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing in local mode", () => {
|
||||
auth.state = { mode: "local" };
|
||||
const { result } = renderHook(() => useChat());
|
||||
expect(result.current.enabled).toBe(false);
|
||||
expect(api.chat.rooms).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,12 @@
|
||||
* 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. Off in local mode — one person has no one to chat with.
|
||||
* 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). Off in local mode — one person has no one to chat with.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -14,8 +19,13 @@ 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;
|
||||
@@ -30,8 +40,36 @@ export type ChatState = {
|
||||
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;
|
||||
};
|
||||
|
||||
/** 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;
|
||||
@@ -39,6 +77,11 @@ export function useChat(): ChatState {
|
||||
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 [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;
|
||||
@@ -89,6 +132,18 @@ export function useChat(): ChatState {
|
||||
// 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;
|
||||
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 });
|
||||
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) => {
|
||||
@@ -114,6 +169,31 @@ export function useChat(): ChatState {
|
||||
);
|
||||
}, []);
|
||||
|
||||
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), []);
|
||||
// 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 };
|
||||
return {
|
||||
enabled,
|
||||
me,
|
||||
rooms,
|
||||
people,
|
||||
unread,
|
||||
reload,
|
||||
upsert,
|
||||
onMessage,
|
||||
markRead,
|
||||
setActive,
|
||||
notice,
|
||||
dismissNotice,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,7 +261,8 @@
|
||||
"sysRenamed": "{{who}} renamed the group",
|
||||
"sysLeft": "{{who}} left",
|
||||
"sysAdded": "{{who}} added {{names}}",
|
||||
"sysRemoved": "{{who}} removed {{names}}"
|
||||
"sysRemoved": "{{who}} removed {{names}}",
|
||||
"openFrom": "Open the chat with {{name}}"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
|
||||
@@ -261,7 +261,8 @@
|
||||
"sysRenamed": "{{who}} đã đổi tên nhóm",
|
||||
"sysLeft": "{{who}} đã rời nhóm",
|
||||
"sysAdded": "{{who}} đã thêm {{names}}",
|
||||
"sysRemoved": "{{who}} đã xoá {{names}}"
|
||||
"sysRemoved": "{{who}} đã xoá {{names}}",
|
||||
"openFrom": "Mở chat với {{name}}"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
|
||||
Reference in New Issue
Block a user