import { useState, useEffect, useCallback } from "react"; import { 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, fetchAgents, type InboxResponse, type OutboxResponse, type AgentMailboxResponse, type Agent, } from "../api"; import { MessageComposer } from "./MessageComposer"; // ── Types ───────────────────────────────────────────────────────────────── type MailboxTab = "inbox" | "outbox" | "agents"; interface MailboxViewProps { projectId?: string; addToast?: (msg: string, type?: "success" | "error") => void; /** Callback when unread count changes (for header badge updates) */ onUnreadCountChange?: (count: number) => void; } /** Represents a grouped conversation in the inbox */ interface ConversationGroup { /** Unique key combining fromId and fromType */ key: string; fromId: string; fromType: ParticipantType; /** Latest message in the conversation */ latestMessage: Message; /** All messages in this conversation */ messages: Message[]; /** Count of unread messages in this conversation */ unreadCount: number; } // ── 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"; } } /** Groups messages by conversation (sender) key */ function groupMessagesByConversation(messages: Message[]): ConversationGroup[] { const groups = new Map(); for (const msg of messages) { const key = `${msg.fromType}:${msg.fromId}`; const existing = groups.get(key); if (existing) { existing.messages.push(msg); // Track latest by timestamp if (new Date(msg.createdAt) > new Date(existing.latestMessage.createdAt)) { existing.latestMessage = msg; } // Update unread count if (!msg.read) { existing.unreadCount++; } } else { groups.set(key, { key, fromId: msg.fromId, fromType: msg.fromType, latestMessage: msg, messages: [msg], unreadCount: msg.read ? 0 : 1, }); } } // Sort by latest message timestamp, newest first return Array.from(groups.values()).sort( (a, b) => new Date(b.latestMessage.createdAt).getTime() - new Date(a.latestMessage.createdAt).getTime() ); } // ── Component ───────────────────────────────────────────────────────────── export function MailboxView({ projectId, addToast, onUnreadCountChange, }: MailboxViewProps) { 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); const [agents, setAgents] = useState([]); // ── Data fetching ───────────────────────────────────────────────────── const loadInbox = useCallback(async () => { setIsLoading(true); try { const data = await fetchInbox({ limit: 50 }, projectId); setInbox(data); setUnreadCount(data.unreadCount); onUnreadCountChange?.(data.unreadCount); } catch { // Silently fail — empty state will show } finally { setIsLoading(false); } }, [projectId, onUnreadCountChange]); 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 loadAgents = useCallback(async () => { try { const data = await fetchAgents(undefined, projectId); setAgents(data); } catch { // Silently fail } }, [projectId]); const refreshUnreadCount = useCallback(async () => { try { const data = await fetchUnreadCount(projectId); setUnreadCount(data.unreadCount); onUnreadCountChange?.(data.unreadCount); } catch { // Silently fail } }, [projectId, onUnreadCountChange]); // Load data on tab change useEffect(() => { if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); else if (activeTab === "agents") loadAgents(); }, [activeTab, loadInbox, loadOutbox, loadAgents]); // Load agent mailbox when selected useEffect(() => { if (!selectedAgentId) return; loadAgentMailbox(selectedAgentId); }, [selectedAgentId, loadAgentMailbox]); // Load unread count on mount useEffect(() => { refreshUnreadCount(); }, [refreshUnreadCount]); // Load agents on mount so they're available for compose from any tab (not just agents tab) useEffect(() => { loadAgents(); }, [loadAgents]); // Subscribe to mailbox SSE events for near-real-time refresh. useEffect(() => { if (typeof EventSource === "undefined") { return; } const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const eventSource = new EventSource(`/api/events${query}`); const onMailboxUpdate = () => { void refreshUnreadCount(); if (activeTab === "inbox") { void loadInbox(); } else if (activeTab === "outbox") { void loadOutbox(); } if (selectedAgentId) { void loadAgentMailbox(selectedAgentId); } }; eventSource.addEventListener("message:sent", onMailboxUpdate); eventSource.addEventListener("message:received", onMailboxUpdate); eventSource.addEventListener("message:read", onMailboxUpdate); eventSource.addEventListener("message:deleted", onMailboxUpdate); return () => { eventSource.removeEventListener("message:sent", onMailboxUpdate); eventSource.removeEventListener("message:received", onMailboxUpdate); eventSource.removeEventListener("message:read", onMailboxUpdate); eventSource.removeEventListener("message:deleted", onMailboxUpdate); eventSource.close(); }; }, [projectId, activeTab, selectedAgentId, refreshUnreadCount, loadInbox, loadOutbox, loadAgentMailbox]); // ── 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 if (updated) { setInbox((prev) => prev ? { ...prev, messages: prev.messages.map((m) => (m.id === updated.id ? updated : m)), unreadCount: Math.max(0, prev.unreadCount - 1), } : prev, ); } const newCount = Math.max(0, unreadCount - 1); setUnreadCount(newCount); onUnreadCountChange?.(newCount); } catch { // Non-critical } } // Load conversation thread try { const conv = await fetchConversation(message.fromId, message.fromType, projectId); setConversationMessages(conv); } catch { setConversationMessages([message]); } }, [projectId, unreadCount, onUnreadCountChange]); const handleCloseMessage = useCallback(() => { setSelectedMessage(null); setConversationMessages([]); }, []); const handleMarkAllRead = useCallback(async () => { try { const result = await markAllMessagesRead(projectId); setUnreadCount(0); onUnreadCountChange?.(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, onUnreadCountChange]); 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); refreshUnreadCount(); }, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, addToast, refreshUnreadCount]); 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); }, []); // ── Render ──────────────────────────────────────────────────────────── return (
{/* 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 - Grouped by conversation */} {activeTab === "inbox" && (
{isLoading && !inbox && } {inbox && inbox.messages.length === 0 && (

No messages in your inbox

)} {inbox && inbox.messages.length > 0 && (
{groupMessagesByConversation(inbox.messages).map((group) => (
0 ? "unread" : ""}`} onClick={() => handleOpenMessage(group.latestMessage)} data-testid={`mailbox-conversation-${group.key}`} >
{group.fromType === "agent" ? : }
{participantLabel(group.fromId, group.fromType)} {formatTimestamp(group.latestMessage.createdAt)}
{group.latestMessage.content.slice(0, 80)} {group.latestMessage.content.length > 80 ? "…" : ""}
{group.unreadCount > 0 && (
{group.unreadCount > 9 ? "9+" : group.unreadCount}
)}
))}
)}
)} {/* 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) => (
))}
); }