5 Commits

Author SHA1 Message Date
maaz519 e70904a219 feat(messenger): CRM consumes @insignia/iios-messaging-ui instead of embedding
The messenger tab now renders the shared SDK's <Messenger>, 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 <MessengerSdk/>; 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 <noreply@anthropic.com>
2026-07-21 19:37:47 +05:30
maaz519 036ed84255 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 <noreply@anthropic.com>
2026-07-20 14:29:21 +05:30
maaz519 7bcd1a2a2d fix(inbox): reload mail content when switching items (+ filter refetch)
The appshell SDK useQuery only re-runs when the action changes, not the
variables — so switching mail items (same crm.mail.history action, new
threadId) never refetched and the reader kept the first thread's content
(only the subject, a direct prop, updated). Key MailReader by threadId to
remount it on switch (same pattern Messenger already uses for ThreadView).
Also force a refetch of crm.inbox.list when the filter changes, which hit
the same stale-variables bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:08:47 +05:30
maaz519 efd31d9293 fix(inbox): show sent mail in the unified inbox + fix compose attach button
- inbox merges crm.mail.list threads (kind MAIL) into crm.inbox.list items so
  sent/received mail actually appears in the one surface; mail shows in the
  Open view (it has no inbox work-item state) and opens in MailReader on click.
  Mail rows are read/reply only — no Done/Snooze/Archive (crm.inbox.transition
  doesn't apply to a mail thread).
- compose Attachments block was wrapped in <Field> (a <label>), so the label
  hijacked the Attach button's click via label→file-input association and the
  picker misfired. Use a plain div with the same field styling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 18:53:33 +05:30
maaz519 a2a7e71f59 Merge pull request 'Feat/crm mail ui' (#30) from feat/crm-mail-ui into goutamnextflow
Reviewed-on: #30
2026-07-18 12:31:18 +00:00
11 changed files with 338 additions and 13 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// The SDK ships ESM/TS; let Next transpile it.
transpilePackages: ["@abe-kap/appshell-sdk"],
// The SDKs ship ESM; let Next transpile them.
transpilePackages: ["@abe-kap/appshell-sdk", "@insignia/iios-messaging-ui"],
// The browser calls the Shell BFF same-origin under /shell (so the HttpOnly
// session cookie flows). We deliberately use /shell (NOT /api) to avoid
// clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
+15
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@abe-kap/appshell-sdk": "^0.2.6",
"@insignia/iios-kernel-client": "^0.1.4",
"@insignia/iios-messaging-ui": "file:../iios/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "16.2.9",
@@ -1026,6 +1027,20 @@
"socket.io-client": "^4.8.1"
}
},
"node_modules/@insignia/iios-messaging-ui": {
"version": "0.1.0",
"resolved": "file:../iios/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz",
"integrity": "sha512-6pSVWACjex1sXWzVqx+42rM6vgWEJ+1ud5y3DLKogJiQmfgFZgCB8vTw0PN/5FECugXC7iGuc9rV9RsHsP+EDw==",
"peerDependencies": {
"@insignia/iios-kernel-client": "*",
"react": ">=18"
},
"peerDependenciesMeta": {
"@insignia/iios-kernel-client": {
"optional": true
}
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@abe-kap/appshell-sdk": "^0.2.6",
"@insignia/iios-kernel-client": "^0.1.4",
"@insignia/iios-messaging-ui": "file:../iios/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "16.2.9",
+14
View File
@@ -134,6 +134,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; }
.row { display: flex; align-items: center; }
+2 -2
View File
@@ -15,7 +15,7 @@ import { Support } from "./support";
import { Rules } from "./rules";
import { AiAssistant } from "./ai-assistant";
import { TeamManagement } from "./team-management";
import { Messenger } from "./messenger";
import { MessengerSdk } from "./messenger-sdk";
import { Inbox } from "./inbox";
import "../../app/dashboard/dashboard.css";
@@ -47,7 +47,7 @@ export function Dashboard() {
: active === "support" ? <Support />
: active === "rules" ? <Rules />
: active === "ai" ? <AiAssistant />
: active === "messenger" ? <Messenger />
: active === "messenger" ? <MessengerSdk />
: active === "inbox" ? <Inbox />
: active === "team" ? <TeamManagement />
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
+8 -4
View File
@@ -14,6 +14,7 @@ import { useInboxData, type InboxState, type UiInboxItem } from "@/lib/inbox-api
import { MailReader, NewMailModal } from "./mail";
const KIND_LABEL: Record<string, string> = {
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",
};
@@ -103,7 +104,7 @@ function ItemRow({ it, active, onClick }: { it: UiInboxItem; active: boolean; on
}}
>
<span style={{ marginTop: 2, color: isMention ? "var(--orange)" : "var(--text-2)", flexShrink: 0 }}>
<Icon name={it.threadId ? "chat" : isMention ? "chat" : "bell"} size={18} />
<Icon name={it.kind === "MAIL" ? "mail" : it.threadId ? "chat" : isMention ? "chat" : "bell"} size={18} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
@@ -122,8 +123,9 @@ function Detail({ it, onError, onDone, onSnooze, onArchive }: {
}) {
return (
<>
{/* Item actions bar (works for every item, threaded or not) */}
{it.state === "OPEN" && (
{/* 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" && (
<div style={{ display: "flex", gap: 6, padding: "10px 16px", borderBottom: "1px solid var(--border)", justifyContent: "flex-end" }}>
<Btn variant="ghost" icon="clock" onClick={onSnooze}>Snooze</Btn>
<Btn variant="outline" icon="check" onClick={onDone}>Done</Btn>
@@ -133,8 +135,10 @@ function Detail({ it, onError, onDone, onSnooze, onArchive }: {
{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.
<div style={{ flex: 1, minHeight: 0 }}>
<MailReader threadId={it.threadId} subject={it.title} onError={onError} />
<MailReader key={it.threadId} threadId={it.threadId} subject={it.title} onError={onError} />
</div>
) : (
// A non-threaded item (e.g. a system alert) → show its detail.
+5 -2
View File
@@ -216,13 +216,16 @@ export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field>
<Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field>
<Field label="Attachments">
{/* NOT a <Field> (which is a <label>): a label wrapping the file input would hijack the
Attach button's click via label→input association and open the picker erratically. */}
<div className="ds-field">
<span className="ds-field-lbl">Attachments</span>
<input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={onPickFile} />
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<Btn variant="outline" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading || staged.length >= 10}>{uploading ? "Uploading…" : "Attach"}</Btn>
{staged.map((f, i) => <StagedChip key={`${f.contentRef}_${i}`} file={f} onRemove={() => setStaged((s) => s.filter((_, j) => j !== i))} />)}
</div>
</Field>
</div>
</Modal>
);
}
@@ -0,0 +1,72 @@
"use client";
// The CRM messenger, now rendered by the shared @insignia/iios-messaging-ui SDK instead of a
// bespoke in-CRM implementation. The CRM only supplies an adapter (transport) + theming; all the
// UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter);
// demo path = the SDK's own MockAdapter.
import { useMemo } from "react";
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
import { MessagingProvider, Messenger as SdkMessenger, type MessagingAdapter } from "@insignia/iios-messaging-ui";
import { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock";
import "@insignia/iios-messaging-ui/styles.css";
import { isShellConfigured } from "@/lib/appshell";
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
import { PageHead } from "./ui";
const SHELL = isShellConfigured();
export function MessengerSdk() {
return (
<div className="view">
<PageHead
eyebrow="Communication"
title="Messenger"
subtitle="Chat with your team and clients — rendered by the messaging SDK"
icon="send"
/>
{!SHELL && (
<div
style={{
margin: "0 0 14px",
padding: "8px 14px",
borderRadius: 10,
background: "var(--panel-2)",
color: "var(--muted)",
fontSize: 13,
border: "1px solid var(--border)",
}}
>
Demo mode running on the SDK&apos;s mock adapter.
</div>
)}
<div className="miu-host">{SHELL ? <LiveHost /> : <DemoHost />}</div>
</div>
);
}
// SHELL is a build-time constant, so exactly one of these mounts for the life of the app
// (Rules-of-Hooks safe — the other branch never renders).
function DemoHost() {
const adapter = useMemo<MessagingAdapter>(() => new MockAdapter(), []);
return (
<MessagingProvider adapter={adapter}>
<SdkMessenger />
</MessagingProvider>
);
}
function LiveHost() {
const { sdk } = useAppShell();
const { user } = useAuth();
const adapter = useMemo<MessagingAdapter | null>(
() => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id) : null),
[sdk, user?.id],
);
if (!adapter) return <div className="miu-empty">Loading</div>;
return (
<MessagingProvider adapter={adapter}>
<SdkMessenger />
</MessagingProvider>
);
}
+165
View File
@@ -0,0 +1,165 @@
// The CRM's implementation of the SDK's MessagingAdapter, over the be-crm data door
// (appshell `crm.messenger.*`). This is how the CRM consumes @insignia/iios-messaging-ui
// instead of embedding its own messenger: the SDK renders, this adapter transports.
//
// Data (list/open/history/send) goes through the BFF, which attaches the session + tenancy
// server-side. Live updates are polled here for a first cut; realtime (the IIOS socket the CRM
// already opens in messenger-socket.tsx) can be layered in by emitting into the same registry.
import type {
Conversation,
Membership,
Message,
MessageEvent,
MessagingAdapter,
SendOpts,
Unsubscribe,
} from "@insignia/iios-messaging-ui";
/** The imperative appshell data door (useAppShell().sdk). Typed structurally so we don't couple to its class. */
export interface DataDoor {
query<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
command<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
}
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
interface ConversationDTO {
threadId: string; subject: string | null; membership: Membership | null;
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
}
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null }
const POLL_MS = 4000;
interface Poll {
cbs: Set<(e: MessageEvent) => void>;
seen: Set<string>;
primed: boolean;
timer: ReturnType<typeof setInterval> | null;
}
export class CrmMessagingAdapter implements MessagingAdapter {
private names: Map<string, string> | null = null;
private readonly polls = new Map<string, Poll>();
constructor(private readonly sdk: DataDoor, private readonly me: string) {}
currentActorId(): string {
return this.me;
}
async listConversations(): Promise<Conversation[]> {
const [convs, names] = await Promise.all([
this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}),
this.directory(),
]);
return convs.map((c) => this.toConversation(c, names));
}
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
const res = await this.sdk.command<{ threadId: string }>("crm.messenger.conversation.open", {
participantIds: p.participantIds,
...(p.membership ? { membership: p.membership } : {}),
...(p.subject ? { subject: p.subject } : {}),
});
return { threadId: res.threadId };
}
async history(threadId: string): Promise<Message[]> {
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
return msgs.map((m) => this.toMessage(m));
}
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
const m = await this.sdk.command<MessageDTO>("crm.messenger.send", {
threadId,
content,
...(opts?.attachment ? { attachment: opts.attachment } : {}),
});
const msg = this.toMessage(m);
// Mark it seen so the poll doesn't re-emit our own message on top of useMessages' optimistic row.
this.polls.get(threadId)?.seen.add(msg.id);
return msg;
}
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
let poll = this.polls.get(threadId);
if (!poll) {
poll = { cbs: new Set(), seen: new Set(), primed: false, timer: null };
this.polls.set(threadId, poll);
const tick = async (): Promise<void> => {
const p = this.polls.get(threadId);
if (!p) return;
try {
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
for (const m of msgs) {
if (p.seen.has(m.interactionId)) continue;
p.seen.add(m.interactionId);
// The first pass just records what's already loaded (useMessages fetched history);
// only genuinely new messages after that are pushed to the UI.
if (p.primed) p.cbs.forEach((f) => f({ kind: "message", message: this.toMessage(m) }));
}
p.primed = true;
} catch {
/* transient BFF error — try again next tick */
}
};
void tick();
poll.timer = setInterval(tick, POLL_MS);
}
poll.cbs.add(cb);
return () => {
const p = this.polls.get(threadId);
if (!p) return;
p.cbs.delete(cb);
if (p.cbs.size === 0) {
if (p.timer) clearInterval(p.timer);
this.polls.delete(threadId);
}
};
}
sendTyping(): void {
// No BFF verb for typing; realtime is a follow-up (the IIOS socket). No-op keeps the contract.
}
async markRead(): Promise<void> {
// No BFF verb for read receipts here; follow-up via the socket. No-op resolves the contract.
}
// ── mapping ────────────────────────────────────────────────────
private async directory(): Promise<Map<string, string>> {
if (!this.names) {
const dir = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 200 });
this.names = new Map(dir.map((d) => [d.id, d.displayName]));
}
return this.names;
}
private toConversation(c: ConversationDTO, names: Map<string, string>): Conversation {
const others = c.participants.filter((p) => p !== this.me);
const title =
c.subject?.trim() ||
others.map((id) => names.get(id) ?? id).join(", ") ||
"Conversation";
return {
threadId: c.threadId,
title,
subject: c.subject,
membership: c.membership,
participants: c.participants,
unread: c.unread,
...(c.lastMessage ? { lastMessage: c.lastMessage } : {}),
...(c.lastAt ? { lastAt: c.lastAt } : {}),
};
}
private toMessage(m: MessageDTO): Message {
return {
id: m.interactionId,
actorId: m.actorId,
text: m.text ?? "",
at: m.occurredAt,
};
}
}
+40 -2
View File
@@ -8,9 +8,10 @@
// query crm.inbox.list { state? } -> InboxItem[]
// cmd crm.inbox.transition { id, state, reason? } -> InboxItem
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import type { MailThread } from "./mail-api";
export type InboxState = "OPEN" | "SNOOZED" | "DONE" | "ARCHIVED" | "CANCELLED" | "STALE";
export interface UiInboxItem {
@@ -34,11 +35,48 @@ export function useInboxData(state?: InboxState): InboxData {
function useLiveInbox(state?: InboxState): InboxData {
const { sdk } = useAppShell();
const q = useQuery<UiInboxItem[]>("crm.inbox.list", state ? { state } : {});
// Mail lives in crm-mail threads, NOT the inbox projection — fold it into the one unified
// surface. Mail has no inbox work-item state, so it only shows in the Open (or unfiltered) view.
const showMail = !state || state === "OPEN";
const mq = useQuery<MailThread[]>("crm.mail.list", {});
// The SDK's useQuery only refetches when the ACTION changes, not the variables — so a filter
// change (same action, new { state }) wouldn't reload. Force a refetch when the filter changes.
const refetchInbox = q.refetch;
useEffect(() => { refetchInbox(); }, [state, refetchInbox]);
const items = useMemo<UiInboxItem[]>(() => {
const inboxItems = q.data ?? [];
const mailItems: UiInboxItem[] = showMail
? (mq.data ?? []).map((t) => ({
id: `mail:${t.threadId}`,
kind: "MAIL",
state: "OPEN" as InboxState,
title: t.subject || "(no subject)",
...(t.lastMessage ? { summary: t.lastMessage } : {}),
priority: t.unread > 0 ? "HIGH" : "LOW",
threadId: t.threadId,
createdAt: t.lastAt ?? "",
}))
: [];
// Newest first; mail and inbox items interleave by time.
return [...mailItems, ...inboxItems].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
}, [q.data, mq.data, showMail]);
const transition = useCallback(async (id: string, next: InboxState) => {
await sdk.command("crm.inbox.transition", { id, state: next });
q.refetch();
}, [sdk, q]);
return { live: true, loading: q.loading, error: q.error?.message ?? null, items: q.data ?? [], transition, refetch: q.refetch };
return {
live: true,
loading: q.loading || (showMail && mq.loading),
// Don't let a mail-list hiccup blank the whole inbox — surface only the inbox error.
error: q.error?.message ?? null,
items,
transition,
refetch: () => { q.refetch(); mq.refetch(); },
};
}
const MOCK_ITEMS: UiInboxItem[] = [
+14 -1
View File
@@ -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<string, string> = {
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<UploadedAttachment> => {
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}).`);