import { useEffect, useRef, useState } 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"; 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 chunksRef = useRef([]); 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 [isRecording, setIsRecording] = useState(false); 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); } }; }, [previewUrl]); 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 { const message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId, replyToMessageId); 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."); } } async function handleUpload(file: File, messageType: "file" | "image" | "video" | "audio" | "voice" = "file") { 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); await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size); confirmMessageByClientId(activeChatId, clientMessageId, message); 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() { 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; } } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const recorder = new MediaRecorder(stream); chunksRef.current = []; recorder.ondataavailable = (e) => chunksRef.current.push(e.data); recorder.onstop = async () => { const blob = new Blob(chunksRef.current, { type: "audio/webm" }); const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" }); setIsRecording(false); await handleUpload(file, "voice"); }; recorderRef.current = recorder; recorder.start(); setIsRecording(true); } catch { setUploadError("Microphone access denied. Please allow microphone and retry."); } } function stopRecord() { recorderRef.current?.stop(); recorderRef.current = null; setIsRecording(false); } function selectFile(file: File) { setUploadError(null); setSelectedFile(file); 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 (previewUrl) { URL.revokeObjectURL(previewUrl); } setSelectedFile(null); setPreviewUrl(null); setSelectedType("file"); setUploadProgress(0); } function cancelSelectedFile() { if (previewUrl) { URL.revokeObjectURL(previewUrl); } setSelectedFile(null); setPreviewUrl(null); 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`; } return (
{activeChatId && replyToByChat[activeChatId] ? (

Replying

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

) : null}
{ const next = e.target.value; setText(next); if (activeChatId) { setDraft(activeChatId, next); } if (activeChatId) { const ws = getWs(); ws?.send(JSON.stringify({ event: "typing_start", payload: { chat_id: activeChatId } })); } }} />
{selectedFile ? (
Ready to send
{selectedFile.name}
{formatBytes(selectedFile.size)}
{previewUrl && selectedType === "image" ? ( {selectedFile.name} ) : null} {previewUrl && selectedType === "video" ? (
); }