From 036ed842551ef9486f4fbdf61bc8096ba800bbce Mon Sep 17 00:00:00 2001 From: maaz519 Date: Mon, 20 Jul 2026 14:29:21 +0530 Subject: [PATCH 01/12] feat(media): infer MIME from extension for empty-type uploads (.md, .html) Browsers report an empty file.type for files with no OS-registered MIME (esp. .md), which then went up as application/octet-stream and got rejected. Infer the type from the extension for the text types IIOS allows (md/markdown/html/ htm/txt/csv) so those attachments upload. Co-Authored-By: Claude Opus 4.8 --- src/lib/media-api.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/lib/media-api.ts b/src/lib/media-api.ts index 3a3f73d..20a62bc 100644 --- a/src/lib/media-api.ts +++ b/src/lib/media-api.ts @@ -14,12 +14,25 @@ export function isImage(mime?: string | null): boolean { return !!mime && mime.startsWith("image/"); } +// Some types (notably .md) have no OS-registered MIME, so the browser reports an empty file.type. +// Fall back to the extension for the text types IIOS allows, else a generic binary. +const EXT_MIME: Record = { + md: "text/markdown", markdown: "text/markdown", + html: "text/html", htm: "text/html", + txt: "text/plain", csv: "text/csv", +}; +function mimeForFile(file: File): string { + if (file.type) return file.type; + const ext = file.name.toLowerCase().split(".").pop() ?? ""; + return EXT_MIME[ext] ?? "application/octet-stream"; +} + /** Upload a File → { contentRef, mimeType, sizeBytes, filename }. Throws on oversize / failure. */ export function useUploadAttachment() { const { sdk } = useAppShell(); return useCallback(async (file: File): Promise => { if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB)."); - const mime = file.type || "application/octet-stream"; + const mime = mimeForFile(file); const { objectKey, uploadUrl } = (await sdk.command("crm.media.presignUpload", { mime, sizeBytes: file.size })) as { objectKey: string; uploadUrl: string }; const res = await fetch(uploadUrl, { method: "PUT", body: file }); if (!res.ok) throw new Error(`Upload failed (${res.status}).`); From e70904a219da65a00f9a56fe387f0457c65de7b8 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Tue, 21 Jul 2026 19:37:47 +0530 Subject: [PATCH 02/12] feat(messenger): CRM consumes @insignia/iios-messaging-ui instead of embedding The messenger tab now renders the shared SDK's , not the bespoke in-CRM implementation. The CRM only supplies transport + theme: - CrmMessagingAdapter implements the SDK's MessagingAdapter over the be-crm data door (crm.messenger.conversation.list/open, history, send, directory); live updates via a 4s poll for now (socket realtime is a follow-up that reuses messenger-socket.tsx). Tenancy/auth stay server-side. - messenger-sdk.tsx wires MessagingProvider + adapter (live = CrmMessagingAdapter, demo = the SDK MockAdapter), themed by mapping --miu-* tokens to the CRM design system. - dashboard renders ; the old messenger.tsx is left in place for rollback and will be deleted once this is proven live. NOTE: the SDK is installed from a local tarball (file:) for verification since it isn't published yet. For deploy: publish @insignia/iios-messaging-ui to the registry and change the dep to a version range. transpilePackages covers ESM. Co-Authored-By: Claude Opus 4.8 --- next.config.ts | 4 +- package-lock.json | 15 ++ package.json | 1 + src/app/dashboard/dashboard.css | 14 ++ src/components/dashboard/dashboard.tsx | 4 +- src/components/dashboard/messenger-sdk.tsx | 72 +++++++++ src/lib/crm-messaging-adapter.ts | 165 +++++++++++++++++++++ 7 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 src/components/dashboard/messenger-sdk.tsx create mode 100644 src/lib/crm-messaging-adapter.ts diff --git a/next.config.ts b/next.config.ts index aa1c5aa..976f677 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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. diff --git a/package-lock.json b/package-lock.json index 3a0245f..714de0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 726f655..3960434 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index de8381a..51ba53f 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -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; } diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index d052a4e..0fb2892 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -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" ? : active === "rules" ? : active === "ai" ? - : active === "messenger" ? + : active === "messenger" ? : active === "inbox" ? : active === "team" ? : } diff --git a/src/components/dashboard/messenger-sdk.tsx b/src/components/dashboard/messenger-sdk.tsx new file mode 100644 index 0000000..7e440a3 --- /dev/null +++ b/src/components/dashboard/messenger-sdk.tsx @@ -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 ( +
+ + {!SHELL && ( +
+ Demo mode — running on the SDK's mock adapter. +
+ )} +
{SHELL ? : }
+
+ ); +} + +// 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(() => new MockAdapter(), []); + return ( + + + + ); +} + +function LiveHost() { + const { sdk } = useAppShell(); + const { user } = useAuth(); + const adapter = useMemo( + () => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id) : null), + [sdk, user?.id], + ); + if (!adapter) return
Loading…
; + return ( + + + + ); +} diff --git a/src/lib/crm-messaging-adapter.ts b/src/lib/crm-messaging-adapter.ts new file mode 100644 index 0000000..899bfb7 --- /dev/null +++ b/src/lib/crm-messaging-adapter.ts @@ -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(action: string, variables?: Record): Promise; + command(action: string, variables?: Record): Promise; +} + +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; + primed: boolean; + timer: ReturnType | null; +} + +export class CrmMessagingAdapter implements MessagingAdapter { + private names: Map | null = null; + private readonly polls = new Map(); + + constructor(private readonly sdk: DataDoor, private readonly me: string) {} + + currentActorId(): string { + return this.me; + } + + async listConversations(): Promise { + const [convs, names] = await Promise.all([ + this.sdk.query("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 { + const msgs = await this.sdk.query("crm.messenger.history", { threadId }); + return msgs.map((m) => this.toMessage(m)); + } + + async send(threadId: string, content: string, opts?: SendOpts): Promise { + const m = await this.sdk.command("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 => { + const p = this.polls.get(threadId); + if (!p) return; + try { + const msgs = await this.sdk.query("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 { + // No BFF verb for read receipts here; follow-up via the socket. No-op resolves the contract. + } + + // ── mapping ──────────────────────────────────────────────────── + private async directory(): Promise> { + if (!this.names) { + const dir = await this.sdk.query("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): 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, + }; + } +} From cd8015ada8956537ee0db54407fa79e3d0100d39 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Tue, 21 Jul 2026 19:49:43 +0530 Subject: [PATCH 03/12] feat(messenger): socket realtime in the CRM SDK adapter (parity with old messenger) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CrmMessagingAdapter is now hybrid: BFF for the conversation list, thread creation, and directory (server-side tenancy); the IIOS MessageSocket (delegated crm.messenger.realtime token) for everything live — history+join, send, typing, read receipts, and reaction annotations. Falls back to the 4s poll only when no socket is available. - messenger-sdk useRealtimeSocket() opens the MessageSocket from the BFF token and passes it to the adapter (adapter rebuilds live once connected) - socket path maps senderId (userId space) → actorId so 'mine'/seen work consistently, killing the actor-vs-user ambiguity in the poll fallback - reaction annotations tracked per message and re-emitted as full sets Co-Authored-By: Claude Opus 4.8 --- src/components/dashboard/messenger-sdk.tsx | 32 ++- src/lib/crm-messaging-adapter.ts | 219 +++++++++++++++------ 2 files changed, 182 insertions(+), 69 deletions(-) diff --git a/src/components/dashboard/messenger-sdk.tsx b/src/components/dashboard/messenger-sdk.tsx index 7e440a3..e7ae9f0 100644 --- a/src/components/dashboard/messenger-sdk.tsx +++ b/src/components/dashboard/messenger-sdk.tsx @@ -5,8 +5,9 @@ // 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 { useEffect, useMemo, useState } from "react"; +import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react"; +import { MessageSocket } from "@insignia/iios-kernel-client"; 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"; @@ -14,6 +15,27 @@ import { isShellConfigured } from "@/lib/appshell"; import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter"; import { PageHead } from "./ui"; +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() { @@ -59,9 +81,11 @@ function DemoHost() { function LiveHost() { const { sdk } = useAppShell(); const { user } = useAuth(); + const socket = useRealtimeSocket(); + // 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) : null), - [sdk, user?.id], + () => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id, socket ?? undefined) : null), + [sdk, user?.id, socket], ); if (!adapter) return
Loading…
; return ( diff --git a/src/lib/crm-messaging-adapter.ts b/src/lib/crm-messaging-adapter.ts index 899bfb7..c340fdd 100644 --- a/src/lib/crm-messaging-adapter.ts +++ b/src/lib/crm-messaging-adapter.ts @@ -1,10 +1,9 @@ -// 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. +// The CRM's implementation of the SDK's MessagingAdapter. HYBRID transport: +// • BFF (appshell crm.messenger.*) for the conversation list, thread creation, and directory +// — these need server-side tenancy/auth. +// • IIOS MessageSocket (delegated token from crm.messenger.realtime) for everything live: +// history+join, send, typing, read receipts, reactions. +// When no socket is available (token failed / demo), it degrades to a 4s history poll. import type { Conversation, @@ -12,11 +11,13 @@ import type { Message, MessageEvent, MessagingAdapter, + Reaction, SendOpts, Unsubscribe, } from "@insignia/iios-messaging-ui"; +import type { MessageSocket, Message as KernelMessage } from "@insignia/iios-kernel-client"; -/** The imperative appshell data door (useAppShell().sdk). Typed structurally so we don't couple to its class. */ +/** The imperative appshell data door (useAppShell().sdk). Typed structurally, not to its class. */ export interface DataDoor { query(action: string, variables?: Record): Promise; command(action: string, variables?: Record): Promise; @@ -30,19 +31,44 @@ interface ConversationDTO { interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null } const POLL_MS = 4000; +const REACTION = "reaction"; -interface Poll { - cbs: Set<(e: MessageEvent) => void>; - seen: Set; - primed: boolean; - timer: ReturnType | null; -} +interface Poll { seen: Set; primed: boolean; timer: ReturnType | null } export class CrmMessagingAdapter implements MessagingAdapter { private names: Map | null = null; + private readonly listeners = new Map void>>(); private readonly polls = new Map(); + private readonly joined = new Set(); + /** messageId → emoji → userSet, so a single annotation delta can be re-emitted as a full set. */ + private readonly reactions = new Map>>(); - constructor(private readonly sdk: DataDoor, private readonly me: string) {} + /** Only present with a socket — the UI hides the reaction affordance without it. */ + react?: (threadId: string, messageId: string, emoji: string) => Promise; + + constructor( + private readonly sdk: DataDoor, + private readonly me: string, + private readonly socket?: MessageSocket, + ) { + if (socket) { + socket.on("message", (m) => { + this.ingestReactions(m); + this.emit(m.threadId, { kind: "message", message: this.fromKernel(m) }); + }); + 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. + socket.on("receipt", (e) => this.broadcast({ kind: "receipt", messageId: e.interactionId, actorId: e.actorId })); + socket.on("annotation", (e) => { + if (e.type !== REACTION) return; + this.setReactionUsers(e.interactionId, e.value, e.users); + this.emit(e.threadId, { kind: "reaction", messageId: e.interactionId, reactions: this.reactionsOf(e.interactionId) }); + }); + this.react = async (threadId, messageId, emoji) => { + await socket.react(threadId, messageId, emoji); + }; + } + } currentActorId(): string { return this.me; @@ -66,65 +92,87 @@ export class CrmMessagingAdapter implements MessagingAdapter { } async history(threadId: string): Promise { + if (this.socket) { + const res = await this.socket.openThread(threadId); // joins so live events flow + this.joined.add(threadId); + return res.history.map((m) => { + this.ingestReactions(m); + return this.fromKernel(m); + }); + } const msgs = await this.sdk.query("crm.messenger.history", { threadId }); - return msgs.map((m) => this.toMessage(m)); + return msgs.map((m) => this.fromDto(m)); } async send(threadId: string, content: string, opts?: SendOpts): Promise { - const m = await this.sdk.command("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. + if (this.socket) { + const m = await this.socket.sendMessage( + threadId, + content, + opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : undefined, + ); + return this.fromKernel(m); + } + const m = await this.sdk.command("crm.messenger.send", { threadId, content }); + const msg = this.fromDto(m); 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 => { - const p = this.polls.get(threadId); - if (!p) return; - try { - const msgs = await this.sdk.query("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); + if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set()); + this.listeners.get(threadId)!.add(cb); + + if (this.socket) { + if (!this.joined.has(threadId)) { + this.joined.add(threadId); + void this.socket.openThread(threadId).catch(() => this.joined.delete(threadId)); + } + } else { + this.startPoll(threadId); } - 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); + const set = this.listeners.get(threadId); + set?.delete(cb); + if (set && set.size === 0) { + this.listeners.delete(threadId); + const poll = this.polls.get(threadId); + if (poll?.timer) clearInterval(poll.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. + sendTyping(threadId: string): void { + this.socket?.typing(threadId); } - async markRead(): Promise { - // No BFF verb for read receipts here; follow-up via the socket. No-op resolves the contract. + async markRead(threadId: string, messageId: string): Promise { + if (this.socket) await this.socket.markRead(threadId, messageId); + } + + // ── polling fallback (no socket) ─────────────────────────────── + private startPoll(threadId: string): void { + if (this.polls.has(threadId)) return; + const poll: Poll = { seen: new Set(), primed: false, timer: null }; + this.polls.set(threadId, poll); + const tick = async (): Promise => { + if (!this.polls.has(threadId)) return; + try { + const msgs = await this.sdk.query("crm.messenger.history", { threadId }); + for (const m of msgs) { + if (poll.seen.has(m.interactionId)) continue; + poll.seen.add(m.interactionId); + if (poll.primed) this.emit(threadId, { kind: "message", message: this.fromDto(m) }); + } + poll.primed = true; + } catch { + /* transient — retry next tick */ + } + }; + void tick(); + poll.timer = setInterval(tick, POLL_MS); } // ── mapping ──────────────────────────────────────────────────── @@ -138,10 +186,7 @@ export class CrmMessagingAdapter implements MessagingAdapter { private toConversation(c: ConversationDTO, names: Map): Conversation { const others = c.participants.filter((p) => p !== this.me); - const title = - c.subject?.trim() || - others.map((id) => names.get(id) ?? id).join(", ") || - "Conversation"; + const title = c.subject?.trim() || others.map((id) => names.get(id) ?? id).join(", ") || "Conversation"; return { threadId: c.threadId, title, @@ -154,12 +199,56 @@ export class CrmMessagingAdapter implements MessagingAdapter { }; } - private toMessage(m: MessageDTO): Message { + /** Kernel Message (socket) → SDK Message. actorId = senderId (userId space), matching currentActorId. */ + private fromKernel(m: KernelMessage): Message { return { - id: m.interactionId, - actorId: m.actorId, - text: m.text ?? "", - at: m.occurredAt, + id: m.id, + actorId: m.senderId ?? null, + text: m.content ?? "", + at: m.createdAt, + parentInteractionId: m.parentInteractionId ?? null, + reactions: this.reactionsOf(m.id), }; } + + /** BFF DTO (poll fallback) → SDK Message. Note: actorId is IIOS actor-id space here. */ + private fromDto(m: MessageDTO): Message { + return { id: m.interactionId, actorId: m.actorId, text: m.text ?? "", at: m.occurredAt }; + } + + // ── reaction state ───────────────────────────────────────────── + private ingestReactions(m: KernelMessage): void { + for (const a of m.annotations ?? []) { + if (a.type === REACTION) this.setReactionUsers(m.id, a.value, a.users); + } + } + + private setReactionUsers(messageId: string, emoji: string, users: string[]): void { + let byEmoji = this.reactions.get(messageId); + if (!byEmoji) { + byEmoji = new Map(); + this.reactions.set(messageId, byEmoji); + } + if (users.length === 0) byEmoji.delete(emoji); + else byEmoji.set(emoji, new Set(users)); + } + + private reactionsOf(messageId: string): Reaction[] { + const byEmoji = this.reactions.get(messageId); + if (!byEmoji) return []; + const out: Reaction[] = []; + for (const [emoji, users] of byEmoji) { + if (users.size > 0) out.push({ emoji, count: users.size, mine: users.has(this.me) }); + } + return out; + } + + // ── event fan-out ────────────────────────────────────────────── + private emit(threadId: string, e: MessageEvent): void { + this.listeners.get(threadId)?.forEach((cb) => cb(e)); + } + + private broadcast(e: MessageEvent): void { + for (const set of this.listeners.values()) set.forEach((cb) => cb(e)); + } } From 413f8d43b67c408b981b6f01d4ba12d8b84e83a3 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Tue, 21 Jul 2026 23:35:41 +0530 Subject: [PATCH 04/12] feat(messenger): channels + @mentions in the CRM SDK adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CrmMessagingAdapter now implements the full SDK contract: - browseChannels/createChannel/leaveChannel over the be-crm channel door (crm.messenger.channel.*); joinChannel is a governed public self-join over the socket (openThread) — the BFF has no join verb, OPA enforces it - listMembers over crm.messenger.members → drives @mention autocomplete - send forwards mentions[] on the socket path (→ IIOS MENTION inbox items) So the CRM messenger (rendered by the SDK) now gets sectioned Channels/DMs, browse+join+create, and @mention autocomplete/highlight — no embedded code. (SDK re-packed to the local tarball; publish for deploy.) tsc + next build green. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 2 +- src/lib/crm-messaging-adapter.ts | 54 +++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 714de0f..d660907 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-6pSVWACjex1sXWzVqx+42rM6vgWEJ+1ud5y3DLKogJiQmfgFZgCB8vTw0PN/5FECugXC7iGuc9rV9RsHsP+EDw==", + "integrity": "sha512-B6Vzb75lBv4W3ZuKE4muMSz/OyUrE0NxImUI6sHaCBby5xveHVyanPiwkxiaqg9zZPYQMYiZRMONYwlNqyJvig==", "peerDependencies": { "@insignia/iios-kernel-client": "*", "react": ">=18" diff --git a/src/lib/crm-messaging-adapter.ts b/src/lib/crm-messaging-adapter.ts index c340fdd..0ecb00e 100644 --- a/src/lib/crm-messaging-adapter.ts +++ b/src/lib/crm-messaging-adapter.ts @@ -6,11 +6,15 @@ // When no socket is available (token failed / demo), it degrades to a 4s history poll. import type { + ChannelSummary, + ChannelVisibility, Conversation, + CreateChannelInput, Membership, Message, MessageEvent, MessagingAdapter, + Person, Reaction, SendOpts, Unsubscribe, @@ -106,11 +110,11 @@ export class CrmMessagingAdapter implements MessagingAdapter { async send(threadId: string, content: string, opts?: SendOpts): Promise { if (this.socket) { - const m = await this.socket.sendMessage( - threadId, - content, - opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : undefined, - ); + const sendOpts = { + ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}), + ...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}), + }; + const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined); return this.fromKernel(m); } const m = await this.sdk.command("crm.messenger.send", { threadId, content }); @@ -152,6 +156,46 @@ export class CrmMessagingAdapter implements MessagingAdapter { if (this.socket) await this.socket.markRead(threadId, messageId); } + // ── channels + members (BFF, except join which is a governed socket self-join) ── + async browseChannels(): Promise { + const rows = await this.sdk.query>( + "crm.messenger.channel.browse", + {}, + ); + return rows.map((c) => ({ + threadId: c.threadId, + name: c.name, + topic: c.topic, + visibility: (c.visibility === "private" ? "private" : "public") as ChannelVisibility, + memberCount: c.memberCount, + joined: c.joined, + })); + } + + async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> { + return this.sdk.command<{ threadId: string }>("crm.messenger.channel.create", { + name: input.name, + ...(input.topic ? { topic: input.topic } : {}), + visibility: input.visibility, + }); + } + + async joinChannel(threadId: string): Promise { + // Governed public self-join over the socket (the BFF has no join verb; OPA enforces it). + if (!this.socket) throw new Error("joining a channel needs a live connection"); + await this.socket.openThread(threadId); + this.joined.add(threadId); + } + + async leaveChannel(threadId: string): Promise { + await this.sdk.command("crm.messenger.channel.leave", { threadId }); + } + + async listMembers(threadId: string): Promise { + const rows = await this.sdk.query>("crm.messenger.members", { threadId }); + return rows.map((r) => ({ id: r.userId, name: r.displayName, kind: "staff" as const })); + } + // ── polling fallback (no socket) ─────────────────────────────── private startPoll(threadId: string): void { if (this.polls.has(threadId)) return; From facc50e82f6a07ead831f0acca9dd13c7d6d0875 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 22 Jul 2026 00:54:03 +0530 Subject: [PATCH 05/12] 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)}

` }); + } +} From 462eb7f0364b6bb3283a5b663e5d2d01cfd08385 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 22 Jul 2026 01:05:14 +0530 Subject: [PATCH 06/12] feat(inbox): wire mail attachments through CrmInboxAdapter uploadAttachment presigns via crm.media.presignUpload + PUTs bytes to IIOS storage; mailReply/composeInternal/composeExternal forward attachment refs to crm.mail.reply/internal/send (server already supported them). Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 2 +- src/lib/crm-inbox-adapter.ts | 43 +++++++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index eb0d18b..2949949 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-RoXEI/SgzYM2Zj+XvwNkc3rrDQ5kDa/oqsCo2wkZxwRs3bVHJuRoQR+c713wgaWK+MmqIg0cetXQ//rfJvpBbg==", + "integrity": "sha512-qJc7+34VwPcsuaucXcUe7LbW7CAvVcGBUnUR6ktuHHISpGVWzXEni9rjUiBI+Baw3UxvUFbtmIuqT1DTXIeegw==", "peerDependencies": { "@insignia/iios-kernel-client": "*", "react": ">=18" diff --git a/src/lib/crm-inbox-adapter.ts b/src/lib/crm-inbox-adapter.ts index 2bc3041..f5cca15 100644 --- a/src/lib/crm-inbox-adapter.ts +++ b/src/lib/crm-inbox-adapter.ts @@ -6,11 +6,24 @@ import type { InboxAdapter, InboxItem, InboxState, + MailAttachment, MailMessage, MailPerson, } from "@insignia/iios-messaging-ui"; import type { DataDoor } from "./crm-messaging-adapter"; +const MAX_ATTACHMENT_BYTES = 26 * 1024 * 1024; // matches IIOS's cap + +// Some types (notably .md) have no OS-registered MIME, so the browser reports an empty file.type. +const EXT_MIME: Record = { + md: "text/markdown", markdown: "text/markdown", html: "text/html", htm: "text/html", txt: "text/plain", csv: "text/csv", +}; +function mimeForFile(file: File): string { + if (file.type) return file.type; + const ext = file.name.toLowerCase().split(".").pop() ?? ""; + return EXT_MIME[ext] ?? "application/octet-stream"; +} + interface InboxItemDTO { id: string; kind: string; state: InboxState; title: string; summary?: string; priority: string; threadId?: string; createdAt: string; } @@ -63,8 +76,21 @@ export class CrmInboxAdapter implements InboxAdapter { })); } - async mailReply(threadId: string, content: string): Promise { - await this.sdk.command("crm.mail.reply", { threadId, content }); + async mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise { + await this.sdk.command("crm.mail.reply", { + threadId, + content, + ...(attachment ? { attachment: { filename: attachment.filename ?? "attachment", contentRef: attachment.contentRef, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes } } : {}), + }); + } + + async uploadAttachment(file: File): Promise { + if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB)."); + const mime = mimeForFile(file); + const { objectKey, uploadUrl } = await this.sdk.command<{ objectKey: string; uploadUrl: string }>("crm.media.presignUpload", { mime, sizeBytes: file.size }); + const res = await fetch(uploadUrl, { method: "PUT", body: file }); + if (!res.ok) throw new Error(`Upload failed (${res.status}).`); + return { contentRef: objectKey, mimeType: mime, sizeBytes: file.size, filename: file.name }; } async directory(): Promise { @@ -72,11 +98,16 @@ export class CrmInboxAdapter implements InboxAdapter { 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 composeInternal(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise { + await this.sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `

${escapeHtml(text)}

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

${escapeHtml(text)}

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

${escapeHtml(text)}

`, ...attachmentsVar(attachments) }); } } + +function attachmentsVar(attachments?: MailAttachment[]): { attachments?: Array<{ filename: string; contentRef: string; mimeType: string; sizeBytes: number }> } { + if (!attachments || attachments.length === 0) return {}; + return { attachments: attachments.map((a) => ({ filename: a.filename ?? "attachment", contentRef: a.contentRef, mimeType: a.mimeType, sizeBytes: a.sizeBytes })) }; +} From 8e2fe315ce245aab5d8963a090c791d3c198ba73 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 22 Jul 2026 17:43:53 +0530 Subject: [PATCH 07/12] =?UTF-8?q?feat(settings):=20Org=20Settings=20?= =?UTF-8?q?=E2=86=92=20Integrations=20with=20BYO=20Twilio=20SMS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Settings tab renders an Integrations section; the SMS (Twilio) card lets a tenant admin connect their own Twilio account (Account SID / write-only Auth Token / E.164 from-number) via crm.settings.sms.configure, and shows the masked status (from-number + SID last-4) from crm.settings.sms.status. Demo mode stores hints locally. Email (SMTP) shown as the next provider on the same registry. Co-Authored-By: Claude Opus 4.8 --- src/app/dashboard/dashboard.css | 22 +++- src/components/dashboard/dashboard.tsx | 2 + src/components/dashboard/settings.tsx | 136 +++++++++++++++++++++++++ src/lib/sms-settings-api.ts | 78 ++++++++++++++ 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 src/components/dashboard/settings.tsx create mode 100644 src/lib/sms-settings-api.ts diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index cf5aa7c..d1b1a64 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -1155,4 +1155,24 @@ .dash-root .ai-bubble { max-width: 86%; } .dash-root .ai-view { height: calc(100vh - 150px); } } - \ No newline at end of file + + /* ---- Org Settings → Integrations ---- */ + .dash-root .settings-section { margin-top: 8px; } + .dash-root .settings-section-title { font-size: 13px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); margin: 0 0 14px; } + .dash-root .settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; } + .dash-root .settings-card { border: 1px solid var(--border); background: var(--panel); border-radius: 16px; padding: 18px; display: flex; flex-direction: column; gap: 16px; } + .dash-root .settings-card.is-soon { opacity: 0.6; } + .dash-root .settings-card-head { display: flex; align-items: flex-start; gap: 12px; } + .dash-root .settings-card-ic { flex: 0 0 auto; width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); } + .dash-root .settings-card-titles { flex: 1 1 auto; min-width: 0; } + .dash-root .settings-card-name { font-size: 15px; font-weight: 700; color: var(--text); } + .dash-root .settings-card-sub { font-weight: 500; color: var(--muted); } + .dash-root .settings-card-desc { font-size: 12.5px; color: var(--muted); margin-top: 2px; } + .dash-root .settings-card-body { display: flex; flex-direction: column; gap: 12px; } + .dash-root .settings-kv { display: grid; gap: 10px; margin: 0; } + .dash-root .settings-kv > div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; } + .dash-root .settings-kv > div:last-child { border-bottom: 0; padding-bottom: 0; } + .dash-root .settings-kv dt { font-size: 12.5px; color: var(--muted); } + .dash-root .settings-kv dd { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; } + .dash-root .settings-card-actions { display: flex; gap: 8px; align-items: center; margin-top: 2px; } + .dash-root .settings-card-note { font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; } diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index 9fa6bc6..cbf21fd 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -17,6 +17,7 @@ import { AiAssistant } from "./ai-assistant"; import { TeamManagement } from "./team-management"; import { MessengerSdk } from "./messenger-sdk"; import { InboxSdk } from "./inbox-sdk"; +import { Settings } from "./settings"; import "../../app/dashboard/dashboard.css"; export function Dashboard() { @@ -49,6 +50,7 @@ export function Dashboard() { : active === "ai" ? : active === "messenger" ? : active === "inbox" ? + : active === "settings" ? : active === "team" ? : } diff --git a/src/components/dashboard/settings.tsx b/src/components/dashboard/settings.tsx new file mode 100644 index 0000000..486d245 --- /dev/null +++ b/src/components/dashboard/settings.tsx @@ -0,0 +1,136 @@ +"use client"; + +// ============================================================ +// Org Settings → Integrations. Today: SMS (Twilio) — a tenant +// brings its OWN Twilio credentials, which be-crm seals in IIOS +// (per-scope) and resolves at send time. The auth token is +// write-only: sealed in IIOS, never read back, so status shows +// only masked hints (from-number + SID last-4). Email (SMTP) is +// the next provider on the same generic credential registry. +// ============================================================ + +import { useState } from "react"; +import { Btn, Field, Icon, PageHead, Pill, useToast } from "./ui"; +import { useSmsSettings } from "@/lib/sms-settings-api"; + +const SID_RE = /^AC[0-9a-fA-F]{32}$/; +const E164_RE = /^\+[1-9]\d{6,14}$/; + +export function Settings() { + return ( +
+ +
+

Integrations

+
+ + +
+
+
+ ); +} + +function TwilioCard() { + const toast = useToast(); + const { status, loading, live, configure } = useSmsSettings(); + const [editing, setEditing] = useState(false); + const [accountSid, setAccountSid] = useState(""); + const [authToken, setAuthToken] = useState(""); + const [fromNumber, setFromNumber] = useState(""); + const [errors, setErrors] = useState<{ accountSid?: string; authToken?: string; fromNumber?: string }>({}); + const [saving, setSaving] = useState(false); + + const showForm = editing || (!loading && !status.configured); + + function validate(): boolean { + const e: typeof errors = {}; + if (!SID_RE.test(accountSid.trim())) e.accountSid = "Must be a Twilio Account SID (AC + 32 hex chars)."; + if (!authToken.trim()) e.authToken = "Auth token is required."; + if (!E164_RE.test(fromNumber.trim())) e.fromNumber = "Must be E.164, e.g. +15551234567."; + setErrors(e); + return Object.keys(e).length === 0; + } + + async function save() { + if (!validate()) return; + setSaving(true); + try { + await configure({ accountSid: accountSid.trim(), authToken: authToken.trim(), fromNumber: fromNumber.trim() }); + toast.push({ tone: "success", title: "Twilio connected", desc: "Your SMS credentials are saved and encrypted." }); + setAccountSid(""); setAuthToken(""); setFromNumber(""); setErrors({}); setEditing(false); + } catch (err) { + toast.push({ tone: "error", title: "Couldn't save credentials", desc: (err as Error).message }); + } finally { + setSaving(false); + } + } + + return ( +
+
+ +
+
+ SMS · Twilio +
+
Send texts from your own Twilio number.
+
+ {status.configured + ? Connected + : Not connected} +
+ + {status.configured && !editing ? ( +
+
+
From number
{status.fromNumber ?? "—"}
+
Account SID
{status.sidLast4 ? `AC ···· ${status.sidLast4}` : "—"}
+
Status
{status.enabled ? "Active" : "Disabled"}
+
+ setEditing(true)}>Update credentials +
+ ) : null} + + {showForm ? ( +
+ + setAccountSid(e.target.value)} placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" autoComplete="off" /> + + + setAuthToken(e.target.value)} placeholder="••••••••••••••••••••••••••••••••" autoComplete="off" /> + + + setFromNumber(e.target.value)} placeholder="+15551234567" autoComplete="off" /> + +
+ {saving ? "Saving…" : status.configured ? "Update" : "Connect Twilio"} + {status.configured ? { setEditing(false); setErrors({}); }}>Cancel : null} +
+
+ ) : null} + + {!live ?
Demo mode — credentials are stored locally and not sent to Twilio.
: null} +
+ ); +} + +function SmtpComingSoon() { + return ( +
+
+ +
+
Email · SMTP
+
Bring your own SMTP server for outbound email.
+
+ Coming soon +
+
+ ); +} diff --git a/src/lib/sms-settings-api.ts b/src/lib/sms-settings-api.ts new file mode 100644 index 0000000..7d05de3 --- /dev/null +++ b/src/lib/sms-settings-api.ts @@ -0,0 +1,78 @@ +"use client"; + +// SMS settings data layer. Serves EITHER the local mock (Shell not configured — the demo keeps +// working) OR the live be-crm data door (crm.settings.sms.*), behind one interface. +// +// Live contract (be-crm → IIOS BYO credential store): +// query crm.settings.sms.status {} -> { configured, enabled?, hints? } +// cmd crm.settings.sms.configure { accountSid, authToken, fromNumber } -> masked status +// The auth token is write-only: it is sealed in IIOS and NEVER returned — status carries only +// non-secret hints (from-number + SID last-4). + +import { useCallback, useState } from "react"; +import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react"; +import { isShellConfigured } from "./appshell"; + +export interface SmsCredentials { accountSid: string; authToken: string; fromNumber: string } + +export interface SmsStatus { + configured: boolean; + enabled: boolean; + fromNumber?: string; + sidLast4?: string; +} + +export interface SmsSettingsData { + live: boolean; + loading: boolean; + error: string | null; + status: SmsStatus; + configure: (input: SmsCredentials) => Promise; + refetch: () => void; +} + +interface StatusDTO { configured: boolean; enabled?: boolean; hints?: { fromNumber?: string; sidLast4?: string } } + +function toStatus(dto?: StatusDTO | null): SmsStatus { + return { + configured: !!dto?.configured, + enabled: dto?.enabled ?? false, + fromNumber: dto?.hints?.fromNumber, + sidLast4: dto?.hints?.sidLast4, + }; +} + +/* ---- Mock (demo mode) — stores only the non-secret hints, mirroring the masked live status ---- */ +function useMockSms(): SmsSettingsData { + const [status, setStatus] = useState({ configured: false, enabled: false }); + const configure = useCallback(async ({ accountSid, fromNumber }: SmsCredentials) => { + setStatus({ configured: true, enabled: true, fromNumber, sidLast4: accountSid.slice(-4) }); + }, []); + return { live: false, loading: false, error: null, status, configure, refetch: () => {} }; +} + +/* ---- Live (be-crm data door) ---- */ +function useLiveSms(): SmsSettingsData { + const { sdk } = useAppShell(); + const q = useQuery("crm.settings.sms.status", {}); + const configure = useCallback(async (input: SmsCredentials) => { + await sdk.command("crm.settings.sms.configure", { ...input }); + q.refetch(); + }, [sdk, q]); + return { + live: true, + loading: q.loading, + error: q.error ? String(q.error) : null, + status: toStatus(q.data), + configure, + refetch: q.refetch, + }; +} + +const SHELL = isShellConfigured(); + +export function useSmsSettings(): SmsSettingsData { + // SHELL is constant for the bundle's life (NEXT_PUBLIC_* is build-time), so the same hook path + // runs every render — Rules-of-Hooks safe. + return SHELL ? useLiveSms() : useMockSms(); +} From 988b46f6e7399ec80bc3a2187e0f5897cbb970d6 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 22 Jul 2026 17:55:10 +0530 Subject: [PATCH 08/12] refactor(messaging): drop bespoke messenger/inbox/mail + wrapper page headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the now-dead pre-SDK stack (messenger/inbox/mail components + their *-api libs + messenger-socket) — both tabs render the @insignia/iios-messaging-ui SDK. Remove the redundant from the messenger/inbox SDK wrappers (the Topbar already shows the title); the SDK itself renders no header. Co-Authored-By: Claude Opus 4.8 --- src/components/dashboard/inbox-sdk.tsx | 7 - src/components/dashboard/inbox.tsx | 152 ------ src/components/dashboard/mail.tsx | 231 --------- src/components/dashboard/messenger-sdk.tsx | 7 - src/components/dashboard/messenger.tsx | 550 --------------------- src/lib/inbox-api.ts | 95 ---- src/lib/mail-api.ts | 121 ----- src/lib/messenger-api.ts | 435 ---------------- src/lib/messenger-socket.tsx | 149 ------ 9 files changed, 1747 deletions(-) delete mode 100644 src/components/dashboard/inbox.tsx delete mode 100644 src/components/dashboard/mail.tsx delete mode 100644 src/components/dashboard/messenger.tsx delete mode 100644 src/lib/inbox-api.ts delete mode 100644 src/lib/mail-api.ts delete mode 100644 src/lib/messenger-api.ts delete mode 100644 src/lib/messenger-socket.tsx diff --git a/src/components/dashboard/inbox-sdk.tsx b/src/components/dashboard/inbox-sdk.tsx index 43fb8df..1c0fbed 100644 --- a/src/components/dashboard/inbox-sdk.tsx +++ b/src/components/dashboard/inbox-sdk.tsx @@ -12,19 +12,12 @@ 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. diff --git a/src/components/dashboard/inbox.tsx b/src/components/dashboard/inbox.tsx deleted file mode 100644 index c79ac1c..0000000 --- a/src/components/dashboard/inbox.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -// ============================================================ -// Inbox — the ONE unified communication surface. It lists everything -// IIOS surfaces for you (mentions, needs-reply, system alerts, support -// updates, …) AND the mail behind them: click an item tied to a thread -// and its conversation opens on the right to read + reply. Compose new -// mail from here too. Items come from crm.inbox.*; threads from crm.mail.*. -// ============================================================ - -import { useEffect, useState } from "react"; -import { Btn, Icon, PageHead, Pill, useToast } from "./ui"; -import { useInboxData, type InboxState, type UiInboxItem } from "@/lib/inbox-api"; -import { MailReader, NewMailModal } from "./mail"; - -const KIND_LABEL: Record = { - MAIL: "Mail", - MENTION: "Mention", NEEDS_REPLY: "Needs reply", NEEDS_REVIEW: "Needs review", NEEDS_APPROVAL: "Needs approval", - SUPPORT_UPDATE: "Support", MEETING_FOLLOWUP: "Meeting", DIGEST: "Digest", SYSTEM_ALERT: "Alert", CRM_OWNER_INTEREST: "Owner", -}; -const FILTERS: { value: InboxState; label: string }[] = [ - { value: "OPEN", label: "Open" }, { value: "SNOOZED", label: "Snoozed" }, { value: "DONE", label: "Done" }, { value: "ARCHIVED", label: "Archived" }, -]; - -export function Inbox() { - const [filter, setFilter] = useState("OPEN"); - const inbox = useInboxData(filter); - const toast = useToast(); - const [selectedId, setSelectedId] = useState(null); - const [newOpen, setNewOpen] = useState(false); - - useEffect(() => { - if ((!selectedId || !inbox.items.some((i) => i.id === selectedId)) && inbox.items[0]) setSelectedId(inbox.items[0].id); - }, [inbox.items, selectedId]); - - const selected = inbox.items.find((i) => i.id === selectedId) ?? null; - - return ( -
- setNewOpen(true)}>New mail} - /> - {!inbox.live && ( -
- Demo mode — running on mock data. It goes live once the Shell + be-crm are connected. -
- )} - -
- {FILTERS.map((f) => ( - setFilter(f.value)}>{f.label} - ))} -
- -
- {/* Left — the unified item list */} - - - {/* Right — read the mail behind the item, or the item detail */} -
- {selected ? ( - toast.push({ tone: "error", title: "Failed", desc: m })} - onDone={() => inbox.transition(selected.id, "DONE")} - onSnooze={() => inbox.transition(selected.id, "SNOOZED")} - onArchive={() => inbox.transition(selected.id, "ARCHIVED")} - /> - ) : ( -
-

Select an item to read

-
- )} -
-
- - setNewOpen(false)} - onSent={() => { setNewOpen(false); inbox.refetch(); toast.push({ tone: "success", title: "Sent" }); }} - onError={(m) => toast.push({ tone: "error", title: "Couldn't send", desc: m })} - /> -
- ); -} - -function ItemRow({ it, active, onClick }: { it: UiInboxItem; active: boolean; onClick: () => void }) { - const isMention = it.kind === "MENTION"; - return ( - - ); -} - -function Detail({ it, onError, onDone, onSnooze, onArchive }: { - it: UiInboxItem; onError: (m: string) => void; onDone: () => void; onSnooze: () => void; onArchive: () => void; -}) { - return ( - <> - {/* Item actions bar — only for real inbox work-items. Mail isn't an inbox item - (no crm.inbox.transition), so it gets read/reply only, no Done/Snooze/Archive. */} - {it.state === "OPEN" && it.kind !== "MAIL" && ( -
- Snooze - Done - Archive -
- )} - - {it.threadId ? ( - // A message/mail item → open the conversation to read + reply. - // key by threadId: the SDK's useQuery only refetches when the ACTION changes, not the - // variables — so switching items must remount MailReader to load the new thread's history. -
- -
- ) : ( - // A non-threaded item (e.g. a system alert) → show its detail. -
-
{it.title}
- {it.summary &&
{it.summary}
} -
- )} - - ); -} diff --git a/src/components/dashboard/mail.tsx b/src/components/dashboard/mail.tsx deleted file mode 100644 index 9ee09da..0000000 --- a/src/components/dashboard/mail.tsx +++ /dev/null @@ -1,231 +0,0 @@ -"use client"; - -// ============================================================ -// Mail components used INSIDE the Inbox (not a separate tab). -// The Inbox is the one unified surface — mentions, system messages -// and mail all live there. These render the mail body + reply, and -// compose a new message. HTML bodies render in a sandboxed iframe. -// ============================================================ - -import { type CSSProperties, useEffect, useRef, useState } from "react"; -import { Avatar, Btn, Field, Icon, Modal, Pill } from "./ui"; -import { useMailThread, useMailCompose, type MailAttachment, type MailPerson } from "@/lib/mail-api"; -import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api"; - -const timeOf = (iso?: string) => { - if (!iso) return ""; - const d = new Date(iso); - return Number.isNaN(+d) ? "" : d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); -}; -const CUSTOMER_GRAD = "linear-gradient(135deg,#10b981,#059669)"; -const inputStyle: CSSProperties = { - width: "100%", padding: "9px 12px", borderRadius: 10, border: "1px solid var(--border)", - background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none", -}; - -function fmtBytes(n: number): string { - if (!n) return ""; - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; - return `${(n / (1024 * 1024)).toFixed(1)} MB`; -} - -/** Resolves a signed URL for a stored attachment and renders it inline (image) or as a file chip. */ -function MailAttachmentView({ att }: { att: MailAttachment }) { - const getUrl = useDownloadUrl(); - const [url, setUrl] = useState(null); - useEffect(() => { - let alive = true; - getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {}); - return () => { alive = false; }; - }, [att.contentRef, att.mimeType, getUrl]); - - const label = att.filename || "Attachment"; - if (isImage(att.mimeType)) { - return url - ? {label} - :
Loading image…
; - } - return ( - - - {label} - {att.sizeBytes > 0 && {fmtBytes(att.sizeBytes)}} - - ); -} - -/** A small staged-file chip shown in a composer before send, with a remove button. */ -function StagedChip({ file, onRemove }: { file: UploadedAttachment; onRemove: () => void }) { - return ( -
- - {file.filename} - {fmtBytes(file.sizeBytes)} - -
- ); -} - -/** Reader + reply for one mail thread. Used in the Inbox detail pane when an item has a threadId. */ -export function MailReader({ threadId, subject, onError }: { threadId: string; subject: string; onError: (m: string) => void }) { - const t = useMailThread(threadId); - const upload = useUploadAttachment(); - const [draft, setDraft] = useState(""); - const [sending, setSending] = useState(false); - const [staged, setStaged] = useState(null); - const [uploading, setUploading] = useState(false); - const fileRef = useRef(null); - - async function onPickFile(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - e.target.value = ""; - if (!file) return; - setUploading(true); - try { setStaged(await upload(file)); } - catch (err) { onError((err as Error).message); } - finally { setUploading(false); } - } - - async function reply() { - const text = draft.trim(); - if ((!text && !staged) || sending) return; - const att = staged ?? undefined; - setDraft(""); setStaged(null); setSending(true); - try { await t.reply(text, att); } - catch (e) { setDraft(text); setStaged(att ?? null); onError((e as Error).message); } - finally { setSending(false); } - } - - return ( -
-
-
{subject || "(no subject)"}
-
-
- {t.loading && t.messages.length === 0 &&
Loading…
} - {!t.loading && t.messages.length === 0 &&
No messages.
} - {t.messages.map((m) => ( -
-
- {m.kind === "EMAIL" ? "Email" : "Reply"}{m.actorId ? ` · ${m.actorId.replace(/^(pp_|cust_)/, "").slice(0, 8)}` : ""} - {timeOf(m.occurredAt)} -
- {m.html - ?