"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 (
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 (
);
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 (
);
}
}
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 (