"use client"; import { useState, useRef, useCallback, useEffect, KeyboardEvent, } from "react"; import { Send, LayoutTemplate, Paperclip, Image as ImageIcon, Video, FileText, Mic, Square, X, Loader2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { GatedButton } from "@/components/ui/gated-button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useCan } from "@/hooks/use-can"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { uploadAccountMedia, MEDIA_MAX_BYTES, } from "@/lib/storage/upload-media"; import { isMetaAcceptedAudio } from "@/lib/whatsapp/audio-transcode"; import { ReplyQuote } from "./reply-quote"; /** Media content types an agent can send from the composer. */ export type ComposerMediaKind = "image" | "video" | "document" | "audio"; export interface SendMediaPayload { kind: ComposerMediaKind; /** Public chat-media URL Meta fetches at send time. */ mediaUrl: string; /** Optional caption (image/video/document only). */ caption?: string; /** Original file name — surfaced to the recipient for documents. */ filename?: string; replyToId?: string; } interface ReplyDraft { /** Internal UUID of the message being replied to — sent back through onSend. */ id: string; authorLabel: string; preview: string; } const CHAT_MEDIA_BUCKET = "chat-media"; // Mirrors the chat-media bucket's allowed_mime_types (migration 023) for // the file picker so unsupported files are rejected before upload rather // than failing with a confusing Storage error. Audio has no picker — it's // captured via the recorder. const PICKER_ACCEPT: Record<"image" | "video" | "document", string> = { image: "image/png,image/jpeg,image/webp", video: "video/mp4,video/3gpp", document: "application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain", }; interface MediaDraft { kind: ComposerMediaKind; mediaUrl: string; filename: string; caption: string; } interface MessageComposerProps { conversationId: string; sessionExpired: boolean; onSend: (text: string, replyToId?: string) => void; onSendMedia: (payload: SendMediaPayload) => void; onOpenTemplates: () => void; replyTo?: ReplyDraft | null; onClearReply?: () => void; } function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${s.toString().padStart(2, "0")}`; } /** * Map a Meta-accepted audio MIME to a file extension for the upload path. * Recordings that aren't already accepted are transcoded to OGG upstream, * so this only ever sees accepted types. */ function audioExtForMime(mime: string): string { const base = mime.split(";")[0]; if (base.includes("ogg")) return "ogg"; if (base.includes("mp4")) return "m4a"; if (base.includes("mpeg")) return "mp3"; if (base.includes("aac")) return "aac"; if (base.includes("amr")) return "amr"; return "ogg"; } export function MessageComposer({ conversationId, sessionExpired, onSend, onSendMedia, onOpenTemplates, replyTo, onClearReply, }: MessageComposerProps) { const [text, setText] = useState(""); const [sending, setSending] = useState(false); const textareaRef = useRef(null); // Media attachment state. `draft` holds an uploaded-but-not-yet-sent // attachment; `busy` covers the upload/transcode window. const [draft, setDraft] = useState(null); const [busy, setBusy] = useState(false); const imageInputRef = useRef(null); const videoInputRef = useRef(null); const documentInputRef = useRef(null); // Voice recording state. const [recording, setRecording] = useState(false); const [recordSeconds, setRecordSeconds] = useState(0); const recorderRef = useRef(null); const chunksRef = useRef([]); const streamRef = useRef(null); const cancelledRef = useRef(false); const timerRef = useRef | null>(null); // Viewers (read-only role) can browse the inbox but never send. // For solo users this is always true — single-owner accounts pass // every capability — so the disabled branch is a no-op there. const canSend = useCan("send-messages"); const readOnly = !canSend; // Media (like free-form text) is only allowed inside the 24h window. const inputsDisabled = readOnly || sessionExpired; const clearTimer = useCallback(() => { if (timerRef.current !== null) { clearInterval(timerRef.current); timerRef.current = null; } }, []); // Tear down any live recording + timer on unmount so a mid-record // navigation doesn't leak the mic. useEffect(() => { return () => { clearTimer(); streamRef.current?.getTracks().forEach((t) => t.stop()); }; }, [clearTimer]); const adjustHeight = useCallback(() => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; // Max 4 lines (~96px) el.style.height = `${Math.min(el.scrollHeight, 96)}px`; }, []); const handleSend = useCallback(async () => { const trimmed = text.trim(); if (!trimmed || sending || sessionExpired) return; setSending(true); try { onSend(trimmed, replyTo?.id); setText(""); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } } finally { setSending(false); } }, [text, sending, sessionExpired, onSend, replyTo?.id]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend] ); const handleChange = useCallback( (e: React.ChangeEvent) => { setText(e.target.value); adjustHeight(); }, [adjustHeight] ); // Upload a captured file to chat-media and stage it as a draft. const stageUpload = useCallback( async (kind: ComposerMediaKind, file: File) => { if (file.size > MEDIA_MAX_BYTES) { toast.error( `File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`, ); return; } setBusy(true); try { const { publicUrl } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file); setDraft({ kind, mediaUrl: publicUrl, filename: file.name, caption: "" }); } catch (err) { toast.error(err instanceof Error ? err.message : "Upload failed."); } finally { setBusy(false); } }, [], ); const handlePicked = useCallback( (kind: "image" | "video" | "document", file: File | undefined) => { if (file) void stageUpload(kind, file); }, [stageUpload], ); // ---- Voice recording ---------------------------------------------- const finalizeRecording = useCallback( async (blob: Blob, mime: string) => { setBusy(true); try { let file: File; if (isMetaAcceptedAudio(mime)) { // Firefox (ogg) / Safari (mp4) already give a Meta-accepted // format — upload as-is. const base = mime.split(";")[0]; file = new File([blob], `voice-${Date.now()}.${audioExtForMime(mime)}`, { type: base, }); } else { // Chromium records WebM/Opus, which Meta rejects — remux to // OGG/Opus server-side first. const form = new FormData(); form.append("file", blob, "recording"); const res = await fetch("/api/whatsapp/transcode-audio", { method: "POST", body: form, }); if (!res.ok) { const payload = await res.json().catch(() => ({})); throw new Error(payload?.error || `Transcode failed (HTTP ${res.status})`); } const ogg = await res.blob(); file = new File([ogg], `voice-${Date.now()}.ogg`, { type: "audio/ogg" }); } if (file.size > MEDIA_MAX_BYTES) { toast.error("Recording is too long (over 16 MB)."); return; } const { publicUrl } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file); setDraft({ kind: "audio", mediaUrl: publicUrl, filename: file.name, caption: "" }); } catch (err) { toast.error(err instanceof Error ? err.message : "Could not process the recording."); } finally { setBusy(false); } }, [], ); const startRecording = useCallback(async () => { if (inputsDisabled || busy || recording) return; if (typeof MediaRecorder === "undefined" || !navigator.mediaDevices?.getUserMedia) { toast.error("Voice recording isn't supported in this browser."); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); streamRef.current = stream; // Prefer a Meta-accepted container (Firefox supports ogg/opus) so // we can skip the transcode round-trip; Chromium falls back to webm. const preferred = ["audio/ogg;codecs=opus", "audio/webm;codecs=opus", "audio/webm"]; const mimeType = preferred.find((t) => MediaRecorder.isTypeSupported(t)) || ""; const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); chunksRef.current = []; cancelledRef.current = false; recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; recorder.onstop = () => { stream.getTracks().forEach((t) => t.stop()); streamRef.current = null; const chunks = chunksRef.current; chunksRef.current = []; if (cancelledRef.current) return; const type = recorder.mimeType || mimeType || "audio/webm"; const blob = new Blob(chunks, { type }); if (blob.size > 0) void finalizeRecording(blob, type); }; recorderRef.current = recorder; recorder.start(); setRecording(true); setRecordSeconds(0); timerRef.current = setInterval(() => setRecordSeconds((s) => s + 1), 1000); } catch { streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = null; toast.error("Microphone access denied or unavailable."); } }, [inputsDisabled, busy, recording, finalizeRecording]); const stopRecording = useCallback(() => { clearTimer(); setRecording(false); recorderRef.current?.stop(); }, [clearTimer]); const cancelRecording = useCallback(() => { cancelledRef.current = true; clearTimer(); setRecording(false); recorderRef.current?.stop(); }, [clearTimer]); // ---- Draft send / discard ----------------------------------------- const sendDraft = useCallback(() => { if (!draft || busy) return; onSendMedia({ kind: draft.kind, mediaUrl: draft.mediaUrl, // Audio takes no caption (Meta rejects it). Everything else: the // trimmed caption, or undefined when blank. caption: draft.kind === "audio" ? undefined : draft.caption.trim() || undefined, filename: draft.kind === "document" ? draft.filename : undefined, replyToId: replyTo?.id, }); setDraft(null); onClearReply?.(); }, [draft, busy, onSendMedia, replyTo?.id, onClearReply]); const discardDraft = useCallback(() => setDraft(null), []); // ---- Render -------------------------------------------------------- const DraftPreview = () => { if (!draft) return null; return (
{draft.kind === "image" && ( // eslint-disable-next-line @next/next/no-img-element {draft.filename} )} {draft.kind === "video" && (
{draft.kind !== "audio" && ( setDraft((d) => (d ? { ...d, caption: e.target.value } : d)) } onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendDraft(); } }} placeholder="Add a caption…" className="flex-1 rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50" /> )}
); }; return (
{replyTo && (
)} {sessionExpired && (

24-hour session expired. Use a template to re-engage.

)} {/* Hidden file inputs driven by the attach menu. */} { handlePicked("image", e.target.files?.[0]); e.target.value = ""; }} /> { handlePicked("video", e.target.files?.[0]); e.target.value = ""; }} /> { handlePicked("document", e.target.files?.[0]); e.target.value = ""; }} /> {draft ? ( ) : recording ? ( // Recording bar — replaces the composer while the mic is live.
Recording… {formatDuration(recordSeconds)}
) : (
{/* Attach menu — photo / video / document / voice. */} {busy ? ( ) : ( )} imageInputRef.current?.click()}> Photo videoInputRef.current?.click()}> documentInputRef.current?.click()}> Document void startRecording()}> Voice note