diff --git a/.npmrc b/.npmrc index de1a953..d79be22 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,6 @@ @abe-kap:registry=https://npm.pkg.github.com //npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} +# @insignia/* (iios-kernel-client — the MessageSocket live stream) resolve from the +# self-hosted Gitea npm registry. Install needs a token with `read:package` in GITEA_TOKEN. +@insignia:registry=https://git.lynkedup.cloud/api/packages/insignia/npm/ +//git.lynkedup.cloud/api/packages/insignia/npm/:_authToken=${GITEA_TOKEN} diff --git a/package-lock.json b/package-lock.json index 9ea699a..3a0245f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@abe-kap/appshell-sdk": "^0.2.6", + "@insignia/iios-kernel-client": "^0.1.4", "clsx": "^2.1.1", "lucide-react": "^1.21.0", "next": "16.2.9", @@ -1011,6 +1012,20 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@insignia/iios-contracts": { + "version": "0.1.0", + "resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-contracts/-/0.1.0/iios-contracts-0.1.0.tgz", + "integrity": "sha512-+ycbP8ORdwllMtn7dmxIL7lnDpDDkV+gHqT2OKJtMplMivjKU03gsuuEveQxnrE1CMvZ3fcSljmfMs10Cw131g==" + }, + "node_modules/@insignia/iios-kernel-client": { + "version": "0.1.4", + "resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-kernel-client/-/0.1.4/iios-kernel-client-0.1.4.tgz", + "integrity": "sha512-pYBHH21TvgOOm0kp3ogvZx/iEzXLwmU23jHpDzIolh5FrqxCrcpUjRwLDkeYRTTkRVdoJAPTmPES/pU66iNoiA==", + "dependencies": { + "@insignia/iios-contracts": "0.1.0", + "socket.io-client": "^4.8.1" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1279,6 +1294,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.110.2", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.2.tgz", @@ -3051,7 +3072,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3205,6 +3225,28 @@ "node": ">= 0.8" } }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.6", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", @@ -6688,6 +6730,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -7448,6 +7518,35 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index e695282..726f655 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@abe-kap/appshell-sdk": "^0.2.6", + "@insignia/iios-kernel-client": "^0.1.4", "clsx": "^2.1.1", "lucide-react": "^1.21.0", "next": "16.2.9", diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index 02d55a2..d052a4e 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -15,6 +15,8 @@ import { Support } from "./support"; import { Rules } from "./rules"; import { AiAssistant } from "./ai-assistant"; import { TeamManagement } from "./team-management"; +import { Messenger } from "./messenger"; +import { Inbox } from "./inbox"; import "../../app/dashboard/dashboard.css"; export function Dashboard() { @@ -45,6 +47,8 @@ export function Dashboard() { : active === "support" ? : active === "rules" ? : active === "ai" ? + : active === "messenger" ? + : active === "inbox" ? : active === "team" ? : } diff --git a/src/components/dashboard/inbox.tsx b/src/components/dashboard/inbox.tsx new file mode 100644 index 0000000..08dbb3d --- /dev/null +++ b/src/components/dashboard/inbox.tsx @@ -0,0 +1,85 @@ +"use client"; + +// ============================================================ +// Inbox — a personalized work/awareness feed IIOS projects from +// events (mentions, needs-reply, support updates, …), surfaced via +// the be-crm data door (crm.inbox.*). List + filter by state, and +// mark items done / snoozed / archived. Items are created by IIOS's +// projector, never here. Mock when the Shell isn't configured. +// ============================================================ + +import { useState } from "react"; +import { Btn, Icon, PageHead, Pill } from "./ui"; +import { useInboxData, type InboxState, type UiInboxItem } from "@/lib/inbox-api"; + +const KIND_LABEL: Record = { + 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); + + return ( + + + {!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} + ))} + + + + {inbox.loading && Loading…} + {!inbox.loading && inbox.items.length === 0 && ( + Nothing here — you're all caught up 🎉 + )} + {inbox.items.map((it) => ( + inbox.transition(it.id, "DONE")} + onSnooze={() => inbox.transition(it.id, "SNOOZED")} + onArchive={() => inbox.transition(it.id, "ARCHIVED")} + /> + ))} + + + ); +} + +function InboxRow({ it, onDone, onSnooze, onArchive }: { it: UiInboxItem; onDone: () => void; onSnooze: () => void; onArchive: () => void }) { + const isMention = it.kind === "MENTION"; + return ( + + + + + + + {KIND_LABEL[it.kind] ?? it.kind} + {it.title} + + {it.summary && {it.summary}} + + {it.state === "OPEN" ? ( + + Snooze + Done + Archive + + ) : ( + {it.state.toLowerCase()} + )} + + ); +} diff --git a/src/components/dashboard/messenger.tsx b/src/components/dashboard/messenger.tsx new file mode 100644 index 0000000..6f6469f --- /dev/null +++ b/src/components/dashboard/messenger.tsx @@ -0,0 +1,248 @@ +"use client"; + +// ============================================================ +// Messenger — internal team + client chat, powered by IIOS via +// the be-crm data door (crm.messenger.*). Conversation list ⇄ +// thread view + composer, with a "new chat" people picker that +// creates a DM (1 person) or group (2+). DM-vs-group and who-can- +// chat are enforced server-side by IIOS/OPA; this is just UI. +// Data comes from useMessengerData()/useThread(): local mock when +// the Shell isn't configured, live be-crm when it is. +// ============================================================ + +import { type CSSProperties, useEffect, useRef, useState } from "react"; +import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, useToast } from "./ui"; +import { useMessengerData, useThread, type Membership, type UiConversation, type UiPerson } from "@/lib/messenger-api"; +import { MessengerSocketProvider } from "@/lib/messenger-socket"; + +const initialsOf = (name: string) => + name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?"; +const timeOf = (iso?: string) => { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(+d) ? "" : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +}; +const GROUP_GRAD = "linear-gradient(135deg,#6366f1,#8b5cf6)"; +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", +}; + +export function Messenger() { + // One shared IIOS socket for the whole panel (live in Shell mode; no-op in mock). + return ( + + + + ); +} + +function MessengerPanel() { + const m = useMessengerData(); + const toast = useToast(); + const [selected, setSelected] = useState(null); + const [newOpen, setNewOpen] = useState(false); + + useEffect(() => { + if ((!selected || !m.conversations.some((c) => c.threadId === selected)) && m.conversations[0]) { + setSelected(m.conversations[0].threadId); + } + }, [m.conversations, selected]); + + const current = m.conversations.find((c) => c.threadId === selected) ?? null; + + return ( + + setNewOpen(true)}>New chat} + /> + {!m.live && ( + + Demo mode — running on mock data. It goes live once the Shell + be-crm are connected. + + )} + + + + + + {current ? ( + toast.push({ tone: "error", title: "Message failed", desc: msg })} /> + ) : ( + + + Select or start a conversation + + )} + + + + setNewOpen(false)} directory={m.directory} + onCreate={async (ids, opts) => { + try { + const id = await m.openConversation(ids, opts); + setSelected(id); + setNewOpen(false); + } catch (e) { + toast.push({ tone: "error", title: "Couldn't start chat", desc: (e as Error).message }); + } + }} + /> + + ); +} + +function ConversationRow({ c, active, onClick }: { c: UiConversation; active: boolean; onClick: () => void }) { + return ( + + + + + {c.title} + {c.membership === "group" && group} + + + {c.lastMessage ?? "No messages yet"} + + + {c.unread > 0 && ( + {c.unread} + )} + + ); +} + +function ThreadView({ conv, onError }: { conv: UiConversation; onError: (m: string) => void }) { + const t = useThread(conv.threadId); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const endRef = useRef(null); + + useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [t.messages.length]); + + async function submit() { + const text = draft.trim(); + if (!text || sending) return; + setDraft(""); setSending(true); + try { await t.send(text); } + catch (e) { setDraft(text); onError((e as Error).message); } + finally { setSending(false); } + } + + return ( + <> + + + + {conv.title} + + {conv.membership === "group" ? `${conv.participants.length} people` : "Direct message"} + + + + + + {t.loading && t.messages.length === 0 && Loading messages…} + {!t.loading && t.messages.length === 0 && No messages yet — say hello 👋} + {t.messages.map((msg) => ( + + + {msg.text} + + {timeOf(msg.at)} + + ))} + + + + + > + ); +} + +function NewChatModal({ + open, onClose, directory, onCreate, +}: { + open: boolean; onClose: () => void; directory: UiPerson[]; + onCreate: (ids: string[], opts: { membership: Membership; subject?: string }) => Promise; +}) { + const [picked, setPicked] = useState([]); + const [subject, setSubject] = useState(""); + const [q, setQ] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { if (!open) { setPicked([]); setSubject(""); setQ(""); setBusy(false); } }, [open]); + + const membership: Membership = picked.length > 1 ? "group" : "dm"; + const filtered = directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase())); + const toggle = (id: string) => setPicked((l) => (l.includes(id) ? l.filter((x) => x !== id) : [...l, id])); + + async function create() { + if (!picked.length || busy) return; + setBusy(true); + await onCreate(picked, { membership, ...(membership === "group" && subject.trim() ? { subject: subject.trim() } : {}) }); + setBusy(false); + } + + return ( + + Cancel + void create()} disabled={!picked.length || busy}>{busy ? "Starting…" : "Start chat"} + >} + > + setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 10 }} /> + {membership === "group" && ( + + setSubject(e.target.value)} placeholder="e.g. Storm response" style={inputStyle} /> + + )} + + {filtered.length === 0 && No people found.} + {filtered.map((p) => ( + + toggle(p.id)} /> + + {p.name} + {p.kind} + + ))} + + + ); +} diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index c818e60..52b4564 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -29,6 +29,13 @@ export const NAV_GROUPS: NavGroup[] = [ { key: "pipeline", label: "Pipeline", icon: "pipeline" }, ], }, + { + title: "Communication", + items: [ + { key: "messenger", label: "Messenger", icon: "send", subtitle: "Chat with your team and clients" }, + { key: "inbox", label: "Inbox", icon: "bell", subtitle: "Mentions, replies and updates for you" }, + ], + }, { title: "Workspace", items: [ @@ -72,7 +79,7 @@ export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items); // brand-new user with no membership); every other item requires membership, and the // items mapped here additionally require the given permission. Unmapped items are // shown to any member. This is UX only — be-crm still enforces every action. -const ALWAYS_VISIBLE = new Set(["dashboard", "profile"]); +const ALWAYS_VISIBLE = new Set(["dashboard", "profile", "messenger", "inbox"]); const NAV_PERMISSION: Record = { team: "team.manage", people: "team.manage", diff --git a/src/lib/inbox-api.ts b/src/lib/inbox-api.ts new file mode 100644 index 0000000..8ea701d --- /dev/null +++ b/src/lib/inbox-api.ts @@ -0,0 +1,57 @@ +"use client"; + +// Inbox data layer. The inbox is a personalized work/awareness feed IIOS projects from events +// (NEEDS_REPLY, MENTION, …). The CRM lists it and transitions item state; items are never created +// here. Mock when the Shell isn't configured; live via the be-crm data door (crm.inbox.*) otherwise. +// +// Live contract (be-crm): +// query crm.inbox.list { state? } -> InboxItem[] +// cmd crm.inbox.transition { id, state, reason? } -> InboxItem + +import { useCallback, useMemo, useState } from "react"; +import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react"; +import { isShellConfigured } from "./appshell"; + +export type InboxState = "OPEN" | "SNOOZED" | "DONE" | "ARCHIVED" | "CANCELLED" | "STALE"; +export interface UiInboxItem { + id: string; kind: string; state: InboxState; title: string; summary?: string; + priority: string; threadId?: string; createdAt: string; +} + +const SHELL = isShellConfigured(); + +export interface InboxData { + live: boolean; loading: boolean; error: string | null; + items: UiInboxItem[]; + transition: (id: string, state: InboxState) => Promise; + refetch: () => void; +} + +export function useInboxData(state?: InboxState): InboxData { + return SHELL ? useLiveInbox(state) : useMockInbox(state); +} + +function useLiveInbox(state?: InboxState): InboxData { + const { sdk } = useAppShell(); + const q = useQuery("crm.inbox.list", state ? { state } : {}); + 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 }; +} + +const MOCK_ITEMS: UiInboxItem[] = [ + { id: "in_1", kind: "MENTION", state: "OPEN", title: "Sofia mentioned you", summary: "@you — can you confirm the Henderson scope?", priority: "HIGH", threadId: "th_mock_1", createdAt: new Date().toISOString() }, + { id: "in_2", kind: "NEEDS_REPLY", state: "OPEN", title: "Reply needed — Storm response", summary: "Dan: Crew is rolling out at 7.", priority: "MEDIUM", threadId: "th_mock_2", createdAt: new Date().toISOString() }, + { id: "in_3", kind: "SUPPORT_UPDATE", state: "OPEN", title: "Ticket TK-204 updated", summary: "Customer replied on the roof-leak case.", priority: "LOW", createdAt: new Date().toISOString() }, +]; + +function useMockInbox(state?: InboxState): InboxData { + const [items, setItems] = useState(MOCK_ITEMS); + const filtered = useMemo(() => (state ? items.filter((i) => i.state === state) : items), [items, state]); + const transition = useCallback(async (id: string, next: InboxState) => { + setItems((l) => l.map((i) => (i.id === id ? { ...i, state: next } : i))); + }, []); + return { live: false, loading: false, error: null, items: filtered, transition, refetch: () => {} }; +} diff --git a/src/lib/messenger-api.ts b/src/lib/messenger-api.ts new file mode 100644 index 0000000..3a2a666 --- /dev/null +++ b/src/lib/messenger-api.ts @@ -0,0 +1,238 @@ +"use client"; + +// Messenger data layer. Serves EITHER a local mock (when the Shell isn't configured — the demo +// keeps working) OR the live be-crm data door (crm.messenger.*), behind one interface so the UI is +// mode-agnostic. DM-vs-group + who-can-chat are enforced server-side by IIOS/OPA; this is just glue. +// +// Live contract (be-crm): +// query crm.messenger.directory { kind, query?, limit } -> DirectoryEntry[] +// query crm.messenger.conversation.list {} -> ConversationSummary[] +// cmd crm.messenger.conversation.open { participantIds[], membership?, subject? } -> { threadId, ... } +// query crm.messenger.history { threadId } -> MessengerMessage[] +// cmd crm.messenger.send { threadId, content } -> MessengerMessage +// cmd crm.messenger.participant.add { threadId, userId } +// +// v1 uses REST + polling for the live stream; v2 swaps in the IIOS MessageSocket (see messenger-socket.ts). + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react"; +import { isShellConfigured } from "./appshell"; +import { useMessengerSocket } from "./messenger-socket"; + +export type Membership = "dm" | "group"; +export interface UiPerson { id: string; name: string; kind: "staff" | "customer" } +export interface UiConversation { + threadId: string; title: string; subject: string | null; membership: Membership | null; + participants: string[]; unread: number; lastMessage?: string; lastAt?: string; +} +export interface UiMessage { id: string; actorId: string | null; text: string; at: string; mine: boolean } + +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 SHELL = isShellConfigured(); +const POLL_MS = 4000; + +const shortId = (id: string) => id.replace(/^(pp_|cust_)/, "").slice(0, 6); + +/* ======================================================================== */ +/* Public hooks */ +/* ======================================================================== */ + +export interface MessengerData { + live: boolean; loading: boolean; error: string | null; + directory: UiPerson[]; + conversations: UiConversation[]; + nameOf: (id: string) => string; + openConversation: (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => Promise; + refetch: () => void; +} + +export interface ThreadData { + loading: boolean; error: string | null; + messages: UiMessage[]; + send: (content: string) => Promise; + refetch: () => void; +} + +export function useMessengerData(): MessengerData { + return SHELL ? useLiveMessenger() : useMockMessenger(); +} +export function useThread(threadId: string): ThreadData { + return SHELL ? useLiveThread(threadId) : useMockThread(threadId); +} + +/* ======================================================================== */ +/* Live implementation (be-crm data door) */ +/* ======================================================================== */ + +function useLiveMessenger(): MessengerData { + const { sdk } = useAppShell(); + const dirQ = useQuery("crm.messenger.directory", { kind: "all", limit: 100 }); + const convQ = useQuery("crm.messenger.conversation.list", {}); + + const directory: UiPerson[] = useMemo( + () => (dirQ.data ?? []).map((d) => ({ id: d.id, name: d.displayName, kind: d.kind })), + [dirQ.data], + ); + const nameById = useMemo(() => Object.fromEntries(directory.map((p) => [p.id, p.name])), [directory]); + const nameOf = useCallback((id: string) => nameById[id] ?? `User ${shortId(id)}`, [nameById]); + + const conversations: UiConversation[] = useMemo( + () => (convQ.data ?? []).map((c) => shape(c, nameOf)), + [convQ.data, nameOf], + ); + + const refetch = useCallback(() => { dirQ.refetch(); convQ.refetch(); }, [dirQ, convQ]); + const openConversation = useCallback(async (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => { + const res = (await sdk.command("crm.messenger.conversation.open", { + participantIds, ...(opts?.membership ? { membership: opts.membership } : {}), ...(opts?.subject ? { subject: opts.subject } : {}), + })) as { threadId: string }; + convQ.refetch(); + return res.threadId; + }, [sdk, convQ]); + + return { + live: true, + loading: dirQ.loading || convQ.loading, + error: (dirQ.error ?? convQ.error)?.message ?? null, + directory, conversations, nameOf, openConversation, refetch, + }; +} + +function useLiveThread(threadId: string): ThreadData { + const { sdk } = useAppShell(); + const socket = useMessengerSocket(); + const socketReady = socket?.ready ?? false; + const q = useQuery("crm.messenger.history", { threadId }); + const [socketMsgs, setSocketMsgs] = useState([]); + const [myActorId, setMyActorId] = useState(null); + + // REST poll — the fallback whenever the live socket isn't connected. + const refetchRef = useRef(q.refetch); + refetchRef.current = q.refetch; + useEffect(() => { + if (socketReady) return; + const t = setInterval(() => refetchRef.current(), POLL_MS); + return () => clearInterval(t); + }, [socketReady, threadId]); + + // Socket (primary): load history + subscribe to live messages once connected. + useEffect(() => { + if (!socket || !socketReady) return; + let alive = true; + setSocketMsgs([]); + void socket.openThread(threadId).then((hist) => { if (alive) setSocketMsgs(hist); }).catch(() => {}); + const unsub = socket.subscribe(threadId, (m) => setSocketMsgs((l) => (l.some((x) => x.id === m.id) ? l : [...l, m]))); + return () => { alive = false; unsub(); }; + }, [socket, socketReady, threadId]); + + const restMsgs: UiMessage[] = useMemo( + () => (q.data ?? []).map((m) => ({ + id: m.interactionId, actorId: m.actorId, text: m.text ?? "", at: m.occurredAt, + mine: !!myActorId && m.actorId === myActorId, + })), + [q.data, myActorId], + ); + + const messages = socketReady ? socketMsgs : restMsgs; + + const send = useCallback(async (content: string) => { + if (socket && socketReady) { + await socket.send(threadId, content); // echoes back over the socket as a 'message' event + } else { + const m = (await sdk.command("crm.messenger.send", { threadId, content })) as MessageDTO; + if (m.actorId) setMyActorId(m.actorId); + q.refetch(); + } + }, [socket, socketReady, threadId, sdk, q]); + + return { loading: q.loading && !socketReady, error: q.error?.message ?? null, messages, send, refetch: q.refetch }; +} + +function shape(c: ConversationDTO, nameOf: (id: string) => string): UiConversation { + const title = c.subject?.trim() + || (c.membership === "group" + ? "Group" + : c.participants.map(nameOf).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 } : {}) }; +} + +/* ======================================================================== */ +/* Mock implementation (no Shell configured — the demo keeps working) */ +/* ======================================================================== */ + +const MOCK_PEOPLE: UiPerson[] = [ + { id: "pp_sofia", name: "Sofia Ramirez", kind: "staff" }, + { id: "pp_dan", name: "Dan Whitaker", kind: "staff" }, + { id: "pp_priya", name: "Priya Nair", kind: "staff" }, + { id: "cust_acme", name: "Acme Roofing (Client)", kind: "customer" }, + { id: "cust_globex", name: "Globex Homes (Client)", kind: "customer" }, +]; + +interface MockThread { threadId: string; membership: Membership; subject: string | null; participants: string[]; messages: UiMessage[] } +const now = () => new Date().toISOString(); +let MOCK_SEQ = 100; + +// A tiny module-level store both mock hooks share, with a subscribe-on-change so the +// conversation list and the open thread stay in sync (no globalThis, no render writes). +const MOCK_STORE = new Map([ + ["th_mock_1", { threadId: "th_mock_1", membership: "dm", subject: null, participants: ["me", "pp_sofia"], + messages: [{ id: "m1", actorId: "pp_sofia", text: "Can you review the Henderson estimate?", at: now(), mine: false }] }], + ["th_mock_2", { threadId: "th_mock_2", membership: "group", subject: "Storm response — East side", participants: ["me", "pp_dan", "pp_priya"], + messages: [{ id: "m2", actorId: "pp_dan", text: "Crew is rolling out at 7.", at: now(), mine: false }] }], +]); +const mockListeners = new Set<() => void>(); +const notifyMock = () => mockListeners.forEach((l) => l()); +function useMockSubscription(): void { + const [, setV] = useState(0); + useEffect(() => { + const l = () => setV((n) => n + 1); + mockListeners.add(l); + return () => { mockListeners.delete(l); }; + }, []); +} + +function useMockMessenger(): MessengerData { + useMockSubscription(); + const nameById = useMemo(() => Object.fromEntries(MOCK_PEOPLE.map((p) => [p.id, p.name])), []); + const nameOf = useCallback((id: string) => nameById[id] ?? `User ${shortId(id)}`, [nameById]); + + const conversations: UiConversation[] = [...MOCK_STORE.values()].map((t) => { + const last = t.messages[t.messages.length - 1]; + return { + threadId: t.threadId, + title: t.subject || t.participants.filter((p) => p !== "me").map(nameOf).join(", ") || "Conversation", + subject: t.subject, membership: t.membership, participants: t.participants, unread: 0, + ...(last ? { lastMessage: last.text, lastAt: last.at } : {}), + }; + }); + + const openConversation = useCallback(async (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => { + const membership = opts?.membership ?? (participantIds.length === 1 ? "dm" : "group"); + const threadId = `th_mock_${MOCK_SEQ++}`; + MOCK_STORE.set(threadId, { threadId, membership, subject: opts?.subject ?? null, participants: ["me", ...participantIds], messages: [] }); + notifyMock(); + return threadId; + }, []); + + return { live: false, loading: false, error: null, directory: MOCK_PEOPLE, conversations, nameOf, openConversation, refetch: () => {} }; +} + +function useMockThread(threadId: string): ThreadData { + useMockSubscription(); + const thread = MOCK_STORE.get(threadId); + const send = useCallback(async (content: string) => { + const t = MOCK_STORE.get(threadId); + if (t) { + t.messages = [...t.messages, { id: `m_${MOCK_SEQ++}`, actorId: "me", text: content, at: now(), mine: true }]; + notifyMock(); + } + }, [threadId]); + return { loading: false, error: null, messages: thread?.messages ?? [], send, refetch: notifyMock }; +} diff --git a/src/lib/messenger-socket.tsx b/src/lib/messenger-socket.tsx new file mode 100644 index 0000000..f7dd13f --- /dev/null +++ b/src/lib/messenger-socket.tsx @@ -0,0 +1,86 @@ +"use client"; + +// v2 live stream: one IIOS MessageSocket for the whole Messenger panel, using the SDK +// (@insignia/iios-kernel-client) — not raw socket.io. The delegated realtime token comes from +// the be-crm data door (crm.messenger.realtime). Threads subscribe through a context; the socket +// re-opens every joined thread on reconnect (handled inside the SDK). In mock mode this is a no-op +// passthrough and the thread hook falls back to the REST poll. + +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { MessageSocket, type Message } from "@insignia/iios-kernel-client"; +import { useAuth, useQuery } from "@abe-kap/appshell-sdk/react"; +import { isShellConfigured } from "./appshell"; +import type { UiMessage } from "./messenger-api"; + +interface RealtimeDTO { url: string; audience: string; token?: string } + +export interface MessengerSocket { + ready: boolean; + openThread: (threadId: string) => Promise; + send: (threadId: string, content: string) => Promise; + subscribe: (threadId: string, cb: (m: UiMessage) => void) => () => void; +} + +const Ctx = createContext(null); +export function useMessengerSocket(): MessengerSocket | null { return useContext(Ctx); } + +const SHELL = isShellConfigured(); + +const toUi = (m: Message, myUserId?: string): UiMessage => ({ + id: m.id, actorId: m.senderActorId ?? null, text: m.content ?? "", at: m.createdAt, + mine: !!myUserId && m.senderId === myUserId, +}); + +export function MessengerSocketProvider({ children }: { children: ReactNode }) { + // SHELL is a build-time constant, so the branch is stable across renders (Rules-of-Hooks safe). + if (!SHELL) return <>{children}>; + return {children}; +} + +function LiveSocketProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const rt = useQuery("crm.messenger.realtime", {}); + const [ready, setReady] = useState(false); + const socketRef = useRef(null); + const listeners = useRef void>>>(new Map()); + const myRef = useRef(user?.id); + myRef.current = user?.id; + + const url = rt.data?.url; + const token = rt.data?.token; + + useEffect(() => { + if (!url || !token) return; + const socket = new MessageSocket({ serviceUrl: url, token, autoConnect: false }); + socketRef.current = socket; + const offConnected = socket.onConnected(() => setReady(true)); + const offMessage = socket.on("message", (m) => { + const cbs = listeners.current.get(m.threadId); + if (cbs && cbs.size) { const ui = toUi(m, myRef.current); cbs.forEach((cb) => cb(ui)); } + }); + socket.connect(); + return () => { offConnected(); offMessage(); socket.disconnect(); socketRef.current = null; setReady(false); }; + }, [url, token]); + + const openThread = useCallback(async (threadId: string): Promise => { + const s = socketRef.current; + if (!s) return []; + const res = await s.openThread(threadId); + return res.history.map((m) => toUi(m, myRef.current)); + }, []); + + const send = useCallback(async (threadId: string, content: string) => { + const s = socketRef.current; + if (!s) throw new Error("Not connected"); + await s.sendMessage(threadId, content); + }, []); + + const subscribe = useCallback((threadId: string, cb: (m: UiMessage) => void) => { + const map = listeners.current; + if (!map.has(threadId)) map.set(threadId, new Set()); + map.get(threadId)!.add(cb); + return () => { map.get(threadId)?.delete(cb); }; + }, []); + + return {children}; +}
Select or start a conversation