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); +}