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:
@@ -45,6 +45,8 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
||||
private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
|
||||
private readonly polls = new Map<string, Poll>();
|
||||
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. */
|
||||
private readonly reactions = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
@@ -59,7 +61,10 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
||||
if (socket) {
|
||||
socket.on("message", (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 }));
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** 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[]> {
|
||||
const [convs, names] = await Promise.all([
|
||||
this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user