import { useState, useEffect, useCallback } from "react"; import { X, Mail, Send, Inbox as InboxIcon, Bot, Trash2, CheckCheck, Loader2, RefreshCw, MessageSquare, User, } from "lucide-react"; import type { Message, MessageType, ParticipantType } from "@fusion/core"; import { fetchInbox, fetchOutbox, fetchUnreadCount, fetchAgentMailbox, markMessageRead, markAllMessagesRead, deleteMessage, fetchConversation, type InboxResponse, type OutboxResponse, type AgentMailboxResponse, } from "../api"; import { MessageComposer } from "./MessageComposer"; import type { Agent } from "../api"; // ── Types ───────────────────────────────────────────────────────────────── type MailboxTab = "inbox" | "outbox" | "agents"; interface MailboxModalProps { isOpen: boolean; onClose: () => void; projectId?: string; addToast?: (msg: string, type?: "success" | "error") => void; agents?: Agent[]; } // ── Helpers ─────────────────────────────────────────────────────────────── function formatTimestamp(ts: string): string { const date = new Date(ts); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return "Just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } function participantLabel(id: string, type: ParticipantType): string { if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`; if (type === "agent") return `Agent: ${id}`; return "System"; } function messageTypeLabel(type: MessageType): string { switch (type) { case "agent-to-agent": return "Agent ↔ Agent"; case "agent-to-user": return "Agent → You"; case "user-to-agent": return "You → Agent"; case "system": return "System"; } } // ── Component ───────────────────────────────────────────────────────────── export function MailboxModal({ isOpen, onClose, projectId, addToast, agents = [], }: MailboxModalProps) { const [activeTab, setActiveTab] = useState("inbox"); const [inbox, setInbox] = useState(null); const [outbox, setOutbox] = useState(null); const [unreadCount, setUnreadCount] = useState(0); const [isLoading, setIsLoading] = useState(false); const [selectedMessage, setSelectedMessage] = useState(null); const [conversationMessages, setConversationMessages] = useState([]); const [showComposer, setShowComposer] = useState(false); const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null); const [selectedAgentId, setSelectedAgentId] = useState(null); const [agentMailbox, setAgentMailbox] = useState(null); // ── Data fetching ───────────────────────────────────────────────────── const loadInbox = useCallback(async () => { setIsLoading(true); try { const data = await fetchInbox({ limit: 50 }, projectId); setInbox(data); setUnreadCount(data.unreadCount); } catch { // Silently fail — empty state will show } finally { setIsLoading(false); } }, [projectId]); const loadOutbox = useCallback(async () => { setIsLoading(true); try { const data = await fetchOutbox({ limit: 50 }, projectId); setOutbox(data); } catch { // Silently fail } finally { setIsLoading(false); } }, [projectId]); const loadAgentMailbox = useCallback(async (agentId: string) => { setIsLoading(true); try { const data = await fetchAgentMailbox(agentId, projectId); setAgentMailbox(data); } catch { // Silently fail } finally { setIsLoading(false); } }, [projectId]); const refreshUnreadCount = useCallback(async () => { try { const data = await fetchUnreadCount(projectId); setUnreadCount(data.unreadCount); } catch { // Silently fail } }, [projectId]); // Load data on tab change useEffect(() => { if (!isOpen) return; if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); }, [isOpen, activeTab, loadInbox, loadOutbox]); // Load agent mailbox when selected useEffect(() => { if (!isOpen || !selectedAgentId) return; loadAgentMailbox(selectedAgentId); }, [isOpen, selectedAgentId, loadAgentMailbox]); // Refresh unread count on open useEffect(() => { if (isOpen) refreshUnreadCount(); }, [isOpen, refreshUnreadCount]); // ── Actions ─────────────────────────────────────────────────────────── const handleOpenMessage = useCallback(async (message: Message) => { setSelectedMessage(message); // Mark as read if unread if (!message.read) { try { const updated = await markMessageRead(message.id, projectId); // Update inbox state setInbox((prev) => prev ? { ...prev, messages: prev.messages.map((m) => (m.id === updated.id ? updated : m)), unreadCount: Math.max(0, prev.unreadCount - 1), } : prev, ); setUnreadCount((c) => Math.max(0, c - 1)); } catch { // Non-critical } } // Load conversation thread try { const conv = await fetchConversation(message.fromId, message.fromType, projectId); setConversationMessages(conv); } catch { setConversationMessages([message]); } }, [projectId]); const handleCloseMessage = useCallback(() => { setSelectedMessage(null); setConversationMessages([]); }, []); const handleMarkAllRead = useCallback(async () => { try { const result = await markAllMessagesRead(projectId); setUnreadCount(0); setInbox((prev) => prev ? { ...prev, messages: prev.messages.map((m) => ({ ...m, read: true })), unreadCount: 0, } : prev, ); addToast?.(`Marked ${result.markedAsRead} messages as read`, "success"); } catch { addToast?.("Failed to mark messages as read", "error"); } }, [projectId, addToast]); const handleDeleteMessage = useCallback(async (id: string) => { try { await deleteMessage(id, projectId); setSelectedMessage(null); setConversationMessages([]); // Refresh current tab if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); else if (selectedAgentId) loadAgentMailbox(selectedAgentId); addToast?.("Message deleted", "success"); } catch { addToast?.("Failed to delete message", "error"); } }, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, addToast]); const handleReply = useCallback((message: Message) => { setComposeRecipient({ id: message.fromId, type: message.fromType }); setShowComposer(true); }, []); const handleMessageSent = useCallback(() => { setShowComposer(false); setComposeRecipient(null); addToast?.("Message sent", "success"); // Refresh current tab if (activeTab === "outbox") loadOutbox(); else if (activeTab === "agents" && selectedAgentId) loadAgentMailbox(selectedAgentId); }, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, addToast]); const handleOpenCompose = useCallback(() => { // Pre-fill recipient from selected agent if available if (activeTab === "agents" && selectedAgentId) { setComposeRecipient({ id: selectedAgentId, type: "agent" }); } else { setComposeRecipient(null); } setShowComposer(true); }, [activeTab, selectedAgentId]); const handleComposeCancel = useCallback(() => { setShowComposer(false); setComposeRecipient(null); }, []); if (!isOpen) return null; // ── Render ──────────────────────────────────────────────────────────── return (
{ if (e.target === e.currentTarget) onClose(); }} data-testid="mailbox-modal-overlay" >
{/* Header */}
Mailbox {unreadCount > 0 && ( {unreadCount} )}
{activeTab === "inbox" && unreadCount > 0 && ( )}
{/* Tabs */}
{/* Content */}
{/* Message Detail View */} {selectedMessage && !showComposer && (
{messageTypeLabel(selectedMessage.type)} {formatTimestamp(selectedMessage.createdAt)}
{selectedMessage.fromType === "agent" && ( )}
From: {selectedMessage.fromType === "agent" ? : } {participantLabel(selectedMessage.fromId, selectedMessage.fromType)}
To: {selectedMessage.toType === "agent" ? : } {participantLabel(selectedMessage.toId, selectedMessage.toType)}
{/* Conversation thread */} {conversationMessages.length > 1 && (
Conversation
{conversationMessages.map((msg) => (
{participantLabel(msg.fromId, msg.fromType)} {formatTimestamp(msg.createdAt)}
{msg.content}
))}
)} {/* Full message content */} {(conversationMessages.length <= 1) && (
{selectedMessage.content}
)}
)} {/* Message Composer */} {showComposer && ( )} {/* Tab Content — message lists */} {!selectedMessage && !showComposer && ( <> {/* Inbox Tab */} {activeTab === "inbox" && (
{isLoading && !inbox && } {inbox && inbox.messages.length === 0 && (

No messages in your inbox

)} {inbox?.messages.map((msg) => (
handleOpenMessage(msg)} data-testid={`mailbox-item-${msg.id}`} >
{msg.fromType === "agent" ? : }
{participantLabel(msg.fromId, msg.fromType)} {formatTimestamp(msg.createdAt)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
{!msg.read &&
}
))}
)} {/* Outbox Tab */} {activeTab === "outbox" && (
{isLoading && !outbox && } {outbox && outbox.messages.length === 0 && (

No sent messages

)} {outbox?.messages.map((msg) => (
handleOpenMessage(msg)} data-testid={`mailbox-item-${msg.id}`} >
{msg.toType === "agent" ? : }
To: {participantLabel(msg.toId, msg.toType)} {formatTimestamp(msg.createdAt)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
))}
)} {/* Agent Mailboxes Tab */} {activeTab === "agents" && (
{agents.length === 0 ? (

No agents found

) : ( <>
{!selectedAgentId && (

Select an agent to view their mailbox

)} {selectedAgentId && isLoading && !agentMailbox && } {agentMailbox && agentMailbox.messages.length === 0 && (

No messages for this agent

)} {agentMailbox?.messages.map((msg) => (
handleOpenMessage(msg)} data-testid={`mailbox-item-${msg.id}`} >
{msg.fromType === "agent" ? : }
{msg.fromType === "agent" ? participantLabel(msg.toId, msg.toType) : participantLabel(msg.fromId, msg.fromType)} {formatTimestamp(msg.createdAt)}
{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}
))}
)}
)} )}
{/* Compose FAB (only when viewing inbox/outbox, not in detail view or agents tab) */} {!selectedMessage && !showComposer && activeTab !== "agents" && ( )}
); } // ── Skeleton ────────────────────────────────────────────────────────────── function MailboxSkeleton() { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
); }