diff --git a/package-lock.json b/package-lock.json
index b99c16f..f0cfac7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"@huggingface/transformers": "^4.2.0",
"@imgly/background-removal": "^1.7.0",
"@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",
"@tensorflow-models/coco-ssd": "^2.2.3",
"@tensorflow/tfjs": "^4.22.0",
@@ -1087,9 +1087,9 @@
}
},
"node_modules/@insignia/iios-messaging-ui": {
- "version": "0.1.6",
- "resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-messaging-ui/-/0.1.6/iios-messaging-ui-0.1.6.tgz",
- "integrity": "sha512-q/PhJDZJWrXIDAMnVuTWJrWdwO+4jU8BNvIFWz7XRJAWc7F5PrQC1FCfMxOJrKSaLUNpZL64C3/pRWTJa+kFPg==",
+ "version": "0.1.7",
+ "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-ZE4fLeSTSa5AFZnvSRT7G/TEaw+Jm5qH8rWaDjns6Vu9zBnDTVYdF8CMEisBrtgSDj+jDQBo9xlwZmr++5qehw==",
"peerDependencies": {
"@insignia/iios-kernel-client": "*",
"react": ">=18",
diff --git a/package.json b/package.json
index 312186a..83f029c 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
"@huggingface/transformers": "^4.2.0",
"@imgly/background-removal": "^1.7.0",
"@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",
"@tensorflow-models/coco-ssd": "^2.2.3",
"@tensorflow/tfjs": "^4.22.0",
diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css
index 3468079..dbc80d4 100644
--- a/src/app/dashboard/dashboard.css
+++ b/src/app/dashboard/dashboard.css
@@ -1272,3 +1272,6 @@
.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); }
@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); }
diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx
index 86f02b2..bd5842e 100644
--- a/src/components/dashboard/dashboard.tsx
+++ b/src/components/dashboard/dashboard.tsx
@@ -18,6 +18,8 @@ import { TeamManagement } from "./team-management";
import { MessengerSdk } from "./messenger-sdk";
import { InboxSdk } from "./inbox-sdk";
import { Settings } from "./settings";
+import { NotificationCenter } from "./notification-center";
+import { RealtimeProvider } from "@/lib/realtime";
import { SmartGallery } from "./smart-gallery";
import "../../app/dashboard/dashboard.css";
@@ -47,11 +49,13 @@ export function Dashboard() {
return (
+
+
{active === "profile" ?
: active === "support" ?
: active === "rules" ?
@@ -65,6 +69,7 @@ export function Dashboard() {
+
);
}
diff --git a/src/components/dashboard/messenger-sdk.tsx b/src/components/dashboard/messenger-sdk.tsx
index df0486c..ee9bd41 100644
--- a/src/components/dashboard/messenger-sdk.tsx
+++ b/src/components/dashboard/messenger-sdk.tsx
@@ -5,36 +5,15 @@
// UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter);
// demo path = the SDK's own MockAdapter.
-import { useEffect, useMemo, useState } from "react";
-import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
-import { MessageSocket } from "@insignia/iios-kernel-client";
+import { useMemo } from "react";
+import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
import { MessagingProvider, Messenger as SdkMessenger, type MessagingAdapter } from "@insignia/iios-messaging-ui";
import { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock";
import "@insignia/iios-messaging-ui/styles.css";
import { isShellConfigured } from "@/lib/appshell";
+import { useRealtime } from "@/lib/realtime";
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("crm.messenger.realtime", {});
- const [socket, setSocket] = useState(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();
export function MessengerSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) {
@@ -74,7 +53,7 @@ function DemoHost({ focusThreadId }: { focusThreadId?: string | null }) {
function LiveHost({ focusThreadId }: { focusThreadId?: string | null }) {
const { sdk } = useAppShell();
const { user } = useAuth();
- const socket = useRealtimeSocket();
+ const socket = useRealtime();
// Rebuilds once the socket connects: the first adapter (no socket) polls; the second runs live.
const adapter = useMemo(
() => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id, socket ?? undefined) : null),
diff --git a/src/components/dashboard/notification-center.tsx b/src/components/dashboard/notification-center.tsx
new file mode 100644
index 0000000..fa88c4b
--- /dev/null
+++ b/src/components/dashboard/notification-center.tsx
@@ -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(
+ () => (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;
+}
diff --git a/src/components/dashboard/ui.tsx b/src/components/dashboard/ui.tsx
index db72ce0..c3b6cb7 100644
--- a/src/components/dashboard/ui.tsx
+++ b/src/components/dashboard/ui.tsx
@@ -265,7 +265,7 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
/* 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) => void };
const ToastContext = createContext(null);
@@ -288,9 +288,12 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{children}
{items.map((t) => (
-
+
-
+
{ t.onClick?.(); setItems((s) => s.filter((x) => x.id !== t.id)); } } : {})}
+ >
{t.title}
{t.desc &&
{t.desc}
}
diff --git a/src/lib/crm-messaging-adapter.ts b/src/lib/crm-messaging-adapter.ts
index c1f7f1e..791b3b6 100644
--- a/src/lib/crm-messaging-adapter.ts
+++ b/src/lib/crm-messaging-adapter.ts
@@ -45,6 +45,8 @@ export class CrmMessagingAdapter implements MessagingAdapter {
private readonly listeners = new Map
void>>();
private readonly polls = new Map();
private readonly joined = new Set();
+ /** 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>>();
@@ -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 {
+ if (!this.socket) return;
+ try {
+ const convs = await this.sdk.query("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 {
const [convs, names] = await Promise.all([
this.sdk.query("crm.messenger.conversation.list", {}),
diff --git a/src/lib/realtime.tsx b/src/lib/realtime.tsx
new file mode 100644
index 0000000..d9f7d4c
--- /dev/null
+++ b/src/lib/realtime.tsx
@@ -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(null);
+interface RealtimeDTO { url: string; audience: string; token?: string }
+const SHELL = isShellConfigured();
+
+function LiveRealtimeProvider({ children }: { children: ReactNode }) {
+ const rt = useQuery("crm.messenger.realtime", {});
+ const [socket, setSocket] = useState(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 {children};
+}
+
+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 {children};
+}
+
+/** The shared socket, or null (demo mode / not yet connected). */
+export function useRealtime(): MessageSocket | null {
+ return useContext(RealtimeContext);
+}