feat(dashboard): live notifications phase 1 — presence, live unread, in-app toasts

Shared RealtimeProvider opens ONE dashboard-wide IIOS message socket reused by
the messenger tab and the new NotificationCenter. CrmMessagingAdapter gains
setFocus (drives presence/push suppression) and subscribeActivity (joins all
of the caller's threads, fans out incoming messages). NotificationCenter shows
a clickable toast on new messages when you're not on the messenger tab and
deep-links to the conversation. Pulls @insignia/iios-messaging-ui@0.1.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:26:17 +05:30
parent 904d003d32
commit be50d53c50
9 changed files with 152 additions and 34 deletions
+4 -4
View File
@@ -12,7 +12,7 @@
"@huggingface/transformers": "^4.2.0", "@huggingface/transformers": "^4.2.0",
"@imgly/background-removal": "^1.7.0", "@imgly/background-removal": "^1.7.0",
"@insignia/iios-kernel-client": "^0.1.4", "@insignia/iios-kernel-client": "^0.1.4",
"@insignia/iios-messaging-ui": "^0.1.6", "@insignia/iios-messaging-ui": "^0.1.7",
"@photo-gallery/sdk": "file:./vendor/photo-gallery-sdk", "@photo-gallery/sdk": "file:./vendor/photo-gallery-sdk",
"@tensorflow-models/coco-ssd": "^2.2.3", "@tensorflow-models/coco-ssd": "^2.2.3",
"@tensorflow/tfjs": "^4.22.0", "@tensorflow/tfjs": "^4.22.0",
@@ -1087,9 +1087,9 @@
} }
}, },
"node_modules/@insignia/iios-messaging-ui": { "node_modules/@insignia/iios-messaging-ui": {
"version": "0.1.6", "version": "0.1.7",
"resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-messaging-ui/-/0.1.6/iios-messaging-ui-0.1.6.tgz", "resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-messaging-ui/-/0.1.7/iios-messaging-ui-0.1.7.tgz",
"integrity": "sha512-q/PhJDZJWrXIDAMnVuTWJrWdwO+4jU8BNvIFWz7XRJAWc7F5PrQC1FCfMxOJrKSaLUNpZL64C3/pRWTJa+kFPg==", "integrity": "sha512-ZE4fLeSTSa5AFZnvSRT7G/TEaw+Jm5qH8rWaDjns6Vu9zBnDTVYdF8CMEisBrtgSDj+jDQBo9xlwZmr++5qehw==",
"peerDependencies": { "peerDependencies": {
"@insignia/iios-kernel-client": "*", "@insignia/iios-kernel-client": "*",
"react": ">=18", "react": ">=18",
+1 -1
View File
@@ -14,7 +14,7 @@
"@huggingface/transformers": "^4.2.0", "@huggingface/transformers": "^4.2.0",
"@imgly/background-removal": "^1.7.0", "@imgly/background-removal": "^1.7.0",
"@insignia/iios-kernel-client": "^0.1.4", "@insignia/iios-kernel-client": "^0.1.4",
"@insignia/iios-messaging-ui": "^0.1.6", "@insignia/iios-messaging-ui": "^0.1.7",
"@photo-gallery/sdk": "file:./vendor/photo-gallery-sdk", "@photo-gallery/sdk": "file:./vendor/photo-gallery-sdk",
"@tensorflow-models/coco-ssd": "^2.2.3", "@tensorflow-models/coco-ssd": "^2.2.3",
"@tensorflow/tfjs": "^4.22.0", "@tensorflow/tfjs": "^4.22.0",
+3
View File
@@ -1272,3 +1272,6 @@
.dash-root .gs-snippet em { color: var(--orange); font-style: normal; font-weight: 600; } .dash-root .gs-snippet em { color: var(--orange); font-style: normal; font-weight: 600; }
.dash-root .gs-surface { flex: 0 0 auto; font-size: 10.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); } .dash-root .gs-surface { flex: 0 0 auto; font-size: 10.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
@media (max-width: 720px) { .dash-root .gs-field { width: 160px; } } @media (max-width: 720px) { .dash-root .gs-field { width: 160px; } }
.dash-root .ds-toast.is-clickable .ds-toast-body { cursor: pointer; }
.dash-root .ds-toast.is-clickable .ds-toast-body:hover .ds-toast-title { color: var(--orange); }
+5
View File
@@ -18,6 +18,8 @@ import { TeamManagement } from "./team-management";
import { MessengerSdk } from "./messenger-sdk"; import { MessengerSdk } from "./messenger-sdk";
import { InboxSdk } from "./inbox-sdk"; import { InboxSdk } from "./inbox-sdk";
import { Settings } from "./settings"; import { Settings } from "./settings";
import { NotificationCenter } from "./notification-center";
import { RealtimeProvider } from "@/lib/realtime";
import { SmartGallery } from "./smart-gallery"; import { SmartGallery } from "./smart-gallery";
import "../../app/dashboard/dashboard.css"; import "../../app/dashboard/dashboard.css";
@@ -47,11 +49,13 @@ export function Dashboard() {
return ( return (
<div className="dash-root" data-theme={theme}> <div className="dash-root" data-theme={theme}>
<RealtimeProvider>
<Sidebar active={active} onSelect={setActive} /> <Sidebar active={active} onSelect={setActive} />
<div className="dash-main"> <div className="dash-main">
<Topbar theme={theme} onToggle={toggle} title={title} subtitle={subtitle} onNavigate={navigateToConversation} /> <Topbar theme={theme} onToggle={toggle} title={title} subtitle={subtitle} onNavigate={navigateToConversation} />
<div className="dash-content"> <div className="dash-content">
<ToastProvider> <ToastProvider>
<NotificationCenter active={active} onNavigate={navigateToConversation} />
{active === "profile" ? <Profile /> {active === "profile" ? <Profile />
: active === "support" ? <Support /> : active === "support" ? <Support />
: active === "rules" ? <Rules /> : active === "rules" ? <Rules />
@@ -65,6 +69,7 @@ export function Dashboard() {
</ToastProvider> </ToastProvider>
</div> </div>
</div> </div>
</RealtimeProvider>
</div> </div>
); );
} }
+4 -25
View File
@@ -5,36 +5,15 @@
// UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter); // UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter);
// demo path = the SDK's own MockAdapter. // demo path = the SDK's own MockAdapter.
import { useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react"; import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
import { MessageSocket } from "@insignia/iios-kernel-client";
import { MessagingProvider, Messenger as SdkMessenger, type MessagingAdapter } from "@insignia/iios-messaging-ui"; import { MessagingProvider, Messenger as SdkMessenger, type MessagingAdapter } from "@insignia/iios-messaging-ui";
import { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock"; import { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock";
import "@insignia/iios-messaging-ui/styles.css"; import "@insignia/iios-messaging-ui/styles.css";
import { isShellConfigured } from "@/lib/appshell"; import { isShellConfigured } from "@/lib/appshell";
import { useRealtime } from "@/lib/realtime";
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter"; import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
interface RealtimeDTO { url: string; audience: string; token?: string }
/** Open the IIOS message socket with the delegated token the BFF mints (crm.messenger.realtime). */
function useRealtimeSocket(): MessageSocket | null {
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
const [socket, setSocket] = useState<MessageSocket | null>(null);
const url = rt.data?.url;
const token = rt.data?.token;
useEffect(() => {
if (!url || !token) return;
const s = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
s.connect();
setSocket(s);
return () => {
s.disconnect();
setSocket(null);
};
}, [url, token]);
return socket;
}
const SHELL = isShellConfigured(); const SHELL = isShellConfigured();
export function MessengerSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) { export function MessengerSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) {
@@ -74,7 +53,7 @@ function DemoHost({ focusThreadId }: { focusThreadId?: string | null }) {
function LiveHost({ focusThreadId }: { focusThreadId?: string | null }) { function LiveHost({ focusThreadId }: { focusThreadId?: string | null }) {
const { sdk } = useAppShell(); const { sdk } = useAppShell();
const { user } = useAuth(); const { user } = useAuth();
const socket = useRealtimeSocket(); const socket = useRealtime();
// Rebuilds once the socket connects: the first adapter (no socket) polls; the second runs live. // Rebuilds once the socket connects: the first adapter (no socket) polls; the second runs live.
const adapter = useMemo<MessagingAdapter | null>( const adapter = useMemo<MessagingAdapter | null>(
() => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id, socket ?? undefined) : null), () => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id, socket ?? undefined) : null),
@@ -0,0 +1,49 @@
"use client";
// App-wide in-app notifications. Uses the shared dashboard socket to watch activity across ALL of
// the user's threads (via the adapter's subscribeActivity) and shows a clickable toast when a new
// message arrives — unless you're already on the Messenger tab (you'd see it live there). Clicking
// deep-links to the conversation. Renders nothing.
import { useEffect, useMemo, useRef } from "react";
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
import type { MessagingAdapter } from "@insignia/iios-messaging-ui";
import { isShellConfigured } from "@/lib/appshell";
import { useRealtime } from "@/lib/realtime";
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
import { useToast } from "./ui";
const SHELL = isShellConfigured();
export function NotificationCenter({ active, onNavigate }: { active: string; onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
const socket = useRealtime();
const { sdk } = useAppShell();
const { user } = useAuth();
const toast = useToast();
const me = user?.id;
// Keep the current tab readable inside the (stable) subscription callback.
const activeRef = useRef(active);
activeRef.current = active;
const adapter = useMemo<MessagingAdapter | null>(
() => (me && socket ? new CrmMessagingAdapter(sdk as unknown as DataDoor, me, socket) : null),
[sdk, me, socket],
);
useEffect(() => {
if (!SHELL || !adapter?.subscribeActivity) return;
return adapter.subscribeActivity(({ threadId, message }) => {
if (message.actorId === me) return; // never notify me about my own message
if (activeRef.current === "messenger") return; // already watching chat live
toast.push({
tone: "info",
title: "New message",
desc: message.text?.slice(0, 90) || "You have a new message",
onClick: () => onNavigate("messenger", threadId),
});
});
}, [adapter, me, toast, onNavigate]);
return null;
}
+6 -3
View File
@@ -265,7 +265,7 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
/* Toast */ /* Toast */
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
type Toast = { id: number; tone: "success" | "info" | "error"; title: string; desc?: string }; type Toast = { id: number; tone: "success" | "info" | "error"; title: string; desc?: string; onClick?: () => void };
type ToastCtx = { push: (t: Omit<Toast, "id">) => void }; type ToastCtx = { push: (t: Omit<Toast, "id">) => void };
const ToastContext = createContext<ToastCtx | null>(null); const ToastContext = createContext<ToastCtx | null>(null);
@@ -288,9 +288,12 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{children} {children}
<div className="ds-toasts"> <div className="ds-toasts">
{items.map((t) => ( {items.map((t) => (
<div key={t.id} className={`ds-toast tone-${t.tone}`}> <div key={t.id} className={`ds-toast tone-${t.tone}${t.onClick ? " is-clickable" : ""}`}>
<Icon name={t.tone === "success" ? "check-circle" : t.tone === "error" ? "alert" : "info"} size={18} /> <Icon name={t.tone === "success" ? "check-circle" : t.tone === "error" ? "alert" : "info"} size={18} />
<div className="ds-toast-body"> <div
className="ds-toast-body"
{...(t.onClick ? { role: "button", tabIndex: 0, onClick: () => { t.onClick?.(); setItems((s) => s.filter((x) => x.id !== t.id)); } } : {})}
>
<div className="ds-toast-title">{t.title}</div> <div className="ds-toast-title">{t.title}</div>
{t.desc && <div className="ds-toast-desc">{t.desc}</div>} {t.desc && <div className="ds-toast-desc">{t.desc}</div>}
</div> </div>
+37 -1
View File
@@ -45,6 +45,8 @@ export class CrmMessagingAdapter implements MessagingAdapter {
private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>(); private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
private readonly polls = new Map<string, Poll>(); private readonly polls = new Map<string, Poll>();
private readonly joined = new Set<string>(); private readonly joined = new Set<string>();
/** Cross-thread activity listeners (live unread + in-app notifications). */
private readonly activity = new Set<(e: { threadId: string; message: Message }) => void>();
/** messageId → emoji → userSet, so a single annotation delta can be re-emitted as a full set. */ /** messageId → emoji → userSet, so a single annotation delta can be re-emitted as a full set. */
private readonly reactions = new Map<string, Map<string, Set<string>>>(); private readonly reactions = new Map<string, Map<string, Set<string>>>();
@@ -59,7 +61,10 @@ export class CrmMessagingAdapter implements MessagingAdapter {
if (socket) { if (socket) {
socket.on("message", (m) => { socket.on("message", (m) => {
this.ingestReactions(m); this.ingestReactions(m);
void this.toKernelMessage(m).then((message) => this.emit(m.threadId, { kind: "message", message })); void this.toKernelMessage(m).then((message) => {
this.emit(m.threadId, { kind: "message", message });
for (const cb of this.activity) cb({ threadId: m.threadId, message });
});
}); });
socket.on("typing", (e) => this.emit(e.threadId, { kind: "typing", userId: e.userId })); socket.on("typing", (e) => this.emit(e.threadId, { kind: "typing", userId: e.userId }));
// Receipts carry no threadId → fan to all open threads; the UI filters by messageId. // Receipts carry no threadId → fan to all open threads; the UI filters by messageId.
@@ -79,6 +84,37 @@ export class CrmMessagingAdapter implements MessagingAdapter {
return this.me; return this.me;
} }
/** Report the foregrounded thread to IIOS presence (suppresses push for what you're viewing). */
setFocus(threadId: string | null): void {
this.socket?.focus(threadId);
}
/** Fire `cb` for every incoming message across ALL the caller's threads (live unread + toasts). */
subscribeActivity(cb: (e: { threadId: string; message: Message }) => void): Unsubscribe {
this.activity.add(cb);
void this.joinAllThreads();
return () => {
this.activity.delete(cb);
};
}
/** Join every thread the user belongs to so their messages arrive over the socket, not just the
* open one. Idempotent (the `joined` set guards re-joins). */
private async joinAllThreads(): Promise<void> {
if (!this.socket) return;
try {
const convs = await this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {});
for (const c of convs) {
if (!this.joined.has(c.threadId)) {
this.joined.add(c.threadId);
void this.socket.openThread(c.threadId).catch(() => this.joined.delete(c.threadId));
}
}
} catch {
/* best-effort — activity just won't cover un-joined threads */
}
}
async listConversations(): Promise<Conversation[]> { async listConversations(): Promise<Conversation[]> {
const [convs, names] = await Promise.all([ const [convs, names] = await Promise.all([
this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}), this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}),
+43
View File
@@ -0,0 +1,43 @@
"use client";
// One shared IIOS message socket for the whole dashboard, so the messenger tab AND the app-wide
// notification center use a single connection (not one each). Opened at the dashboard level and
// kept alive across tab switches; the messenger tab reuses it via useRealtime().
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { useQuery } from "@abe-kap/appshell-sdk/react";
import { MessageSocket } from "@insignia/iios-kernel-client";
import { isShellConfigured } from "./appshell";
const RealtimeContext = createContext<MessageSocket | null>(null);
interface RealtimeDTO { url: string; audience: string; token?: string }
const SHELL = isShellConfigured();
function LiveRealtimeProvider({ children }: { children: ReactNode }) {
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
const [socket, setSocket] = useState<MessageSocket | null>(null);
const url = rt.data?.url;
const token = rt.data?.token;
useEffect(() => {
if (!url || !token) return;
const s = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
s.connect();
setSocket(s);
return () => {
s.disconnect();
setSocket(null);
};
}, [url, token]);
return <RealtimeContext.Provider value={socket}>{children}</RealtimeContext.Provider>;
}
export function RealtimeProvider({ children }: { children: ReactNode }) {
// SHELL is a build-time constant, so the same branch runs every render (Rules-of-Hooks safe).
if (!SHELL) return <>{children}</>;
return <LiveRealtimeProvider>{children}</LiveRealtimeProvider>;
}
/** The shared socket, or null (demo mode / not yet connected). */
export function useRealtime(): MessageSocket | null {
return useContext(RealtimeContext);
}