"use client"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { createClient } from "@/lib/supabase/client"; import { cn } from "@/lib/utils"; import type { Conversation, Message, Contact, ConversationStatus } from "@/types"; import { MessageSquare, ChevronDown, UserPlus, Clock, } from "lucide-react"; import { format, isToday, isYesterday, differenceInHours } from "date-fns"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; import { MessageBubble } from "./message-bubble"; import { MessageComposer } from "./message-composer"; import { toast } from "sonner"; interface MessageThreadProps { conversation: Conversation | null; contact: Contact | null; messages: Message[]; onMessagesLoaded: (messages: Message[]) => void; onNewMessage: (message: Message) => void; onUpdateMessage: (id: string, updates: Partial) => void; onStatusChange: (conversationId: string, status: ConversationStatus) => void; } function formatDateSeparator(dateStr: string): string { const date = new Date(dateStr); if (isToday(date)) return "Today"; if (isYesterday(date)) return "Yesterday"; return format(date, "MMMM d, yyyy"); } function groupMessagesByDate(messages: Message[]) { const groups: { date: string; messages: Message[] }[] = []; let currentDate = ""; for (const msg of messages) { const day = format(new Date(msg.created_at), "yyyy-MM-dd"); if (day !== currentDate) { currentDate = day; groups.push({ date: msg.created_at, messages: [msg] }); } else { groups[groups.length - 1].messages.push(msg); } } return groups; } const STATUS_OPTIONS: { label: string; value: ConversationStatus; color: string }[] = [ { label: "Open", value: "open", color: "text-emerald-400" }, { label: "Pending", value: "pending", color: "text-amber-400" }, { label: "Closed", value: "closed", color: "text-slate-400" }, ]; export function MessageThread({ conversation, contact, messages, onMessagesLoaded, onNewMessage, onUpdateMessage, onStatusChange, }: MessageThreadProps) { const [loading, setLoading] = useState(false); const scrollRef = useRef(null); const [templateModalOpen, setTemplateModalOpen] = useState(false); // 24-hour session timer const sessionInfo = useMemo(() => { if (!messages.length) return { expired: false, remaining: "" }; // Find last customer message const lastCustomerMsg = [...messages] .reverse() .find((m) => m.sender_type === "customer"); if (!lastCustomerMsg) return { expired: true, remaining: "No customer messages" }; const hoursSince = differenceInHours(new Date(), new Date(lastCustomerMsg.created_at)); const expired = hoursSince >= 24; if (expired) { return { expired: true, remaining: "Expired" }; } const hoursLeft = 24 - hoursSince; const remaining = hoursLeft >= 1 ? `${Math.floor(hoursLeft)}h remaining` : `${Math.floor(hoursLeft * 60)}m remaining`; return { expired, remaining }; }, [messages]); // Store latest callback in a ref so fetchMessages doesn't need to // depend on `onMessagesLoaded` — otherwise parent re-renders cause // fetchMessages to change → useEffect re-fires → refetch → realtime // UPDATE on conversations.unread_count → parent re-renders → LOOP. const onMessagesLoadedRef = useRef(onMessagesLoaded); onMessagesLoadedRef.current = onMessagesLoaded; const conversationId = conversation?.id; const hasUnread = (conversation?.unread_count ?? 0) > 0; useEffect(() => { if (!conversationId) return; const supabase = createClient(); let cancelled = false; (async () => { setLoading(true); const { data, error } = await supabase .from("messages") .select("*") .eq("conversation_id", conversationId) .order("created_at", { ascending: true }); if (cancelled) return; if (error) { console.error("Failed to fetch messages:", error); } else { onMessagesLoadedRef.current(data ?? []); } // Only issue the unread_count reset when there's actually something // to reset. Unconditional updates fire a realtime UPDATE event every // time, which — combined with the parent's conversation-event handler // — used to retrigger this effect in a loop. if (hasUnread) { await supabase .from("conversations") .update({ unread_count: 0 }) .eq("id", conversationId); } if (!cancelled) setLoading(false); })(); return () => { cancelled = true; }; // Re-fetch only when the selected conversation changes. `hasUnread` // is used inside but is a boolean derived from conversation; the // conversationId dep is the real trigger. // eslint-disable-next-line react-hooks/exhaustive-deps }, [conversationId]); // Auto-scroll to bottom on new messages useEffect(() => { if (scrollRef.current) { const el = scrollRef.current; el.scrollTop = el.scrollHeight; } }, [messages]); const handleSend = useCallback( async (text: string) => { if (!conversation) return; const tempId = `temp-${Date.now()}`; // Optimistic update — shows the message immediately with "sending" status const optimisticMsg: Message = { id: tempId, conversation_id: conversation.id, sender_type: "agent", content_type: "text", content_text: text, status: "sending", created_at: new Date().toISOString(), }; onNewMessage(optimisticMsg); try { const res = await fetch("/api/whatsapp/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ conversation_id: conversation.id, message_type: "text", content_text: text, }), }); const payload = await res.json().catch(() => ({})); if (!res.ok) { const reason = payload?.error || `HTTP ${res.status}`; console.error("Failed to send message:", reason); toast.error(`Failed to send: ${reason}`); // Mark the optimistic bubble as failed so the user sees what happened onUpdateMessage(tempId, { status: "failed" }); return; } // Success — the realtime INSERT event will replace the temp bubble // with the real DB row. If realtime hasn't arrived yet, at least // flip status to 'sent' so the UI stops showing "sending". onUpdateMessage(tempId, { status: "sent" }); } catch (err) { console.error("Failed to send message:", err); const reason = err instanceof Error ? err.message : "network error"; toast.error(`Failed to send: ${reason}`); onUpdateMessage(tempId, { status: "failed" }); } }, [conversation, onNewMessage, onUpdateMessage] ); const handleStatusChange = useCallback( async (status: ConversationStatus) => { if (!conversation) return; const supabase = createClient(); await supabase .from("conversations") .update({ status }) .eq("id", conversation.id); onStatusChange(conversation.id, status); }, [conversation, onStatusChange] ); const handleOpenTemplates = useCallback(() => { setTemplateModalOpen(true); // Template modal implementation would go here }, []); // Empty state if (!conversation || !contact) { return (

Select a conversation

Choose a conversation from the left to start messaging

); } const displayName = contact.name || contact.phone; const messageGroups = groupMessagesByDate(messages); const currentStatus = STATUS_OPTIONS.find( (s) => s.value === conversation.status ); return (
{/* Header */}
{displayName.charAt(0).toUpperCase()}

{displayName}

{contact.phone}

{/* Session timer badge */} {sessionInfo.remaining}
{/* Status dropdown */} {currentStatus?.label ?? "Status"} {STATUS_OPTIONS.map((opt) => ( handleStatusChange(opt.value)} className={cn("text-sm", opt.color)} > {opt.label} ))} {/* Assign button */}
{/* Messages Area */}
{loading ? (
) : messages.length === 0 ? (

No messages yet

Send a template to start the conversation

) : (
{messageGroups.map((group) => (
{/* Date separator */}
{formatDateSeparator(group.date)}
{/* Messages */}
{group.messages.map((msg) => ( ))}
))}
)}
{/* Composer */}
); }