"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, deleteAccountMedia, MEDIA_MAX_BYTES_BY_KIND, } from "@/lib/storage/upload-media"; import { ReplyQuote } from "./reply-quote"; /** Media content types an agent can send from the composer. */ export type ComposerMediaKind = "image" | "video" | "document" | "audio"; /** Supabase Storage bucket holding agent-sent chat attachments (migration 023). */ export const CHAT_MEDIA_BUCKET = "chat-media"; /** Meta caps media captions at 1024 chars. Enforced here and in the send route. */ export const MEDIA_CAPTION_MAX = 1024; /** Hard cap on a single voice recording so it can't blow the upload/ * transcode limits — auto-stops the recorder when reached. */ const MAX_RECORDING_SECONDS = 5 * 60; export interface SendMediaPayload { kind: ComposerMediaKind; /** Public chat-media URL Meta fetches at send time. */ mediaUrl: string; /** Storage object path — lets the caller GC the object if the send fails. */ path: 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; } // 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; /** Storage path — used to GC the object if the draft is discarded. */ path: 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")}`; } /** Worker that encodes mic input to Ogg/Opus entirely in the browser * (vendored from opus-recorder into /public). Recording client-side in a * Meta-accepted format means no server ffmpeg / transcode step. */ const OPUS_ENCODER_PATH = "/opus/encoderWorker.min.js"; 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); // Mirror of `draft` for the unmount cleanup, which can't read render // state. Kept in sync below so navigating away with a staged-but-unsent // attachment GCs the orphaned object. const draftRef = useRef(null); useEffect(() => { draftRef.current = draft; }, [draft]); // Best-effort GC of a staged object the user never sent. Fire-and-forget. const removeStaged = useCallback((path: string | undefined) => { if (!path) return; void deleteAccountMedia(CHAT_MEDIA_BUCKET, path).catch(() => {}); }, []); // Voice recording state. The recorder encodes Ogg/Opus in-browser // (opus-recorder) so there's no server-side transcode. const [recording, setRecording] = useState(false); const [recordSeconds, setRecordSeconds] = useState(0); const recorderRef = 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, and GC a staged-but-unsent // attachment so it doesn't orphan in the bucket. useEffect(() => { return () => { clearTimer(); cancelledRef.current = true; // stop() releases the mic stream + audio context inside opus-recorder. void recorderRef.current?.stop().catch(() => {}); removeStaged(draftRef.current?.path); }; }, [clearTimer, removeStaged]); 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) => { // Per-kind ceiling mirrors Meta's caps (image 5 MB, etc.) so we // reject before upload rather than orphaning an object that Meta // would then refuse at send. const max = MEDIA_MAX_BYTES_BY_KIND[kind]; if (file.size > max) { toast.error( `File is ${(file.size / 1024 / 1024).toFixed(1)} MB — ${kind} limit is ${Math.round( max / 1024 / 1024, )} MB.`, ); return; } setBusy(true); try { const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file); // Replacing an existing draft? GC the previous object first. removeStaged(draftRef.current?.path); setDraft({ kind, mediaUrl: publicUrl, path, filename: file.name, caption: "" }); } catch (err) { toast.error(err instanceof Error ? err.message : "Upload failed."); } finally { setBusy(false); } }, [removeStaged], ); const handlePicked = useCallback( (kind: "image" | "video" | "document", file: File | undefined) => { if (file) void stageUpload(kind, file); }, [stageUpload], ); // ---- Voice recording (client-side Ogg/Opus, no server transcode) --- // The encoded Ogg/Opus file from opus-recorder → upload as an audio // draft. WhatsApp renders Ogg/Opus as a playable voice note. const finalizeRecording = useCallback( async (bytes: Uint8Array) => { // Uint8Array is a valid BlobPart at runtime; the cast sidesteps the // lib.dom ArrayBufferLike-vs-ArrayBuffer generic mismatch. const file = new File([bytes as unknown as BlobPart], `voice-${Date.now()}.ogg`, { type: "audio/ogg", }); if (file.size === 0) return; // cancelled / empty take if (file.size > MEDIA_MAX_BYTES_BY_KIND.audio) { toast.error("Recording is too long (over 16 MB)."); return; } setBusy(true); try { const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file); removeStaged(draftRef.current?.path); setDraft({ kind: "audio", mediaUrl: publicUrl, path, filename: file.name, caption: "" }); } catch (err) { toast.error(err instanceof Error ? err.message : "Upload failed."); } finally { setBusy(false); } }, [removeStaged], ); const startRecording = useCallback(async () => { if (inputsDisabled || busy || recording) return; if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === "undefined") { toast.error("Voice recording isn't supported in this browser."); return; } try { // Lazy-load the encoder (≈400 KB worker) only when the user records, // keeping it out of the main bundle. const { default: Recorder } = await import("opus-recorder"); const recorder = new Recorder({ encoderPath: OPUS_ENCODER_PATH, numberOfChannels: 1, encoderApplication: 2048, // VOIP — tuned for speech encoderSampleRate: 48000, streamPages: false, // one callback with the complete file on stop }); cancelledRef.current = false; recorder.ondataavailable = (bytes) => { if (cancelledRef.current) return; void finalizeRecording(bytes); }; recorderRef.current = recorder; await recorder.start(); setRecording(true); setRecordSeconds(0); timerRef.current = setInterval(() => setRecordSeconds((s) => s + 1), 1000); } catch { void recorderRef.current?.stop().catch(() => {}); recorderRef.current = null; toast.error("Microphone access denied or unavailable."); } }, [inputsDisabled, busy, recording, finalizeRecording]); const stopRecording = useCallback(() => { clearTimer(); setRecording(false); void recorderRef.current?.stop().catch(() => {}); }, [clearTimer]); const cancelRecording = useCallback(() => { cancelledRef.current = true; clearTimer(); setRecording(false); void recorderRef.current?.stop().catch(() => {}); }, [clearTimer]); // Auto-stop at the cap so a forgotten recording can't blow the // upload size limit. useEffect(() => { if (recording && recordSeconds >= MAX_RECORDING_SECONDS) { stopRecording(); } }, [recording, recordSeconds, stopRecording]); // ---- Draft send / discard ----------------------------------------- const sendDraft = useCallback(() => { if (!draft || busy) return; onSendMedia({ kind: draft.kind, mediaUrl: draft.mediaUrl, path: draft.path, // 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, }); // The object is now owned by the sent message — clear without GC. setDraft(null); onClearReply?.(); }, [draft, busy, onSendMedia, replyTo?.id, onClearReply]); // Discard GCs the staged object — it was uploaded but never sent. const discardDraft = useCallback(() => { removeStaged(draft?.path); setDraft(null); }, [draft?.path, removeStaged]); const setCaption = useCallback((caption: string) => { setDraft((d) => (d ? { ...d, caption } : d)); }, []); // ---- Render -------------------------------------------------------- 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)} /{" "} {formatDuration(MAX_RECORDING_SECONDS)}
) : (
{/* Attach menu — photo / video / document / voice. */} {busy ? ( ) : ( )} imageInputRef.current?.click()}> Photo videoInputRef.current?.click()}> documentInputRef.current?.click()}> Document void startRecording()}> Voice note