import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; import { attachFile, requestUploadUrl, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { buildWsUrl } from "../utils/ws"; import { getAppPreferences } from "../utils/preferences"; type RecordingState = "idle" | "recording" | "locked"; export function MessageComposer() { const activeChatId = useChatStore((s) => s.activeChatId); const me = useAuthStore((s) => s.me); const draftsByChat = useChatStore((s) => s.draftsByChat); const setDraft = useChatStore((s) => s.setDraft); const clearDraft = useChatStore((s) => s.clearDraft); const addOptimisticMessage = useChatStore((s) => s.addOptimisticMessage); const confirmMessageByClientId = useChatStore((s) => s.confirmMessageByClientId); const removeOptimisticMessage = useChatStore((s) => s.removeOptimisticMessage); const replyToByChat = useChatStore((s) => s.replyToByChat); const setReplyToMessage = useChatStore((s) => s.setReplyToMessage); const accessToken = useAuthStore((s) => s.accessToken); const [text, setText] = useState(""); const wsRef = useRef(null); const recorderRef = useRef(null); const recordingStreamRef = useRef(null); const chunksRef = useRef([]); const sendVoiceOnStopRef = useRef(true); const recordingStartedAtRef = useRef(null); const pointerStartYRef = useRef(0); const pointerStartXRef = useRef(0); const pointerCancelArmedRef = useRef(false); const pointerMoveHandlerRef = useRef<((event: globalThis.PointerEvent) => void) | null>(null); const pointerUpHandlerRef = useRef<((event: globalThis.PointerEvent) => void) | null>(null); const recordingStateRef = useRef("idle"); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); const [selectedFile, setSelectedFile] = useState(null); const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file"); const [previewUrl, setPreviewUrl] = useState(null); const [showAttachMenu, setShowAttachMenu] = useState(false); const [showFormatMenu, setShowFormatMenu] = useState(false); const [captionDraft, setCaptionDraft] = useState(""); const mediaInputRef = useRef(null); const fileInputRef = useRef(null); const textareaRef = useRef(null); const [recordingState, setRecordingState] = useState("idle"); const [recordSeconds, setRecordSeconds] = useState(0); const [dragHint, setDragHint] = useState<"idle" | "lock" | "cancel">("idle"); const hasTextToSend = text.trim().length > 0; useEffect(() => { recordingStateRef.current = recordingState; }, [recordingState]); useEffect(() => { if (!activeChatId) { if (text !== "") { setText(""); } return; } const draft = draftsByChat[activeChatId] ?? ""; if (draft !== text) { setText(draft); } }, [activeChatId, draftsByChat, text]); useEffect(() => { return () => { if (previewUrl) { URL.revokeObjectURL(previewUrl); } if (pointerMoveHandlerRef.current) { window.removeEventListener("pointermove", pointerMoveHandlerRef.current); } if (pointerUpHandlerRef.current) { window.removeEventListener("pointerup", pointerUpHandlerRef.current); } }; }, [previewUrl]); useEffect(() => { if (recordingState === "idle") { return; } const interval = window.setInterval(() => { if (!recordingStartedAtRef.current) { return; } const sec = Math.max(0, Math.floor((Date.now() - recordingStartedAtRef.current) / 1000)); setRecordSeconds(sec); }, 250); return () => window.clearInterval(interval); }, [recordingState]); function makeClientMessageId(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); } return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function getWs(): WebSocket | null { if (!accessToken || !activeChatId) { return null; } if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { return wsRef.current; } const wsUrl = buildWsUrl(accessToken); wsRef.current = new WebSocket(wsUrl); return wsRef.current; } async function handleSend() { if (!activeChatId || !text.trim() || !me) { return; } const clientMessageId = makeClientMessageId(); const textValue = text.trim(); const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined; addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "text", text: textValue, clientMessageId }); try { let message = null; for (let attempt = 0; attempt < 2; attempt += 1) { try { message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId, replyToMessageId); break; } catch (error) { if (attempt === 1) { throw error; } await new Promise((resolve) => window.setTimeout(resolve, 250)); } } if (!message) { throw new Error("send failed"); } confirmMessageByClientId(activeChatId, clientMessageId, message); setText(""); clearDraft(activeChatId); setReplyToMessage(activeChatId, null); const ws = getWs(); ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: activeChatId } })); } catch { removeOptimisticMessage(activeChatId, clientMessageId); setUploadError("Message send failed. Please try again."); } } function onComposerKeyDown(event: KeyboardEvent) { if (event.key !== "Enter") { return; } const prefs = getAppPreferences(); const sendWithCtrlEnter = prefs.sendMode === "ctrl_enter"; if (sendWithCtrlEnter) { if (event.ctrlKey) { event.preventDefault(); void handleSend(); } return; } if (!event.shiftKey) { event.preventDefault(); void handleSend(); } } async function handleUpload( file: File, messageType: "file" | "image" | "video" | "audio" | "voice" = "file", waveformPoints?: number[] | null ) { if (!activeChatId || !me) { return; } setIsUploading(true); setUploadProgress(0); setUploadError(null); const clientMessageId = makeClientMessageId(); const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined; try { const upload = await requestUploadUrl(file); await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file, setUploadProgress); addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: messageType, text: upload.file_url, clientMessageId, }); const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, clientMessageId, replyToMessageId); const messageWithWaveform = waveformPoints?.length ? { ...message, attachment_waveform: waveformPoints } : message; confirmMessageByClientId(activeChatId, clientMessageId, messageWithWaveform); try { await attachFile( message.id, upload.file_url, file.type || "application/octet-stream", file.size, waveformPoints ?? null ); } catch { setUploadError("File sent, but metadata save failed. Please refresh chat."); } setReplyToMessage(activeChatId, null); } catch { removeOptimisticMessage(activeChatId, clientMessageId); setUploadError("Upload failed. Please try again."); } finally { setIsUploading(false); } } function inferType(file: File): "file" | "image" | "video" | "audio" { if (file.type.startsWith("image/")) return "image"; if (file.type.startsWith("video/")) return "video"; if (file.type.startsWith("audio/")) return "audio"; return "file"; } function loadImageFromFile(file: File): Promise { return new Promise((resolve, reject) => { const image = new Image(); const url = URL.createObjectURL(file); image.onload = () => { URL.revokeObjectURL(url); resolve(image); }; image.onerror = () => { URL.revokeObjectURL(url); reject(new Error("Failed to load image")); }; image.src = url; }); } async function compressImageForWeb(file: File): Promise { const image = await loadImageFromFile(file); const maxSide = 1920; const ratio = Math.min(1, maxSide / Math.max(image.width, image.height)); const width = Math.max(1, Math.round(image.width * ratio)); const height = Math.max(1, Math.round(image.height * ratio)); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); if (!ctx) { return file; } ctx.drawImage(image, 0, 0, width, height); const blob = await new Promise((resolve) => { canvas.toBlob((result) => resolve(result), "image/jpeg", 0.82); }); if (!blob || blob.size >= file.size) { return file; } const baseName = file.name.replace(/\.[^/.]+$/, ""); return new File([blob], `${baseName || "image"}-web.jpg`, { type: "image/jpeg" }); } async function prepareFileForUpload(file: File, fileType: "file" | "image" | "video" | "audio"): Promise { if (fileType === "image") { return compressImageForWeb(file); } return file; } async function startRecord() { if (recordingState !== "idle") { return false; } try { if (navigator.permissions && navigator.permissions.query) { const permission = await navigator.permissions.query({ name: "microphone" as PermissionName }); if (permission.state === "denied") { setUploadError("Microphone access denied. Allow microphone in browser site permissions."); return false; } } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const recorder = new MediaRecorder(stream); recordingStreamRef.current = stream; chunksRef.current = []; sendVoiceOnStopRef.current = true; recorder.ondataavailable = (event) => chunksRef.current.push(event.data); recorder.onstop = async () => { const shouldSend = sendVoiceOnStopRef.current; const durationMs = recordingStartedAtRef.current ? Date.now() - recordingStartedAtRef.current : 0; const data = [...chunksRef.current]; chunksRef.current = []; if (recordingStreamRef.current) { recordingStreamRef.current.getTracks().forEach((track) => track.stop()); recordingStreamRef.current = null; } recordingStartedAtRef.current = null; if (!shouldSend || data.length === 0) { return; } if (durationMs < 1000) { setUploadError("Voice message is too short. Minimum length is 1 second."); return; } const blob = new Blob(data, { type: "audio/webm" }); const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" }); const waveform = await buildWaveformPoints(blob, 64); await handleUpload(file, "voice", waveform); }; recorderRef.current = recorder; recorder.start(); recordingStartedAtRef.current = Date.now(); setRecordSeconds(0); setRecordingState("recording"); return true; } catch { setUploadError("Microphone access denied. Please allow microphone and retry."); return false; } } function stopRecord(send: boolean) { sendVoiceOnStopRef.current = send; pointerCancelArmedRef.current = false; setDragHint("idle"); if (recorderRef.current && recorderRef.current.state !== "inactive") { recorderRef.current.stop(); } recorderRef.current = null; setRecordingState("idle"); setRecordSeconds(0); } async function onMicPointerDown(event: PointerEvent) { event.preventDefault(); const started = await startRecord(); if (!started) { return; } pointerStartYRef.current = event.clientY; pointerStartXRef.current = event.clientX; pointerCancelArmedRef.current = false; setDragHint("idle"); if (pointerMoveHandlerRef.current) { window.removeEventListener("pointermove", pointerMoveHandlerRef.current); } if (pointerUpHandlerRef.current) { window.removeEventListener("pointerup", pointerUpHandlerRef.current); } const onPointerMove = (moveEvent: globalThis.PointerEvent) => { const current = recordingStateRef.current; if (current === "idle") { return; } const deltaY = pointerStartYRef.current - moveEvent.clientY; const deltaX = pointerStartXRef.current - moveEvent.clientX; if (current === "recording" && deltaY > 70) { setRecordingState("locked"); setDragHint("idle"); return; } if (current === "recording") { if (deltaX > 90) { pointerCancelArmedRef.current = true; setDragHint("cancel"); } else if (deltaY > 40) { pointerCancelArmedRef.current = false; setDragHint("lock"); } else { pointerCancelArmedRef.current = false; setDragHint("idle"); } } }; const onPointerUp = () => { if (pointerMoveHandlerRef.current) { window.removeEventListener("pointermove", pointerMoveHandlerRef.current); } if (pointerUpHandlerRef.current) { window.removeEventListener("pointerup", pointerUpHandlerRef.current); } pointerMoveHandlerRef.current = null; pointerUpHandlerRef.current = null; const current = recordingStateRef.current; if (current === "recording") { stopRecord(!pointerCancelArmedRef.current); } setDragHint("idle"); }; pointerMoveHandlerRef.current = onPointerMove; pointerUpHandlerRef.current = onPointerUp; window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", onPointerUp); } function selectFile(file: File) { setUploadError(null); setSelectedFile(file); setShowAttachMenu(false); const fileType = inferType(file); setSelectedType(fileType); if (previewUrl) { URL.revokeObjectURL(previewUrl); } if (fileType === "image" || fileType === "video") { setPreviewUrl(URL.createObjectURL(file)); } else { setPreviewUrl(null); } } async function sendSelectedFile() { if (!selectedFile) { return; } const uploadFile = await prepareFileForUpload(selectedFile, selectedType); await handleUpload(uploadFile, selectedType); if (captionDraft.trim() && activeChatId && me) { const clientMessageId = makeClientMessageId(); const textValue = captionDraft.trim(); addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "text", text: textValue, clientMessageId }); try { const message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId); confirmMessageByClientId(activeChatId, clientMessageId, message); } catch { removeOptimisticMessage(activeChatId, clientMessageId); } } if (previewUrl) { URL.revokeObjectURL(previewUrl); } setSelectedFile(null); setPreviewUrl(null); setCaptionDraft(""); setSelectedType("file"); setUploadProgress(0); } function cancelSelectedFile() { if (previewUrl) { URL.revokeObjectURL(previewUrl); } setSelectedFile(null); setPreviewUrl(null); setCaptionDraft(""); setSelectedType("file"); setUploadProgress(0); setUploadError(null); } function formatBytes(size: number): string { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; return `${(size / (1024 * 1024)).toFixed(1)} MB`; } function insertFormatting(startTag: string, endTag = startTag, placeholder = "text") { const textarea = textareaRef.current; if (!textarea) { return; } const start = textarea.selectionStart ?? text.length; const end = textarea.selectionEnd ?? text.length; const selected = text.slice(start, end); const middle = selected || placeholder; const nextValue = `${text.slice(0, start)}${startTag}${middle}${endTag}${text.slice(end)}`; setText(nextValue); if (activeChatId) { setDraft(activeChatId, nextValue); } requestAnimationFrame(() => { textarea.focus(); if (selected) { const pos = start + startTag.length + middle.length + endTag.length; textarea.setSelectionRange(pos, pos); } else { const selStart = start + startTag.length; const selEnd = selStart + middle.length; textarea.setSelectionRange(selStart, selEnd); } }); } function insertLink() { const textarea = textareaRef.current; if (!textarea) { return; } const start = textarea.selectionStart ?? text.length; const end = textarea.selectionEnd ?? text.length; const selected = text.slice(start, end).trim() || "text"; const href = window.prompt("Enter URL (https://...)"); if (!href) { return; } const link = `[${selected}](${href.trim()})`; const nextValue = `${text.slice(0, start)}${link}${text.slice(end)}`; setText(nextValue); if (activeChatId) { setDraft(activeChatId, nextValue); } requestAnimationFrame(() => { textarea.focus(); const pos = start + link.length; textarea.setSelectionRange(pos, pos); }); } return (
{activeChatId && replyToByChat[activeChatId] ? (

Replying

{replyToByChat[activeChatId]?.text || "[media]"}

) : null} {recordingState !== "idle" ? (
🎤 Recording {formatDuration(recordSeconds)} {recordingState === "recording" ? ( {dragHint === "cancel" ? "Release to cancel" : dragHint === "lock" ? "Release up to lock" : "Slide up to lock, left to cancel"} ) : ( Locked )}
{recordingState === "locked" ? (
) : null}
) : null} {showFormatMenu ? (
) : null}
{showAttachMenu ? (
) : null} { const file = event.target.files?.[0]; if (file) { selectFile(file); } event.currentTarget.value = ""; }} /> { const file = event.target.files?.[0]; if (file) { selectFile(file); } event.currentTarget.value = ""; }} />