import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; import { attachFile, editMessage, 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"; const STICKER_PRESETS: Array<{ name: string; url: string }> = [ { name: "Thumbs Up", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f44d.png" }, { name: "Party", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f389.png" }, { name: "Fire", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f525.png" }, { name: "Heart", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/2764.png" }, { name: "Cool", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f60e.png" }, { name: "Rocket", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f680.png" }, { name: "Clap", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f44f.png" }, { name: "Star", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/2b50.png" }, ]; const GIF_PRESETS: Array<{ name: string; url: string }> = [ { name: "Cat Typing", url: "https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif" }, { name: "Thumbs Up", url: "https://media.giphy.com/media/111ebonMs90YLu/giphy.gif" }, { name: "Excited", url: "https://media.giphy.com/media/5VKbvrjxpVJCM/giphy.gif" }, { name: "Dance", url: "https://media.giphy.com/media/l0MYt5jPR6QX5pnqM/giphy.gif" }, { name: "Nice", url: "https://media.giphy.com/media/3o7abKhOpu0NwenH3O/giphy.gif" }, { name: "Wow", url: "https://media.giphy.com/media/3oEjI6SIIHBdRxXI40/giphy.gif" }, { name: "Thanks", url: "https://media.giphy.com/media/26ufdipQqU2lhNA4g/giphy.gif" }, { name: "Success", url: "https://media.giphy.com/media/4T7e4DmcrP9du/giphy.gif" }, ]; const STICKER_FAVORITES_KEY = "bm_sticker_favorites_v1"; const GIF_FAVORITES_KEY = "bm_gif_favorites_v1"; const GIF_PROVIDER = (import.meta.env.VITE_GIF_PROVIDER ?? "").toLowerCase(); const TENOR_API_KEY = (import.meta.env.VITE_TENOR_API_KEY ?? "").trim(); const TENOR_CLIENT_KEY = (import.meta.env.VITE_TENOR_CLIENT_KEY ?? "benya_messenger_web").trim(); const GIPHY_API_KEY = (import.meta.env.VITE_GIPHY_API_KEY ?? "").trim(); const GIF_SEARCH_ENABLED = (GIF_PROVIDER === "tenor" && TENOR_API_KEY.length > 0) || (GIF_PROVIDER === "giphy" && GIPHY_API_KEY.length > 0); function loadFavorites(key: string): Set { try { const raw = window.localStorage.getItem(key); if (!raw) return new Set(); const parsed = JSON.parse(raw) as string[]; if (!Array.isArray(parsed)) return new Set(); return new Set(parsed.filter((item) => typeof item === "string")); } catch { return new Set(); } } function saveFavorites(key: string, values: Set): void { try { window.localStorage.setItem(key, JSON.stringify([...values])); } catch { return; } } function pickSupportedAudioMimeType(): string | undefined { if (typeof MediaRecorder === "undefined" || typeof MediaRecorder.isTypeSupported !== "function") { return undefined; } const candidates = [ "audio/ogg;codecs=opus", "audio/mp4", "audio/webm;codecs=opus", "audio/webm", ]; for (const mime of candidates) { if (MediaRecorder.isTypeSupported(mime)) { return mime; } } return undefined; } export function MessageComposer() { const activeChatId = useChatStore((s) => s.activeChatId); const chats = useChatStore((s) => s.chats); 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 editingByChat = useChatStore((s) => s.editingByChat); const setEditingMessage = useChatStore((s) => s.setEditingMessage); const upsertMessage = useChatStore((s) => s.upsertMessage); const accessToken = useAuthStore((s) => s.accessToken); const [text, setText] = useState(""); const wsRef = useRef(null); const wsTokenRef = 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 lastEditingMessageIdRef = useRef(null); const prevActiveChatIdRef = useRef(null); const typingActiveRef = useRef(false); const typingStopTimerRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); const [selectedFiles, setSelectedFiles] = useState([]); 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 [showStickerMenu, setShowStickerMenu] = useState(false); const [showGifMenu, setShowGifMenu] = useState(false); const [stickerTab, setStickerTab] = useState<"all" | "favorites">("all"); const [stickerQuery, setStickerQuery] = useState(""); const [gifTab, setGifTab] = useState<"all" | "favorites">("all"); const [gifQuery, setGifQuery] = useState(""); const [gifResults, setGifResults] = useState>([]); const [gifLoading, setGifLoading] = useState(false); const [gifSearchError, setGifSearchError] = useState(null); const [favoriteStickers, setFavoriteStickers] = useState>(() => loadFavorites(STICKER_FAVORITES_KEY)); const [favoriteGifs, setFavoriteGifs] = useState>(() => loadFavorites(GIF_FAVORITES_KEY)); 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; const activeChat = chats.find((chat) => chat.id === activeChatId); const editingMessage = activeChatId ? (editingByChat[activeChatId] ?? null) : null; const canSendInActiveChat = Boolean( activeChatId && activeChat && (activeChat.type !== "channel" || activeChat.my_role === "owner" || activeChat.my_role === "admin" || activeChat.is_saved) ); async function searchGifs(term: string): Promise> { if (GIF_PROVIDER === "tenor") { const params = new URLSearchParams({ q: term, key: TENOR_API_KEY, client_key: TENOR_CLIENT_KEY, limit: "24", media_filter: "gif", contentfilter: "medium", }); const response = await fetch(`https://tenor.googleapis.com/v2/search?${params.toString()}`); if (!response.ok) { throw new Error("tenor search failed"); } const data = (await response.json()) as { results?: Array<{ content_description?: string; media_formats?: { gif?: { url?: string } } }>; }; return ( data.results ?.map((item) => ({ name: item.content_description || "GIF", url: item.media_formats?.gif?.url || "", })) .filter((item) => item.url.length > 0) ?? [] ); } if (GIF_PROVIDER === "giphy") { const params = new URLSearchParams({ api_key: GIPHY_API_KEY, q: term, limit: "24", rating: "pg-13", lang: "en", }); const response = await fetch(`https://api.giphy.com/v1/gifs/search?${params.toString()}`); if (!response.ok) { throw new Error("giphy search failed"); } const data = (await response.json()) as { data?: Array<{ title?: string; images?: { fixed_height?: { url?: string }; original?: { url?: string }; }; }>; }; return ( data.data ?.map((item) => ({ name: item.title || "GIF", url: item.images?.fixed_height?.url || item.images?.original?.url || "", })) .filter((item) => item.url.length > 0) ?? [] ); } return []; } useEffect(() => { recordingStateRef.current = recordingState; }, [recordingState]); useEffect(() => { if (!activeChatId) { if (text !== "") { setText(""); } lastEditingMessageIdRef.current = null; return; } if (editingMessage) { if (lastEditingMessageIdRef.current !== editingMessage.id) { setText(editingMessage.text ?? ""); } lastEditingMessageIdRef.current = editingMessage.id; return; } lastEditingMessageIdRef.current = null; const draft = draftsByChat[activeChatId] ?? ""; if (draft !== text) { setText(draft); } }, [activeChatId, draftsByChat, editingMessage, text]); useEffect(() => { return () => { if (previewUrl) { URL.revokeObjectURL(previewUrl); } if (recordingStateRef.current !== "idle") { stopRecord(false); } if (pointerMoveHandlerRef.current) { window.removeEventListener("pointermove", pointerMoveHandlerRef.current); } if (pointerUpHandlerRef.current) { window.removeEventListener("pointerup", pointerUpHandlerRef.current); } if (typingStopTimerRef.current !== null) { window.clearTimeout(typingStopTimerRef.current); } wsRef.current?.close(); wsRef.current = null; wsTokenRef.current = null; }; }, [previewUrl]); useEffect(() => { const activeToken = accessToken ?? null; if (wsRef.current && wsTokenRef.current !== activeToken) { wsRef.current.close(); wsRef.current = null; } wsTokenRef.current = activeToken; }, [accessToken]); useEffect(() => { if (!activeChatId && recordingStateRef.current !== "idle") { stopRecord(false); } }, [activeChatId]); useEffect(() => { const prev = prevActiveChatIdRef.current; if (prev && prev !== activeChatId) { sendWsEvent("typing_stop", { chat_id: prev }); typingActiveRef.current = false; if (typingStopTimerRef.current !== null) { window.clearTimeout(typingStopTimerRef.current); typingStopTimerRef.current = null; } } prevActiveChatIdRef.current = activeChatId; }, [activeChatId]); useEffect(() => { const onVisibility = () => { if (document.visibilityState === "hidden" && recordingStateRef.current === "recording") { setRecordingState("locked"); } }; const onPageHide = () => { if (recordingStateRef.current !== "idle") { stopRecord(false); } }; document.addEventListener("visibilitychange", onVisibility); window.addEventListener("pagehide", onPageHide); return () => { document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener("pagehide", onPageHide); }; }, []); 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 && wsTokenRef.current !== accessToken) { wsRef.current.close(); wsRef.current = null; } if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { return wsRef.current; } const wsUrl = buildWsUrl(accessToken); wsRef.current = new WebSocket(wsUrl); wsTokenRef.current = accessToken; return wsRef.current; } function sendWsEvent(eventName: string, payload: Record) { const ws = getWs(); if (!ws || ws.readyState !== WebSocket.OPEN) { return; } try { ws.send(JSON.stringify({ event: eventName, payload })); } catch { return; } } function sendRealtimeChatEvent( eventName: | "typing_start" | "typing_stop" | "recording_voice_start" | "recording_voice_stop" ) { if (!activeChatId) { return; } sendWsEvent(eventName, { chat_id: activeChatId }); } function emitTypingStopIfActive() { if (!activeChatId) { return; } if (typingStopTimerRef.current !== null) { window.clearTimeout(typingStopTimerRef.current); typingStopTimerRef.current = null; } if (typingActiveRef.current) { sendRealtimeChatEvent("typing_stop"); typingActiveRef.current = false; } } function syncTypingForText(nextText: string) { if (!activeChatId) { return; } const hasText = nextText.trim().length > 0; if (!hasText) { emitTypingStopIfActive(); return; } if (!typingActiveRef.current) { sendRealtimeChatEvent("typing_start"); typingActiveRef.current = true; } if (typingStopTimerRef.current !== null) { window.clearTimeout(typingStopTimerRef.current); } typingStopTimerRef.current = window.setTimeout(() => { if (!typingActiveRef.current) { return; } sendRealtimeChatEvent("typing_stop"); typingActiveRef.current = false; typingStopTimerRef.current = null; }, 2500); } async function handleSend() { if (!activeChatId || !text.trim() || !me || !canSendInActiveChat) { return; } if (editingMessage) { try { const updated = await editMessage(editingMessage.id, text.trim()); upsertMessage(activeChatId, updated); setEditingMessage(activeChatId, null); setText(""); clearDraft(activeChatId); } catch { setUploadError("Edit failed. Message may be older than 7 days."); } 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); emitTypingStopIfActive(); } catch { removeOptimisticMessage(activeChatId, clientMessageId); setUploadError("Message send failed. Please try again."); } } async function sendPresetMedia(url: string) { if (!activeChatId || !me || !canSendInActiveChat) { return; } const clientMessageId = makeClientMessageId(); const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined; addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "image", text: url, clientMessageId }); try { const message = await sendMessageWithClientId(activeChatId, url, "image", clientMessageId, replyToMessageId); confirmMessageByClientId(activeChatId, clientMessageId, message); setReplyToMessage(activeChatId, null); setShowStickerMenu(false); setShowGifMenu(false); setGifQuery(""); } catch { removeOptimisticMessage(activeChatId, clientMessageId); setUploadError("Failed to send media"); } } function toggleStickerFavorite(url: string) { setFavoriteStickers((prev) => { const next = new Set(prev); if (next.has(url)) { next.delete(url); } else { next.add(url); } saveFavorites(STICKER_FAVORITES_KEY, next); return next; }); } function toggleGifFavorite(url: string) { setFavoriteGifs((prev) => { const next = new Set(prev); if (next.has(url)) { next.delete(url); } else { next.add(url); } saveFavorites(GIF_FAVORITES_KEY, next); return next; }); } useEffect(() => { const term = gifQuery.trim(); if (!showGifMenu || term.length < 2) { setGifResults([]); setGifLoading(false); setGifSearchError(null); return; } if (!GIF_SEARCH_ENABLED) { setGifResults([]); setGifLoading(false); setGifSearchError("GIF search is not configured. Using preset GIFs."); return; } let cancelled = false; const timer = window.setTimeout(() => { setGifLoading(true); setGifSearchError(null); void (async () => { try { const rows = await searchGifs(term); if (cancelled) return; setGifResults(rows); } catch { if (!cancelled) { setGifResults([]); setGifSearchError("GIF search is unavailable right now. Using preset GIFs."); } } finally { if (!cancelled) { setGifLoading(false); } } })(); }, 280); return () => { cancelled = true; window.clearTimeout(timer); }; }, [gifQuery, showGifMenu]); function onComposerKeyDown(event: KeyboardEvent) { const hasModifier = event.ctrlKey || event.metaKey; if (hasModifier) { const key = event.key.toLowerCase(); if (key === "b") { event.preventDefault(); insertFormatting("**", "**"); return; } if (key === "i") { event.preventDefault(); insertFormatting("*", "*"); return; } if (key === "u") { event.preventDefault(); insertFormatting("__", "__"); return; } if (key === "k") { event.preventDefault(); insertLink(); return; } if (event.shiftKey && key === "x") { event.preventDefault(); insertFormatting("~~", "~~"); return; } if (event.shiftKey && (event.key === "`" || event.code === "Backquote")) { event.preventDefault(); insertFormatting("`", "`"); return; } } 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 || !canSendInActiveChat) { 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" || !canSendInActiveChat) { return false; } try { if (!("mediaDevices" in navigator) || !navigator.mediaDevices.getUserMedia) { setUploadError("Microphone is not supported in this browser."); return false; } 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 mimeType = pickSupportedAudioMimeType(); const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : 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 outputMime = recorder.mimeType || mimeType || "audio/webm"; const blob = new Blob(data, { type: outputMime }); if (blob.size < 512) { setUploadError("Voice message is empty. Please try recording again."); return; } const ext = outputMime.includes("ogg") ? "ogg" : outputMime.includes("mp4") ? "m4a" : "webm"; const file = new File([blob], `voice-${Date.now()}.${ext}`, { type: outputMime }); const waveform = await buildWaveformPoints(blob, 64); await handleUpload(file, "voice", waveform); }; recorderRef.current = recorder; recorder.start(250); recordingStartedAtRef.current = Date.now(); setRecordSeconds(0); setRecordingState("recording"); emitTypingStopIfActive(); sendRealtimeChatEvent("recording_voice_start"); return true; } catch { setUploadError("Microphone access denied. Please allow microphone and retry."); return false; } } function stopRecord(send: boolean) { if (recordingStateRef.current !== "idle") { sendRealtimeChatEvent("recording_voice_stop"); } sendVoiceOnStopRef.current = send; pointerCancelArmedRef.current = false; setDragHint("idle"); if (recorderRef.current && recorderRef.current.state !== "inactive") { try { recorderRef.current.stop(); } catch { // ignore recorder lifecycle race during quick chat switches/unmount } } recorderRef.current = null; setRecordingState("idle"); setRecordSeconds(0); if (pointerMoveHandlerRef.current) { window.removeEventListener("pointermove", pointerMoveHandlerRef.current); pointerMoveHandlerRef.current = null; } if (pointerUpHandlerRef.current) { window.removeEventListener("pointerup", pointerUpHandlerRef.current); pointerUpHandlerRef.current = null; } } 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 finishPointerSession = () => { 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"); }; const onPointerUp = () => { finishPointerSession(); }; const onPointerCancel = () => { pointerCancelArmedRef.current = true; finishPointerSession(); }; pointerMoveHandlerRef.current = onPointerMove; pointerUpHandlerRef.current = onPointerUp; window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", onPointerUp); window.addEventListener("pointercancel", onPointerCancel, { once: true }); } function selectFiles(files: File[]) { if (!files.length) { return; } setUploadError(null); setSelectedFiles(files); setShowAttachMenu(false); const fileType = inferType(files[0]); setSelectedType(fileType); if (previewUrl) { URL.revokeObjectURL(previewUrl); } if (files.length === 1 && (fileType === "image" || fileType === "video")) { setPreviewUrl(URL.createObjectURL(files[0])); } else { setPreviewUrl(null); } } async function sendSelectedFiles() { if (!selectedFiles.length || !activeChatId || !me) { return; } setIsUploading(true); setUploadError(null); setUploadProgress(0); const prepared = await Promise.all( selectedFiles.map(async (file) => { const kind = inferType(file); const uploadFile = await prepareFileForUpload(file, kind); return { file: uploadFile, kind }; }) ); const uploaded: Array<{ fileUrl: string; fileType: string; fileSize: number; kind: "file" | "image" | "video" | "audio" }> = []; try { for (let index = 0; index < prepared.length; index += 1) { const current = prepared[index]; const upload = await requestUploadUrl(current.file); await uploadToPresignedUrl(upload.upload_url, upload.required_headers, current.file, (percent) => { const done = index / prepared.length; const currentPart = (percent / 100) / prepared.length; setUploadProgress(Math.min(100, Math.round((done + currentPart) * 100))); }); uploaded.push({ fileUrl: upload.file_url, fileType: current.file.type || "application/octet-stream", fileSize: current.file.size, kind: current.kind, }); } const kindSet = new Set(uploaded.map((item) => item.kind)); let inferredType: "file" | "image" | "video" | "audio" = "file"; if ([...kindSet].every((kind) => kind === "image")) { inferredType = "image"; } else if ([...kindSet].every((kind) => kind === "image" || kind === "video")) { inferredType = "video"; } else if ([...kindSet].every((kind) => kind === "audio")) { inferredType = "audio"; } else if (kindSet.size === 1) { inferredType = uploaded[0].kind; } const messageType = inferredType; const caption = captionDraft.trim() || null; const clientMessageId = makeClientMessageId(); addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: messageType, text: caption, clientMessageId, }); const replyToMessageId = replyToByChat[activeChatId]?.id ?? undefined; const created = await sendMessageWithClientId( activeChatId, caption, messageType, clientMessageId, replyToMessageId ); confirmMessageByClientId(activeChatId, clientMessageId, created); for (const item of uploaded) { await attachFile(created.id, item.fileUrl, item.fileType, item.fileSize); } setReplyToMessage(activeChatId, null); } catch { setUploadError("Upload failed. Please try again."); } finally { setIsUploading(false); setUploadProgress(0); } if (previewUrl) { URL.revokeObjectURL(previewUrl); } setSelectedFiles([]); setPreviewUrl(null); setCaptionDraft(""); setSelectedType("file"); } function cancelSelectedFile() { if (previewUrl) { URL.revokeObjectURL(previewUrl); } setSelectedFiles([]); 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); }); } function insertQuoteBlock() { 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 source = selected || "quote"; const quoted = source .split("\n") .map((line) => `> ${line}`) .join("\n"); const nextValue = `${text.slice(0, start)}${quoted}${text.slice(end)}`; setText(nextValue); if (activeChatId) { setDraft(activeChatId, nextValue); } requestAnimationFrame(() => { textarea.focus(); const pos = start + quoted.length; textarea.setSelectionRange(pos, pos); }); } return (
{activeChatId && replyToByChat[activeChatId] ? (

Replying

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

) : null} {activeChatId && editingMessage ? (

Editing message

{editingMessage.text || "[empty]"}

) : 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} {showStickerMenu ? (

Stickers

setStickerQuery(event.target.value)} placeholder="Search sticker" value={stickerQuery} />
{STICKER_PRESETS .filter((item) => (stickerTab === "favorites" ? favoriteStickers.has(item.url) : true)) .filter((item) => item.name.toLowerCase().includes(stickerQuery.trim().toLowerCase())) .map((sticker) => ( ))}
{STICKER_PRESETS .filter((item) => (stickerTab === "favorites" ? favoriteStickers.has(item.url) : true)) .filter((item) => item.name.toLowerCase().includes(stickerQuery.trim().toLowerCase())).length === 0 ? (

No stickers found

) : null}
) : null} {showGifMenu ? (

GIF

setGifQuery(event.target.value)} placeholder="Search GIF" value={gifQuery} />
{(gifResults.length > 0 ? gifResults : GIF_PRESETS) .filter((item) => item.name.toLowerCase().includes(gifQuery.trim().toLowerCase()) || gifResults.length > 0) .filter((item) => (gifTab === "favorites" ? favoriteGifs.has(item.url) : true)) .map((gif) => ( ))}
{gifLoading ?

Searching GIF...

: null} {gifSearchError ?

{gifSearchError}

: null}
) : null} {!canSendInActiveChat && activeChat?.type === "channel" ? (
Read-only channel: only owners and admins can post.
) : null}
{showAttachMenu ? (
) : null} { const files = event.target.files ? Array.from(event.target.files) : []; if (files.length) { selectFiles(files); } event.currentTarget.value = ""; }} /> { const files = event.target.files ? Array.from(event.target.files) : []; if (files.length) { selectFiles(files); } event.currentTarget.value = ""; }} />