# 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.