From facc50e82f6a07ead831f0acca9dd13c7d6d0875 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 22 Jul 2026 00:54:03 +0530 Subject: [PATCH] feat(inbox): migrate CRM Inbox to @insignia/iios-messaging-ui SDK Replace the bespoke in-CRM inbox with the SDK's , driven by a new CrmInboxAdapter over the be-crm data door (crm.inbox.* + crm.mail.*). Demo mode falls back to the SDK MockInboxAdapter. Maps --miu-* tokens for .miu-inbox to the CRM theme. Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-07-17-messaging-sdk-design.md | 240 ++++++++++++++++++ package-lock.json | 2 +- src/app/dashboard/dashboard.css | 3 +- src/components/dashboard/dashboard.tsx | 4 +- src/components/dashboard/inbox-sdk.tsx | 55 ++++ src/lib/crm-inbox-adapter.ts | 82 ++++++ 6 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-17-messaging-sdk-design.md create mode 100644 src/components/dashboard/inbox-sdk.tsx create mode 100644 src/lib/crm-inbox-adapter.ts diff --git a/docs/superpowers/specs/2026-07-17-messaging-sdk-design.md b/docs/superpowers/specs/2026-07-17-messaging-sdk-design.md new file mode 100644 index 0000000..335b891 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-messaging-sdk-design.md @@ -0,0 +1,240 @@ +# Messaging UI SDK — Design + +**Date:** 2026-07-17 +**Status:** Approved, pending implementation plan + +## Problem + +Messaging UI is rewritten from scratch in every app that needs it. `lynkeduppro-crm` +has a rich, working messenger — conversation list, thread view, bubbles, reactions, +reply threading, typing, read receipts — built as 352 lines in +`src/components/dashboard/messenger.tsx` over 371 lines in `src/lib/messenger-api.ts`. +None of it is reusable. + +### Why not just use `@insignia/iios-message-web`? + +Because it does not solve this problem, and the CRM already rejected it. + +`iios-message-web` is 109 lines across 2 files. It is fully headless: its only JSX is +the context provider element itself. It exports `MessageProvider`, `useThread`, +`useMessages` and a `Message` type — nothing more. It ships no components, no CSS, no +theming. + +It also guessed its API wrong. `useMessages.send` narrows the options bag to +`{ contentRef? }`, while the underlying `MessageSocket.sendMessage` accepts +`parentInteractionId`, `mentions`, and `attachment`. Threading, mentions and +attachments are unreachable through its public API. The socket is held in a +module-private context with no escape hatch. It has zero consumers outside the iios +repo, and its own docs reference a `useSendMessage` hook that does not exist. + +The CRM consequently bypassed it and depends on `@insignia/iios-kernel-client` +directly. + +**The lesson drives this design:** the headless layer is already an SDK +(`iios-kernel-client` — sockets, threads, receipts, typing, published, consumed). A +second headless package saves no app any work. The unsolved part is the UI. + +### Why tower is not a consumer + +Tower's messaging is a WhatsApp group ingest → moderate → forward pipeline, not chat. +There is no `Conversation` model; `Message` is a captured group post keyed by +`senderJid` + `sourceGroupId` with a moderation `status` enum +(`RAW/PENDING/APPROVED/...`) — no recipient, no delivery state. "Send" is a BullMQ job +rate-limited to 20 forwards/minute to avoid WhatsApp bans. There is no +socket.io/websocket/SSE in the browser anywhere in the repo. Its `threads` and +`drafts` mean different things than a chat SDK's would. + +Tower would pay the abstraction cost for realtime machinery it never turns on. It is +explicitly out of scope. + +## Constraint: one real consumer + +`lynkeduppro-crm` is the only consumer. Genericity is not achievable by intent — it is +forced by a second consumer. This design therefore ports only what is already proven +in production and refuses to invent abstraction for imagined needs. `iios-message-web` +is the cautionary example of the opposite approach. + +## Architecture + +One package, `@insignia/messaging-ui`, published to the existing Gitea registry +(`https://git.lynkedup.cloud/api/packages/insignia/npm/`). React as a peer dependency. + +``` +@insignia/messaging-ui + . → components + provider + hooks + ./styles.css → structural CSS + token defaults + ./adapters/kernel → optional iios-kernel-client adapter + ./adapters/mock → in-memory adapter for demos/tests +``` + +**The core has zero transport knowledge.** `iios-kernel-client` is reachable only via +the optional `./adapters/kernel` subpath, so an app on a different backend never pulls +socket code. This is the specific mistake `iios-message-web` made by welding itself to +`MessageSocket`. + +This boundary is load-bearing for the actual consumer: the CRM does **not** talk to +iios directly. It routes messaging through be-crm's data door (`crm.messenger.*`) via +`@abe-kap/appshell-sdk`, socket-primary with a 4s REST poll fallback. An SDK that +hardcoded `iios-kernel-client` could not be adopted by the only app that wants it. + +## The adapter contract + +Lifted from the existing `MessengerData`/`ThreadData` interfaces in +`src/lib/messenger-api.ts`, which already survived two implementations (live + mock). +Two implementations is the minimum real evidence that a seam is genuine rather than +imagined. This contract was not designed for an SDK — it earned its shape. + +```ts +interface MessagingAdapter { + listConversations(): Promise; + openThread(p: { participantIds: string[]; subject?: string }): Promise<{ threadId: string }>; + history(threadId: string): Promise; + send(threadId: string, content: string, opts?: SendOpts): Promise; + subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe; + sendTyping(threadId: string): void; + markRead(threadId: string, messageId: string): Promise; + react?(messageId: string, emoji: string): Promise; + upload?(file: File): Promise<{ url: string; mime: string; name: string }>; + currentActorId(): string | null; +} + +interface SendOpts { + parentInteractionId?: string; + attachment?: { url: string; mime: string; name: string }; +} +``` + +### Graceful degradation + +`react` and `upload` are optional. When an adapter omits them the UI hides the +reaction picker or the attach button respectively. This is how one component set +serves both a full CRM messenger and a stripped-down widget without a `mode` prop. + +### `currentActorId` fixes a live bug + +Today the CRM infers the current actor id by scanning for a message you sent: + +```ts +// src/lib/messenger-socket.tsx — current behaviour +const mine = socketMsgs.find((m) => m.mine && m.actorId); +if (mine?.actorId && mine.actorId !== myActorId) setMyActorId(mine.actorId); +``` + +Until you have sent a message in a thread, `myActorId` is `null`. Because the REST +poll fallback computes `mine: !!myActorId && m.actorId === myActorId`, **every message +renders as not-yours** in that state. The root cause is that the kernel's receipt +event carries no `threadId`, making it a global stream the CRM compensates for. + +Making identity an explicit adapter responsibility eliminates this class of bug rather +than porting it. The two-tier socket/poll fallback stays in the adapter, not the SDK — +the CRM's adapter keeps its 4s poll; a socket-only app implements `subscribe` and +never polls. + +## Components + +Composable primitives plus one all-in-one for drop-in use: + +```tsx + + {/* all-in-one: list + thread */} + + {/* ...or compose: */} + + + + +``` + +Hooks remain exported (`useConversations`, `useThread`, `useMessages`) so a host +wanting entirely custom UI can use the SDK headlessly. This makes `iios-message-web`'s +use case a strict subset of this package rather than a competitor. + +### Explicitly out of scope + +- **Inbox.** Coupled to iios semantics, not chat transport. Items are projected + server-side by iios from domain events (`MENTION`, `NEEDS_REPLY`, `SUPPORT_UPDATE`, + `CRM_OWNER_INTEREST`); authz is OPA policy. A chat SDK cannot own this. +- **People picker / directory.** Fed by `crm.messenger.directory`. "Who exists and who + may I message" is host and tenant territory. `` takes an `onNewChat` + callback; the host renders its own picker. +- **Presence.** No consumer needs it. + +## Theming + +Structural CSS with token defaults, overridden by the host. No Tailwind, no CSS-in-JS, +no build coupling — the CRM has no shadcn and near-zero Tailwind (its real styling is +1142 lines of hand-rolled `dashboard.css` plus inline style objects), so a +Tailwind-based SDK would force a restyle of the only consumer. + +```css +:root { + --msg-font; --msg-radius; --msg-gap; + --msg-bubble-own-bg; --msg-bubble-other-bg; + --msg-accent; --msg-muted; --msg-surface; --msg-border; +} +``` + +Every component accepts `className`; `` accepts a `classNames` slot map for +per-part overrides. The CRM's existing `#6366f1 → #8b5cf6` group-avatar gradient +becomes a token value rather than a hardcode. + +## Attachments + +The SDK renders attachments (image thumbnail, file chip, download) and calls +`adapter.upload(file)`, passing the result into `send`. **Storage, auth, and +size/mime limits are host concerns** — baking in an upload target would break the next +app. The attach button is hidden when `upload` is absent. + +`MessageSocket.sendMessage` already accepts an `attachment` field, so this exercises +an existing wire contract rather than inventing one. No consumer has exercised it yet; +the CRM has no file upload anywhere today. + +## Data flow + +1. Host constructs an adapter (CRM: wrapping appshell data door + socket). +2. `MessagingProvider` holds the adapter in context. +3. `useConversations` calls `listConversations`; `useMessages(threadId)` calls + `history` then `subscribe`. +4. `Composer` calls `send` with optimistic append; on rejection the optimistic message + is rolled back and the input text restored (matching current CRM behaviour). +5. `subscribe` events reconcile against optimistic state by message id. + +## Error handling + +- Adapter method rejection surfaces via hook `error` state; components render an + inline error affordance, never throw. +- Optimistic send failure restores composer text — the CRM's current behaviour, kept. +- `subscribe` disconnect is the adapter's problem, not the SDK's. The SDK renders a + `connected: boolean` from the adapter as a banner (the CRM's existing "Demo mode" + banner generalises to this). + +## Validation + +The migration is the validation. There is no second app, so the honest bar is: + +1. Rewrite the CRM's `messenger.tsx` to consume the SDK; its data-door implementation + becomes `CrmMessagingAdapter`. **Success = identical behaviour with the 352-line + component deleted**, and the mock adapter preserving demo-mode fallback. +2. Then `support.tsx`'s `MessageCenter` — currently pure `setTimeout` theatre with no + backend — becomes a zero-risk second surface. + +Two surfaces in one app is not a true second consumer. It is the best honest test +available of the adapter boundary, and it should be understood as such. **The design +should be revisited when a genuine second app appears** rather than treated as settled. + +## Testing + +- **Component tests against the mock adapter** — no network. This is the payoff of the + injected seam. +- **`CrmMessagingAdapter` tested against the contract** independently of UI. +- **A shared adapter conformance suite** any adapter can run, so the kernel and CRM + adapters are verified against one definition of correct. +- Explicit regression test for the `currentActorId` bug: messages render as own before + the user has sent anything in the thread. + +## Open questions + +- Package name: `@insignia/messaging-ui` assumed, not confirmed. +- Whether `CrmMessagingAdapter` lives in the CRM repo or ships as + `./adapters/crm`. Preference: the CRM repo — it depends on appshell-sdk, which the + SDK must not. diff --git a/package-lock.json b/package-lock.json index d660907..eb0d18b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1030,7 +1030,7 @@ "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-B6Vzb75lBv4W3ZuKE4muMSz/OyUrE0NxImUI6sHaCBby5xveHVyanPiwkxiaqg9zZPYQMYiZRMONYwlNqyJvig==", + "integrity": "sha512-RoXEI/SgzYM2Zj+XvwNkc3rrDQ5kDa/oqsCo2wkZxwRs3bVHJuRoQR+c713wgaWK+MmqIg0cetXQ//rfJvpBbg==", "peerDependencies": { "@insignia/iios-kernel-client": "*", "react": ">=18" diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index 51ba53f..cf5aa7c 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -137,7 +137,8 @@ /* 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 { + .dash-root .miu-host .miu-messenger, + .dash-root .miu-host .miu-inbox { --miu-bg: var(--bg); --miu-panel: var(--panel); --miu-panel-2: var(--panel-2); diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index 0fb2892..9fa6bc6 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -16,7 +16,7 @@ import { Rules } from "./rules"; import { AiAssistant } from "./ai-assistant"; import { TeamManagement } from "./team-management"; import { MessengerSdk } from "./messenger-sdk"; -import { Inbox } from "./inbox"; +import { InboxSdk } from "./inbox-sdk"; import "../../app/dashboard/dashboard.css"; export function Dashboard() { @@ -48,7 +48,7 @@ export function Dashboard() { : active === "rules" ? : active === "ai" ? : active === "messenger" ? - : active === "inbox" ? + : active === "inbox" ? : active === "team" ? : } diff --git a/src/components/dashboard/inbox-sdk.tsx b/src/components/dashboard/inbox-sdk.tsx new file mode 100644 index 0000000..43fb8df --- /dev/null +++ b/src/components/dashboard/inbox-sdk.tsx @@ -0,0 +1,55 @@ +"use client"; + +// The CRM Inbox, rendered by @insignia/iios-messaging-ui instead of the bespoke in-CRM inbox. +// Live = the be-crm data door (CrmInboxAdapter over crm.inbox.* + crm.mail.*); demo = the SDK's +// MockInboxAdapter. + +import { useMemo } from "react"; +import { useAppShell } from "@abe-kap/appshell-sdk/react"; +import { InboxProvider, Inbox as SdkInbox, type InboxAdapter } from "@insignia/iios-messaging-ui"; +import { MockInboxAdapter } from "@insignia/iios-messaging-ui/adapters/mock-inbox"; +import "@insignia/iios-messaging-ui/styles.css"; +import { isShellConfigured } from "@/lib/appshell"; +import { CrmInboxAdapter } from "@/lib/crm-inbox-adapter"; +import type { DataDoor } from "@/lib/crm-messaging-adapter"; +import { PageHead } from "./ui"; + +const SHELL = isShellConfigured(); + +export function InboxSdk() { + return ( +
+ + {!SHELL && ( +
+ Demo mode — running on the SDK's mock inbox adapter. +
+ )} +
{SHELL ? : }
+
+ ); +} + +function DemoInbox() { + const adapter = useMemo(() => new MockInboxAdapter(), []); + return ( + + + + ); +} + +function LiveInbox() { + const { sdk } = useAppShell(); + const adapter = useMemo(() => new CrmInboxAdapter(sdk as unknown as DataDoor), [sdk]); + return ( + + + + ); +} diff --git a/src/lib/crm-inbox-adapter.ts b/src/lib/crm-inbox-adapter.ts new file mode 100644 index 0000000..2bc3041 --- /dev/null +++ b/src/lib/crm-inbox-adapter.ts @@ -0,0 +1,82 @@ +// The CRM's InboxAdapter — the SDK rendered over the be-crm data door +// (crm.inbox.* + crm.mail.*). Folds mail threads into the unified inbox exactly as the old +// inbox-api did; the CRM keeps auth/tenancy server-side. + +import type { + InboxAdapter, + InboxItem, + InboxState, + MailMessage, + MailPerson, +} from "@insignia/iios-messaging-ui"; +import type { DataDoor } from "./crm-messaging-adapter"; + +interface InboxItemDTO { + id: string; kind: string; state: InboxState; title: string; summary?: string; priority: string; threadId?: string; createdAt: string; +} +interface MailThreadDTO { threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string } +interface MailMessageDTO { + interactionId: string; actorId: string | null; kind: string; occurredAt: string; + html: string | null; text: string | null; + attachment: { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null } | null; +} +interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" } + +const escapeHtml = (s: string): string => s.replace(/&/g, "&").replace(//g, ">"); + +export class CrmInboxAdapter implements InboxAdapter { + constructor(private readonly sdk: DataDoor) {} + + async listInbox(state?: InboxState): Promise { + const showMail = !state || state === "OPEN"; + const [items, mail] = await Promise.all([ + this.sdk.query("crm.inbox.list", state ? { state } : {}), + showMail ? this.sdk.query("crm.mail.list", {}) : Promise.resolve([] as MailThreadDTO[]), + ]); + const mailItems: InboxItem[] = mail.map((t) => ({ + id: `mail:${t.threadId}`, + kind: "MAIL", + state: "OPEN", + title: t.subject || "(no subject)", + ...(t.lastMessage ? { summary: t.lastMessage } : {}), + priority: t.unread > 0 ? "HIGH" : "LOW", + threadId: t.threadId, + createdAt: t.lastAt ?? "", + })); + return [...mailItems, ...items].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? "")); + } + + async transition(id: string, state: InboxState): Promise { + await this.sdk.command("crm.inbox.transition", { id, state }); + } + + async mailHistory(threadId: string): Promise { + const rows = await this.sdk.query("crm.mail.history", { threadId }); + return rows.map((m) => ({ + id: m.interactionId, + actorId: m.actorId, + kind: m.kind, + at: m.occurredAt, + html: m.html, + text: m.text, + attachment: m.attachment, + })); + } + + async mailReply(threadId: string, content: string): Promise { + await this.sdk.command("crm.mail.reply", { threadId, content }); + } + + async directory(): Promise { + const rows = await this.sdk.query("crm.messenger.directory", { kind: "all", limit: 200 }); + return rows.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind })); + } + + async composeInternal(recipientUserId: string, subject: string, text: string): Promise { + await this.sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `

${escapeHtml(text)}

` }); + } + + async composeExternal(target: string, subject: string, text: string): Promise { + await this.sdk.command("crm.mail.send", { target, subject, text, html: `

${escapeHtml(text)}

` }); + } +}