import { useState, useRef, useCallback, useEffect, type PointerEvent as RPointerEvent, type ChangeEvent } from 'react'; import { SendHorizontal, Mic, Square, Trash2, Paperclip, Camera, X } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import type { Attachment } from '../../hooks/useChat'; import { useSpeechRecognition } from '../../hooks/useSpeechRecognition'; interface Props { onSend: (msg: string, attachments?: Attachment[], audioData?: string) => void; onStop: () => void; streaming: boolean; whisperEnabled?: boolean; onTranscribe?: (audio: string) => Promise<{ transcript?: string }>; onRecordingChange?: (recording: boolean) => void; onAudioReady?: (audioData: string) => void; } function formatTime(s: number) { const mins = Math.floor(s / 60); const secs = s % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; } /** Compress an image to fit under maxBytes while staying visually clear for an LLM. */ function compressImage(dataUrl: string, maxBytes = 4 * 1024 * 1024): Promise { return new Promise((resolve) => { const img = new Image(); img.onload = () => { const MAX_DIM = 1600; let { width, height } = img; // Scale down if larger than MAX_DIM on either axis if (width > MAX_DIM || height > MAX_DIM) { const ratio = Math.min(MAX_DIM / width, MAX_DIM / height); width = Math.round(width * ratio); height = Math.round(height * ratio); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d')!; ctx.drawImage(img, 0, 0, width, height); // Try quality levels until under maxBytes for (const q of [0.8, 0.6, 0.4, 0.3]) { const result = canvas.toDataURL('image/jpeg', q); const size = Math.round((result.length - 'data:image/jpeg;base64,'.length) * 0.75); if (size <= maxBytes) { resolve(result); return; } } // Fallback: lowest quality resolve(canvas.toDataURL('image/jpeg', 0.2)); }; img.onerror = () => resolve(dataUrl); // pass through on error img.src = dataUrl; }); } const DRAFT_KEY = 'bloby_draft'; /** Max per-file size — mirrors the server's MAX_ATTACHMENT_BYTES (~12MB). */ const MAX_ATTACHMENT_BYTES = 12 * 1024 * 1024; /** Swap (or append) a file's extension so name/content/on-disk-ext agree after re-encode. */ function withExtension(name: string, ext: string): string { return name.includes('.') ? name.replace(/\.[^.]+$/, `.${ext}`) : `${name}.${ext}`; } export default function InputBar({ onSend, onStop, streaming, whisperEnabled, onTranscribe, onRecordingChange, onAudioReady }: Props) { const { start: startSpeech, stop: stopSpeech, abort: abortSpeech, isSupported: webSpeechSupported } = useSpeechRecognition(); const voiceEnabled = whisperEnabled || webSpeechSupported; const [text, setText] = useState(() => { try { return localStorage.getItem(DRAFT_KEY) || ''; } catch { return ''; } }); const [attachments, setAttachments] = useState([]); const [attachError, setAttachError] = useState(null); const attachErrorTimerRef = useRef | null>(null); const draftTimerRef = useRef | null>(null); const [isRecording, _setIsRecording] = useState(false); const setIsRecording = useCallback((v: boolean) => { _setIsRecording(v); onRecordingChange?.(v); }, [onRecordingChange]); const [recordingTime, setRecordingTime] = useState(0); const hasText = text.trim().length > 0; const hasContent = hasText || attachments.length > 0; const textareaRef = useRef(null); const fileRef = useRef(null); const cameraRef = useRef(null); const trashRef = useRef(null); const micRef = useRef(null); const startXRef = useRef(0); const dragRef = useRef(0); const holdTimerRef = useRef | null>(null); const isHolding = useRef(false); const pointerIsDown = useRef(false); const intervalRef = useRef | null>(null); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const streamRef = useRef(null); // Auto-resize textarea up to 4 lines, then scroll useEffect(() => { const el = textareaRef.current; if (!el) return; el.style.height = '0px'; // 4 lines ~ 5.5rem = 88px at text-sm (14px * 1.625 line-height * 4) el.style.height = `${Math.min(el.scrollHeight, 88)}px`; }, [text]); // Debounced draft save to localStorage useEffect(() => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current); draftTimerRef.current = setTimeout(() => { try { localStorage.setItem(DRAFT_KEY, text); } catch {} }, 500); return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current); }; }, [text]); // Clean up the transient attach-error timer on unmount useEffect(() => () => { if (attachErrorTimerRef.current) clearTimeout(attachErrorTimerRef.current); }, []); // Recording timer useEffect(() => { if (!isRecording) return; intervalRef.current = setInterval(() => setRecordingTime((t) => t + 1), 1000); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; }, [isRecording]); const stopRecording = useCallback(async (cancelled: boolean) => { if (intervalRef.current) clearInterval(intervalRef.current); if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; } isHolding.current = false; const recorder = mediaRecorderRef.current; const stream = streamRef.current; if (cancelled) { stream?.getTracks().forEach((t) => t.stop()); mediaRecorderRef.current = null; streamRef.current = null; audioChunksRef.current = []; abortSpeech(); } else if (recorder && recorder.state !== 'inactive') { // Whisper path: stop MediaRecorder and use its audio recorder.onstop = async () => { stream?.getTracks().forEach((t) => t.stop()); const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); audioChunksRef.current = []; mediaRecorderRef.current = null; streamRef.current = null; if (blob.size < 1000) return; const fileReader = new FileReader(); fileReader.onloadend = async () => { const dataUrl = fileReader.result as string; const base64 = dataUrl.split(',')[1]; if (!base64) return; // Show pending audio bubble immediately (before transcription) onAudioReady?.(dataUrl); const pendingAtts = attachments.length > 0 ? attachments : undefined; try { let data: { transcript?: string }; if (onTranscribe) { data = await onTranscribe(base64); } else { const res = await fetch('/api/whisper/transcribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audio: base64 }), }); data = await res.json(); } onSend(data.transcript?.trim() || '', pendingAtts, dataUrl); } catch (err) { console.error('[InputBar] Whisper transcription error:', err); onSend('', pendingAtts, dataUrl); } if (pendingAtts) setAttachments([]); }; fileReader.readAsDataURL(blob); }; recorder.stop(); } else { // Web Speech API path (no MediaRecorder): get transcript directly stream?.getTracks().forEach((t) => t.stop()); mediaRecorderRef.current = null; streamRef.current = null; audioChunksRef.current = []; try { const transcript = await stopSpeech(); if (transcript.trim()) { const pendingAtts = attachments.length > 0 ? attachments : undefined; onSend(transcript.trim(), pendingAtts); if (pendingAtts) setAttachments([]); } } catch (err) { console.error('[InputBar] Web Speech stop error:', err); } } if (micRef.current) micRef.current.style.transform = ''; setIsRecording(false); setRecordingTime(0); dragRef.current = 0; }, [onSend, onTranscribe, attachments, whisperEnabled, abortSpeech, stopSpeech]); // ── File handling ── /** Surface a transient inline error in the attachment tray. */ const showAttachError = useCallback((msg: string) => { setAttachError(msg); if (attachErrorTimerRef.current) clearTimeout(attachErrorTimerRef.current); attachErrorTimerRef.current = setTimeout(() => setAttachError(null), 4000); }, []); const addFile = useCallback((file: File) => { const isImage = file.type.startsWith('image/'); // Constrain by what the harness/model accepts, not by type — accept any file, // but guard size (images are re-compressed below, so only gate non-images here). if (!isImage && file.size > MAX_ATTACHMENT_BYTES) { showAttachError(`"${file.name}" is too large (max ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB)`); return; } const reader = new FileReader(); reader.onload = async (e) => { let preview = e.target?.result as string; let name = file.name; if (isImage) { // Compress images to stay under API limits. compressImage() always emits // image/jpeg, so realign the name's extension to .jpg (name/mediaType/ext agree). preview = await compressImage(preview); if (preview.startsWith('data:image/jpeg')) name = withExtension(name, 'jpg'); } else { // Non-image: enforce size on the encoded payload too (data URL ~33% larger). const approxBytes = Math.round((preview.length - (preview.indexOf(',') + 1)) * 0.75); if (approxBytes > MAX_ATTACHMENT_BYTES) { showAttachError(`"${file.name}" is too large (max ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB)`); return; } } setAttachments((prev) => [ ...prev, { id: Math.random().toString(36).slice(2), type: isImage ? 'image' : 'file', name, preview, }, ]); }; reader.readAsDataURL(file); }, [showAttachError]); const handleFileChange = useCallback((e: ChangeEvent) => { const files = e.target.files; if (!files) return; for (let i = 0; i < files.length; i++) addFile(files[i]); e.target.value = ''; }, [addFile]); const removeAttachment = useCallback((id: string) => { setAttachments((prev) => prev.filter((a) => a.id !== id)); }, []); // Handle paste for images useEffect(() => { const handlePaste = (e: ClipboardEvent) => { if (!e.clipboardData?.items) return; for (const item of e.clipboardData.items) { if (item.type.startsWith('image/')) { e.preventDefault(); const file = item.getAsFile(); if (file) addFile(file); } } }; document.addEventListener('paste', handlePaste); return () => document.removeEventListener('paste', handlePaste); }, [addFile]); const handleSend = () => { if (!hasContent) return; onSend(text, attachments.length > 0 ? attachments : undefined); setText(''); setAttachments([]); try { localStorage.removeItem(DRAFT_KEY); } catch {} requestAnimationFrame(() => textareaRef.current?.focus()); }; // ── Device detection ── const isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0; // ── Start recording helper (shared by desktop click & mobile hold) ── const beginRecording = useCallback(async () => { if (!voiceEnabled) return; try { if (whisperEnabled) { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); if (!isMobile && !pointerIsDown.current) { // Desktop: pointer already released is fine (click), keep going } else if (isMobile && !pointerIsDown.current) { stream.getTracks().forEach((t) => t.stop()); return; } streamRef.current = stream; const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'; const recorder = new MediaRecorder(stream, { mimeType }); audioChunksRef.current = []; recorder.ondataavailable = (ev) => { if (ev.data.size > 0) audioChunksRef.current.push(ev.data); }; mediaRecorderRef.current = recorder; recorder.start(); } else { startSpeech(!isMobile); // desktop: continuous=true, mobile: continuous=false if (isMobile && !pointerIsDown.current) { abortSpeech(); return; } } isHolding.current = true; setIsRecording(true); setRecordingTime(0); } catch (err) { console.error('[InputBar] recording setup failed:', err); } }, [voiceEnabled, whisperEnabled, startSpeech, abortSpeech]); // ── Mic pointer handlers ── const handleMicDown = useCallback((e: RPointerEvent) => { e.preventDefault(); if (!isMobile) { // Desktop: click-to-toggle if (isRecording) { stopRecording(false); return; } pointerIsDown.current = true; beginRecording(); return; } // Mobile: press-and-hold pointerIsDown.current = true; startXRef.current = e.clientX; dragRef.current = 0; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); holdTimerRef.current = setTimeout(() => { try { navigator.vibrate?.(50); } catch {} beginRecording(); }, 200); }, [isMobile, isRecording, voiceEnabled, beginRecording, stopRecording]); const handleMicMove = useCallback((e: RPointerEvent) => { if (!isMobile || !isHolding.current) return; const dx = Math.min(0, e.clientX - startXRef.current); dragRef.current = dx; if (micRef.current) micRef.current.style.transform = `translateX(${dx}px)`; if (trashRef.current) { const trashRect = trashRef.current.getBoundingClientRect(); const trashCenterX = trashRect.left + trashRect.width / 2; if (Math.abs(e.clientX - trashCenterX) < 36) { stopRecording(true); } } }, [isMobile, stopRecording]); const handleMicUp = useCallback(() => { pointerIsDown.current = false; if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; } // Desktop: don't stop on release (toggle mode) if (!isMobile) return; // Mobile: stop on release if (!isHolding.current) return; stopRecording(false); }, [isMobile, stopRecording]); const handleMicCancel = useCallback(() => { pointerIsDown.current = false; if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; } if (isHolding.current) stopRecording(true); }, [stopRecording]); return (
{/* ── Normal input (always mounted to keep keyboard alive) ── */}
{/* ── Inline attachment error (oversize / read failure) ── */} {attachError && (
{attachError}
)}
{/* ── Attachment previews ── */} {attachments.length > 0 && (
{attachments.map((att) => (
{att.type === 'image' ? ( {att.name} ) : (
{att.name.split('.').pop()}
)}
))}
)}