import React, { useState, useEffect, useRef, useCallback } from 'react'; import Message from './Message'; import { parseMarkdown } from '../utils/markdown'; /* ──────────────────────────────────────────── Helper: debounce ──────────────────────────────────────────── */ function useDebouncedCallback(fn, delay) { const timer = useRef(null); return useCallback((...args) => { if (timer.current) return; fn(...args); timer.current = setTimeout(() => { timer.current = null; }, delay); }, [fn, delay]); } /* ──────────────────────────────────────────── Sub-component: MessageInput ──────────────────────────────────────────── */ function MessageInput({ token, channelId, socket, socketEvent, replyTo, onClearReply, isDm }) { const [text, setText] = useState(''); const [file, setFile] = useState(null); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [error, setError] = useState(''); const textareaRef = useRef(null); const fileRef = useRef(null); const emitTyping = useDebouncedCallback(() => { if (!socket) return; if (isDm) { socket.emit('dm-typing', { conversationId: channelId }); } else { socket.emit('typing', { channelId }); } }, 2000); const autoGrow = () => { const ta = textareaRef.current; if (!ta) return; ta.style.height = 'auto'; const maxH = 5 * 24; // ~5 lines ta.style.height = Math.min(ta.scrollHeight, maxH) + 'px'; }; const handleChange = (e) => { setText(e.target.value); autoGrow(); emitTyping(); }; const uploadFile = async (fileObj) => { const form = new FormData(); form.append('file', fileObj); setUploading(true); setUploadProgress(0); setError(''); try { // Use XMLHttpRequest for progress return await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload'); xhr.setRequestHeader('Authorization', `Bearer ${token}`); xhr.upload.onprogress = (e) => { if (e.lengthComputable) setUploadProgress(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error('Upload failed')); } }; xhr.onerror = () => reject(new Error('Upload failed')); xhr.send(form); }); } catch (err) { setError(err.message); return null; } finally { setUploading(false); setUploadProgress(0); } }; const send = async () => { const content = text.trim(); if (!content && !file) return; setError(''); let fileData = null; if (file) { fileData = await uploadFile(file); if (!fileData) return; setFile(null); if (fileRef.current) fileRef.current.value = ''; } const body = { content: content || '' }; if (replyTo) body.replyTo = replyTo.id; if (fileData) { body.file_url = fileData.url; body.file_name = fileData.name; body.file_size = fileData.size; body.file_type = fileData.type; } const endpoint = isDm ? `/api/dms/${channelId}/messages` : `/api/channels/${channelId}/messages`; try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error('Failed to send message'); setText(''); if (onClearReply) onClearReply(); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } } catch (err) { setError(err.message); } }; const handleKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }; const handleFileSelect = (e) => { if (e.target.files?.[0]) setFile(e.target.files[0]); }; return (
{replyTo && (
Replying to {replyTo.username}
)} {file && (
{file.name}
)} {uploading && (
)}