forked from Goutam/lynkeduppro-crm
feat(inbox): migrate CRM Inbox to @insignia/iios-messaging-ui SDK
Replace the bespoke in-CRM inbox with the SDK's <Inbox>, 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Conversation[]>;
|
||||
openThread(p: { participantIds: string[]; subject?: string }): Promise<{ threadId: string }>;
|
||||
history(threadId: string): Promise<Message[]>;
|
||||
send(threadId: string, content: string, opts?: SendOpts): Promise<Message>;
|
||||
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe;
|
||||
sendTyping(threadId: string): void;
|
||||
markRead(threadId: string, messageId: string): Promise<void>;
|
||||
react?(messageId: string, emoji: string): Promise<void>;
|
||||
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
|
||||
<MessagingProvider adapter={adapter}>
|
||||
<Messenger onNewChat={openPicker} /> {/* all-in-one: list + thread */}
|
||||
|
||||
{/* ...or compose: */}
|
||||
<ConversationList onSelect={setId} renderRow={custom} />
|
||||
<ThreadView threadId={id} />
|
||||
<Composer threadId={id} />
|
||||
</MessagingProvider>
|
||||
```
|
||||
|
||||
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. `<Messenger>` 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`; `<Messenger>` 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.
|
||||
Generated
+1
-1
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" ? <Rules />
|
||||
: active === "ai" ? <AiAssistant />
|
||||
: active === "messenger" ? <MessengerSdk />
|
||||
: active === "inbox" ? <Inbox />
|
||||
: active === "inbox" ? <InboxSdk />
|
||||
: active === "team" ? <TeamManagement />
|
||||
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
|
||||
</ToastProvider>
|
||||
|
||||
@@ -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 (
|
||||
<div className="view">
|
||||
<PageHead
|
||||
eyebrow="Communication"
|
||||
title="Inbox"
|
||||
subtitle="Mentions, messages, alerts and mail — all in one, via the messaging SDK"
|
||||
icon="bell"
|
||||
/>
|
||||
{!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's mock inbox adapter.
|
||||
</div>
|
||||
)}
|
||||
<div className="miu-host miu-host-inbox">{SHELL ? <LiveInbox /> : <DemoInbox />}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoInbox() {
|
||||
const adapter = useMemo<InboxAdapter>(() => new MockInboxAdapter(), []);
|
||||
return (
|
||||
<InboxProvider adapter={adapter}>
|
||||
<SdkInbox />
|
||||
</InboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveInbox() {
|
||||
const { sdk } = useAppShell();
|
||||
const adapter = useMemo<InboxAdapter>(() => new CrmInboxAdapter(sdk as unknown as DataDoor), [sdk]);
|
||||
return (
|
||||
<InboxProvider adapter={adapter}>
|
||||
<SdkInbox />
|
||||
</InboxProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// The CRM's InboxAdapter — the SDK <Inbox> 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, "<").replace(/>/g, ">");
|
||||
|
||||
export class CrmInboxAdapter implements InboxAdapter {
|
||||
constructor(private readonly sdk: DataDoor) {}
|
||||
|
||||
async listInbox(state?: InboxState): Promise<InboxItem[]> {
|
||||
const showMail = !state || state === "OPEN";
|
||||
const [items, mail] = await Promise.all([
|
||||
this.sdk.query<InboxItemDTO[]>("crm.inbox.list", state ? { state } : {}),
|
||||
showMail ? this.sdk.query<MailThreadDTO[]>("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<void> {
|
||||
await this.sdk.command("crm.inbox.transition", { id, state });
|
||||
}
|
||||
|
||||
async mailHistory(threadId: string): Promise<MailMessage[]> {
|
||||
const rows = await this.sdk.query<MailMessageDTO[]>("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<void> {
|
||||
await this.sdk.command("crm.mail.reply", { threadId, content });
|
||||
}
|
||||
|
||||
async directory(): Promise<MailPerson[]> {
|
||||
const rows = await this.sdk.query<DirectoryDTO[]>("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<void> {
|
||||
await this.sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>` });
|
||||
}
|
||||
|
||||
async composeExternal(target: string, subject: string, text: string): Promise<void> {
|
||||
await this.sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>` });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user