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
+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 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", {}),