Feat/adopt messaging sdk #31

Merged
maaz519 merged 12 commits from feat/adopt-messaging-sdk into goutamnextflow 2026-07-22 19:06:49 +00:00
7 changed files with 271 additions and 4 deletions
Showing only changes of commit e70904a219 - Show all commits
+2 -2
View File
@@ -1,8 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// The SDK ships ESM/TS; let Next transpile it.
transpilePackages: ["@abe-kap/appshell-sdk"],
// The SDKs ship ESM; let Next transpile them.
transpilePackages: ["@abe-kap/appshell-sdk", "@insignia/iios-messaging-ui"],
// The browser calls the Shell BFF same-origin under /shell (so the HttpOnly
// session cookie flows). We deliberately use /shell (NOT /api) to avoid
// clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
+15
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@abe-kap/appshell-sdk": "^0.2.6",
"@insignia/iios-kernel-client": "^0.1.4",
"@insignia/iios-messaging-ui": "file:../iios/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "16.2.9",
@@ -1026,6 +1027,20 @@
"socket.io-client": "^4.8.1"
}
},
"node_modules/@insignia/iios-messaging-ui": {
"version": "0.1.0",
"resolved": "file:../iios/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz",
"integrity": "sha512-6pSVWACjex1sXWzVqx+42rM6vgWEJ+1ud5y3DLKogJiQmfgFZgCB8vTw0PN/5FECugXC7iGuc9rV9RsHsP+EDw==",
"peerDependencies": {
"@insignia/iios-kernel-client": "*",
"react": ">=18"
},
"peerDependenciesMeta": {
"@insignia/iios-kernel-client": {
"optional": true
}
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@abe-kap/appshell-sdk": "^0.2.6",
"@insignia/iios-kernel-client": "^0.1.4",
"@insignia/iios-messaging-ui": "file:../iios/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "16.2.9",
+14
View File
@@ -133,6 +133,20 @@
.dash-content { padding: 22px 28px 40px; width: 100%; }
.sec-title { font-size: 15px; font-weight: 700; margin: 6px 0 14px; }
/* Host for @insignia/iios-messaging-ui: a fixed-height card that maps the SDK's --miu-* tokens
onto the CRM design system, so the drop-in SDK matches the rest of the app. */
.dash-root .miu-host { height: 620px; border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
.dash-root .miu-host .miu-messenger {
--miu-bg: var(--bg);
--miu-panel: var(--panel);
--miu-panel-2: var(--panel-2);
--miu-border: var(--border);
--miu-text: var(--text);
--miu-muted: var(--muted);
--miu-accent: var(--orange);
--miu-accent-text: #1a1206;
}
/* ---- grid helpers ---- */
.grid { display: grid; gap: 16px; }
+2 -2
View File
@@ -15,7 +15,7 @@ import { Support } from "./support";
import { Rules } from "./rules";
import { AiAssistant } from "./ai-assistant";
import { TeamManagement } from "./team-management";
import { Messenger } from "./messenger";
import { MessengerSdk } from "./messenger-sdk";
import { Inbox } from "./inbox";
import "../../app/dashboard/dashboard.css";
@@ -47,7 +47,7 @@ export function Dashboard() {
: active === "support" ? <Support />
: active === "rules" ? <Rules />
: active === "ai" ? <AiAssistant />
: active === "messenger" ? <Messenger />
: active === "messenger" ? <MessengerSdk />
: active === "inbox" ? <Inbox />
: active === "team" ? <TeamManagement />
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
@@ -0,0 +1,72 @@
"use client";
// The CRM messenger, now rendered by the shared @insignia/iios-messaging-ui SDK instead of a
// bespoke in-CRM implementation. The CRM only supplies an adapter (transport) + theming; all the
// UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter);
// demo path = the SDK's own MockAdapter.
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 { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
import { PageHead } from "./ui";
const SHELL = isShellConfigured();
export function MessengerSdk() {
return (
<div className="view">
<PageHead
eyebrow="Communication"
title="Messenger"
subtitle="Chat with your team and clients — rendered by the messaging SDK"
icon="send"
/>
{!SHELL && (
<div
style={{
margin: "0 0 14px",
padding: "8px 14px",
borderRadius: 10,
background: "var(--panel-2)",
color: "var(--muted)",
fontSize: 13,
border: "1px solid var(--border)",
}}
>
Demo mode running on the SDK&apos;s mock adapter.
</div>
)}
<div className="miu-host">{SHELL ? <LiveHost /> : <DemoHost />}</div>
</div>
);
}
// SHELL is a build-time constant, so exactly one of these mounts for the life of the app
// (Rules-of-Hooks safe — the other branch never renders).
function DemoHost() {
const adapter = useMemo<MessagingAdapter>(() => new MockAdapter(), []);
return (
<MessagingProvider adapter={adapter}>
<SdkMessenger />
</MessagingProvider>
);
}
function LiveHost() {
const { sdk } = useAppShell();
const { user } = useAuth();
const adapter = useMemo<MessagingAdapter | null>(
() => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id) : null),
[sdk, user?.id],
);
if (!adapter) return <div className="miu-empty">Loading</div>;
return (
<MessagingProvider adapter={adapter}>
<SdkMessenger />
</MessagingProvider>
);
}
+165
View File
@@ -0,0 +1,165 @@
// The CRM's implementation of the SDK's MessagingAdapter, over the be-crm data door
// (appshell `crm.messenger.*`). This is how the CRM consumes @insignia/iios-messaging-ui
// instead of embedding its own messenger: the SDK renders, this adapter transports.
//
// Data (list/open/history/send) goes through the BFF, which attaches the session + tenancy
// server-side. Live updates are polled here for a first cut; realtime (the IIOS socket the CRM
// already opens in messenger-socket.tsx) can be layered in by emitting into the same registry.
import type {
Conversation,
Membership,
Message,
MessageEvent,
MessagingAdapter,
SendOpts,
Unsubscribe,
} from "@insignia/iios-messaging-ui";
/** The imperative appshell data door (useAppShell().sdk). Typed structurally so we don't couple to its class. */
export interface DataDoor {
query<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
command<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
}
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
interface ConversationDTO {
threadId: string; subject: string | null; membership: Membership | null;
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
}
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null }
const POLL_MS = 4000;
interface Poll {
cbs: Set<(e: MessageEvent) => void>;
seen: Set<string>;
primed: boolean;
timer: ReturnType<typeof setInterval> | null;
}
export class CrmMessagingAdapter implements MessagingAdapter {
private names: Map<string, string> | null = null;
private readonly polls = new Map<string, Poll>();
constructor(private readonly sdk: DataDoor, private readonly me: string) {}
currentActorId(): string {
return this.me;
}
async listConversations(): Promise<Conversation[]> {
const [convs, names] = await Promise.all([
this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}),
this.directory(),
]);
return convs.map((c) => this.toConversation(c, names));
}
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
const res = await this.sdk.command<{ threadId: string }>("crm.messenger.conversation.open", {
participantIds: p.participantIds,
...(p.membership ? { membership: p.membership } : {}),
...(p.subject ? { subject: p.subject } : {}),
});
return { threadId: res.threadId };
}
async history(threadId: string): Promise<Message[]> {
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
return msgs.map((m) => this.toMessage(m));
}
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
const m = await this.sdk.command<MessageDTO>("crm.messenger.send", {
threadId,
content,
...(opts?.attachment ? { attachment: opts.attachment } : {}),
});
const msg = this.toMessage(m);
// Mark it seen so the poll doesn't re-emit our own message on top of useMessages' optimistic row.
this.polls.get(threadId)?.seen.add(msg.id);
return msg;
}
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
let poll = this.polls.get(threadId);
if (!poll) {
poll = { cbs: new Set(), seen: new Set(), primed: false, timer: null };
this.polls.set(threadId, poll);
const tick = async (): Promise<void> => {
const p = this.polls.get(threadId);
if (!p) return;
try {
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
for (const m of msgs) {
if (p.seen.has(m.interactionId)) continue;
p.seen.add(m.interactionId);
// The first pass just records what's already loaded (useMessages fetched history);
// only genuinely new messages after that are pushed to the UI.
if (p.primed) p.cbs.forEach((f) => f({ kind: "message", message: this.toMessage(m) }));
}
p.primed = true;
} catch {
/* transient BFF error — try again next tick */
}
};
void tick();
poll.timer = setInterval(tick, POLL_MS);
}
poll.cbs.add(cb);
return () => {
const p = this.polls.get(threadId);
if (!p) return;
p.cbs.delete(cb);
if (p.cbs.size === 0) {
if (p.timer) clearInterval(p.timer);
this.polls.delete(threadId);
}
};
}
sendTyping(): void {
// No BFF verb for typing; realtime is a follow-up (the IIOS socket). No-op keeps the contract.
}
async markRead(): Promise<void> {
// No BFF verb for read receipts here; follow-up via the socket. No-op resolves the contract.
}
// ── mapping ────────────────────────────────────────────────────
private async directory(): Promise<Map<string, string>> {
if (!this.names) {
const dir = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 200 });
this.names = new Map(dir.map((d) => [d.id, d.displayName]));
}
return this.names;
}
private toConversation(c: ConversationDTO, names: Map<string, string>): Conversation {
const others = c.participants.filter((p) => p !== this.me);
const title =
c.subject?.trim() ||
others.map((id) => names.get(id) ?? id).join(", ") ||
"Conversation";
return {
threadId: c.threadId,
title,
subject: c.subject,
membership: c.membership,
participants: c.participants,
unread: c.unread,
...(c.lastMessage ? { lastMessage: c.lastMessage } : {}),
...(c.lastAt ? { lastAt: c.lastAt } : {}),
};
}
private toMessage(m: MessageDTO): Message {
return {
id: m.interactionId,
actorId: m.actorId,
text: m.text ?? "",
at: m.occurredAt,
};
}
}