"use client"; import { useState, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; import type { Message, MessageReaction } from "@/types"; import { Clock, Check, CheckCheck, XCircle, FileText, MapPin, LayoutTemplate, ImageOff, CornerDownLeft, } from "lucide-react"; import { format } from "date-fns"; import { ReplyQuote } from "./reply-quote"; import { MessageReactions } from "./message-reactions"; import { useAuth } from "@/hooks/use-auth"; import { isFlowsEnabled } from "@/lib/flows/feature-flag"; interface MessageBubbleProps { message: Message; /** Pre-computed quote info for messages that reply to another. */ reply?: { authorLabel: string; preview: string } | null; reactions?: MessageReaction[]; currentUserId?: string; onToggleReaction?: (emoji: string) => void; } function StatusIcon({ status }: { status: Message["status"] }) { switch (status) { case "sending": return ; case "sent": return ; case "delivered": return ; case "read": return ; case "failed": return ; default: return null; } } function MediaUnavailable({ label }: { label: string }) { return (
{label} unavailable
); } function MediaImage({ url, alt }: { url: string; alt: string }) { const [src, setSrc] = useState(null); const [error, setError] = useState(false); const [loading, setLoading] = useState(true); const loadImage = useCallback(async () => { if (!url) return; // Proxy URLs need auth fetch to create blob URL if (url.startsWith("/api/whatsapp/media/")) { try { const res = await fetch(url); if (!res.ok) throw new Error("Failed to load media"); const blob = await res.blob(); const blobUrl = URL.createObjectURL(blob); setSrc(blobUrl); } catch { setError(true); } finally { setLoading(false); } } else { setSrc(url); setLoading(false); } }, [url]); useEffect(() => { loadImage(); return () => { if (src?.startsWith("blob:")) { URL.revokeObjectURL(src); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [loadImage]); if (error) { return (
); } if (loading) { return (
); } return ( {alt} setError(true)} /> ); } function MessageContent({ message }: { message: Message }) { // Used to gate the interactive-reply bubble style below. The webhook // only ever inserts `content_type='interactive'` rows when our runner // sent an interactive message first — and the runner itself is behind // the same beta gate (forthcoming PRs) — so this gate is belt-and- // suspenders against any stray row that slipped in via manual SQL, // a misconfigured import, or a future bug. Non-beta accounts fall // back to plain text. const { profile } = useAuth(); switch (message.content_type) { case "text": return (

{message.content_text}

); case "image": return (
{message.media_url ? ( ) : ( )} {message.content_text && (

{message.content_text}

)}
); case "video": return (
{message.media_url ? (
); case "audio": return (
{message.media_url ? (
); case "document": if (!message.media_url) { return ; } return ( {message.content_text || "Document"} ); case "template": return (
Template {message.content_text && (

{message.content_text}

)}
); case "location": return (
{message.content_text || "Location shared"}
); case "interactive": { // Defensive gate — non-beta accounts render the tapped option as // plain text. See the comment at the top of MessageContent for why // this can't be reached in normal operation; this branch exists so // that if a stray interactive row ever appears for a non-beta // account, they don't see UI for a feature they haven't opted into. if (!isFlowsEnabled(profile)) { return (

{message.content_text || "[Interactive reply]"}

); } // Customer tapped a reply button or list row on a message the bot // sent. We show the tapped option's title (already in content_text, // set by parseMessageContent in the webhook) with a small affordance // so agents reading the inbox can tell at a glance that this is a // tap rather than the customer typing the same words. return (
Button reply

{message.content_text || "[Interactive reply]"}

); } default: return (

{message.content_text || "[Unsupported message type]"}

); } } export function MessageBubble({ message, reply, reactions, currentUserId, onToggleReaction, }: MessageBubbleProps) { const isAgent = message.sender_type === "agent" || message.sender_type === "bot"; const time = format(new Date(message.created_at), "HH:mm"); // Row alignment + width cap are owned by so its hover // group matches the bubble's content area, not the full row. return (
{reply && ( )}
{time} {isAgent && }
{reactions && reactions.length > 0 && onToggleReaction && ( )}
); }