wacrm/src/components/inbox/message-bubble.tsx
Arnas Donauskas 208b096cb1
chore(flows): defensive gate on interactive bubble render
Wraps the new `case 'interactive'` rendering with `isFlowsEnabled(profile)`
from the beta-features module that landed in #113. Non-beta accounts
fall back to the plain-text rendering for `content_type='interactive'`
rows — they get the option title as text, no special "↩ Button reply"
affordance.

Why this gate exists even though the case is unreachable today:
  - No code path on this branch produces `content_type='interactive'`
    rows. The flow runner that does is upcoming (PR #2), and it'll be
    behind the same gate.
  - But: if a stray interactive row ever shows up in a non-beta
    account (manual SQL insert, data import, future bug), we'd rather
    the account see boring plain text than UI for a feature they
    haven't opted into.
  - The cost is ~3 LoC and one hook call. Pure paranoia insurance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:21:22 +03:00

317 lines
9.4 KiB
TypeScript

"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 <Clock className="h-3 w-3 text-slate-400" />;
case "sent":
return <Check className="h-3 w-3 text-slate-400" />;
case "delivered":
return <CheckCheck className="h-3 w-3 text-slate-400" />;
case "read":
return <CheckCheck className="h-3 w-3 text-blue-400" />;
case "failed":
return <XCircle className="h-3 w-3 text-red-400" />;
default:
return null;
}
}
function MediaUnavailable({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 rounded-lg bg-slate-700/40 px-3 py-2 text-xs text-slate-300">
<ImageOff className="h-4 w-4 shrink-0 text-slate-500" />
<span>{label} unavailable</span>
</div>
);
}
function MediaImage({ url, alt }: { url: string; alt: string }) {
const [src, setSrc] = useState<string | null>(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 (
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-slate-700">
<ImageOff className="h-8 w-8 text-slate-500" />
</div>
);
}
if (loading) {
return (
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-slate-700">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-violet-500 border-t-transparent" />
</div>
);
}
return (
<img
src={src ?? ""}
alt={alt}
className="max-h-64 max-w-60 rounded-lg object-cover"
onError={() => 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 (
<p className="whitespace-pre-wrap break-words text-sm">
{message.content_text}
</p>
);
case "image":
return (
<div>
{message.media_url ? (
<MediaImage url={message.media_url} alt="Shared image" />
) : (
<MediaUnavailable label="Image" />
)}
{message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
{message.content_text}
</p>
)}
</div>
);
case "video":
return (
<div>
{message.media_url ? (
<video
src={message.media_url}
controls
className="max-h-64 max-w-60 rounded-lg"
/>
) : (
<MediaUnavailable label="Video" />
)}
{message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
{message.content_text}
</p>
)}
</div>
);
case "audio":
return (
<div>
{message.media_url ? (
<audio src={message.media_url} controls className="max-w-60" />
) : (
<MediaUnavailable label="Audio" />
)}
</div>
);
case "document":
if (!message.media_url) {
return <MediaUnavailable label={message.content_text || "Document"} />;
}
return (
<a
href={message.media_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg bg-slate-700/50 px-3 py-2 text-sm hover:bg-slate-700"
>
<FileText className="h-5 w-5 shrink-0 text-slate-400" />
<span className="truncate">
{message.content_text || "Document"}
</span>
</a>
);
case "template":
return (
<div>
<span className="mb-1 inline-flex items-center gap-1 rounded bg-violet-500/20 px-1.5 py-0.5 text-[10px] font-medium text-violet-400">
<LayoutTemplate className="h-3 w-3" />
Template
</span>
{message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
{message.content_text}
</p>
)}
</div>
);
case "location":
return (
<div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 shrink-0 text-slate-400" />
<span>{message.content_text || "Location shared"}</span>
</div>
);
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 (
<p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || "[Interactive reply]"}
</p>
);
}
// 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 (
<div className="flex flex-col gap-0.5">
<span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
<CornerDownLeft className="h-3 w-3" />
Button reply
</span>
<p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || "[Interactive reply]"}
</p>
</div>
);
}
default:
return (
<p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || "[Unsupported message type]"}
</p>
);
}
}
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 <MessageActions> so its hover
// group matches the bubble's content area, not the full row.
return (
<div
className={cn(
"flex flex-col",
isAgent ? "items-end" : "items-start",
)}
>
<div
className={cn(
"relative rounded-2xl px-3 py-2",
isAgent
? "rounded-br-md bg-violet-600 text-white"
: "rounded-bl-md bg-slate-800 text-slate-100",
)}
>
{reply && (
<ReplyQuote authorLabel={reply.authorLabel} preview={reply.preview} />
)}
<MessageContent message={message} />
<div
className={cn(
"mt-1 flex items-center gap-1",
isAgent ? "justify-end" : "justify-start",
)}
>
<span className="text-[10px] text-white/60">{time}</span>
{isAgent && <StatusIcon status={message.status} />}
</div>
</div>
{reactions && reactions.length > 0 && onToggleReaction && (
<MessageReactions
reactions={reactions}
currentUserId={currentUserId}
onToggle={onToggleReaction}
/>
)}
</div>
);
}