"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. // Live messages, typing, read receipts and reactions come over the // IIOS socket (Shell mode); mock keeps the demo working offline. // ============================================================ import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"; import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, useToast } from "./ui"; import { useMessengerData, useThread, type Membership, type UiConversation, type UiMessage, type UiPerson } from "@/lib/messenger-api"; import { MessengerSocketProvider, useMessengerSocket } 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 REACTION_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🎉"]; 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 ( ); } function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (id: string) => string; onError: (m: string) => void }) { const t = useThread(conv.threadId); const socket = useMessengerSocket(); const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); const [replyTo, setReplyTo] = useState(null); const endRef = useRef(null); const typingSentAt = useRef(0); useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [t.messages.length]); const byId = useMemo(() => Object.fromEntries(t.messages.map((m) => [m.id, m])), [t.messages]); const lastMineId = useMemo(() => [...t.messages].reverse().find((m) => m.mine)?.id ?? null, [t.messages]); function onDraftChange(v: string) { setDraft(v); const now = Date.now(); if (socket && now - typingSentAt.current > 2000) { socket.sendTyping(conv.threadId); typingSentAt.current = now; } } async function submit() { const text = draft.trim(); if (!text || sending) return; const parent = replyTo?.id; setDraft(""); setReplyTo(null); setSending(true); try { await t.send(text, parent ? { parentInteractionId: parent } : undefined); } catch (e) { setDraft(text); onError((e as Error).message); } finally { setSending(false); } } const typingLabel = t.typingUserIds.length === 1 ? `${nameOf(t.typingUserIds[0])} is typing…` : t.typingUserIds.length > 1 ? "Several people are typing…" : ""; 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) => ( t.react(msg.id, emoji)} onReply={() => setReplyTo(msg)} /> ))}
{typingLabel}
{replyTo && (
Replying to {replyTo.mine ? "yourself" : nameOf(replyTo.senderId ?? "")}
{replyTo.text}
)}
onDraftChange(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }} placeholder="Type a message…" style={inputStyle} /> void submit()} disabled={sending || !draft.trim()}>Send
); } function MessageBubble({ msg, parent, seen, showStatus, onReact, onReply, }: { msg: UiMessage; parent?: UiMessage; seen: boolean; showStatus: boolean; onReact: (emoji: string) => void; onReply: () => void; }) { const [hover, setHover] = useState(false); const [picker, setPicker] = useState(false); return (
setHover(true)} onMouseLeave={() => { setHover(false); setPicker(false); }} style={{ alignSelf: msg.mine ? "flex-end" : "flex-start", maxWidth: "72%", display: "flex", flexDirection: "column", alignItems: msg.mine ? "flex-end" : "flex-start", position: "relative" }} > {parent && (
↩ {parent.text}
)}
{msg.text}
{hover && (
{picker && (
{REACTION_EMOJIS.map((e) => ( ))}
)}
)}
{msg.reactions && msg.reactions.length > 0 && (
{msg.reactions.map((r) => ( ))}
)}
{timeOf(msg.at)}{showStatus && msg.mine ? ` · ${seen ? "Seen" : "Sent"}` : ""}
); } const actionBtnStyle: CSSProperties = { background: "var(--panel-2)", border: "1px solid var(--border)", borderRadius: 8, width: 26, height: 26, display: "grid", placeItems: "center", cursor: "pointer", fontSize: 13, color: "var(--text)", padding: 0, }; 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) => ( ))}
); }