import { useState, useRef, useEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Send, Paperclip, Smile, Mic, X, Reply, Pencil, Image as ImageIcon, FileText, Music, Clock, ChevronLeft, ChevronRight, Calendar, Check, } from 'lucide-react'; import { useChatStore } from '../../application/chatStore'; import { useAuthStore } from '../../../auth/application/authStore'; import { ChatApi } from '../../infrastructure/chatApi'; import { getSocket } from '../../../../core/infrastructure/socket'; import { useLang } from '../../../../core/infrastructure/i18n'; import { AUDIO_EXTENSIONS, MAX_FILE_SIZE, type ChatMember } from '../../../../core/domain/types'; import { useNotificationStore } from '../../../../core/application/stores/notificationStore'; import EmojiPicker from './EmojiPicker'; interface Attachment { file: File; preview?: string; type: 'image' | 'video' | 'file' | 'audio'; } interface MessageInputProps { chatId: string; } export default function MessageInput({ chatId }: MessageInputProps) { const { user } = useAuthStore(); const { t } = useLang(); const { replyTo, editingMessage, setReplyTo, setEditingMessage, getDraft, setDraft, chats } = useChatStore(); const [text, setText] = useState(() => getDraft(chatId)); // Get current chat members for @mentions const chat = chats.find(c => c.id === chatId); const isGroup = chat?.type === 'group'; const chatMembers = (chat?.members || []).filter((m) => m.user.id !== user?.id); const [showEmoji, setShowEmoji] = useState(false); const [isRecording, setIsRecording] = useState(false); const [recordingTime, setRecordingTime] = useState(0); const [showAttachMenu, setShowAttachMenu] = useState(false); const [attachments, setAttachments] = useState([]); const [isSending, setIsSending] = useState(false); const [isDragging, setIsDragging] = useState(false); const [formatMenu, setFormatMenu] = useState<{ show: boolean; x: number; y: number }>({ show: false, x: 0, y: 0 }); const [mentionQuery, setMentionQuery] = useState(null); const [mentionIndex, setMentionIndex] = useState(0); const [showSchedule, setShowSchedule] = useState(false); const [scheduleDate, setScheduleDate] = useState(''); const [scheduleStep, setScheduleStep] = useState<'presets' | 'custom'>('presets'); const [scheduleHour, setScheduleHour] = useState('12'); const [scheduleMinute, setScheduleMinute] = useState('00'); const [scheduleCalDate, setScheduleCalDate] = useState(''); // YYYY-MM-DD const [scheduleCalMonth, setScheduleCalMonth] = useState(new Date().getMonth()); const [scheduleCalYear, setScheduleCalYear] = useState(new Date().getFullYear()); const [scheduleToast, setScheduleToast] = useState(null); // Filtered members for @mention const filteredMembers = mentionQuery !== null && isGroup ? chatMembers.filter((m) => { const q = mentionQuery.toLowerCase(); return (m.user.displayName || '').toLowerCase().includes(q) || (m.user.userName || '').toLowerCase().includes(q) || (m.user.username || '').toLowerCase().includes(q); }).slice(0, 6) : []; const insertMention = (member: ChatMember) => { const el = inputRef.current; if (!el) return; const username = member.user.userName || member.user.username; if (!username) return; const cursorPos = el.selectionStart; const before = text.substring(0, cursorPos); const after = text.substring(cursorPos); // Find the @ that started this mention const atIdx = before.lastIndexOf('@'); if (atIdx === -1) return; const newText = before.substring(0, atIdx) + `@${username} ` + after; setText(newText); setDraft(chatId, newText); setMentionQuery(null); setMentionIndex(0); setTimeout(() => { el.focus(); const newPos = atIdx + username.length + 2; el.setSelectionRange(newPos, newPos); }, 0); }; const inputRef = useRef(null); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); const timerRef = useRef>(undefined); const typingTimeoutRef = useRef>(undefined); const fileInputRef = useRef(null); const imageInputRef = useRef(null); const analyserRef = useRef(null); const audioContextRef = useRef(null); const animFrameRef = useRef(0); const recordingTimeRef = useRef(0); const streamRef = useRef(null); const [liveBars, setLiveBars] = useState(() => Array(32).fill(5)); // Cleanup recording resources on unmount useEffect(() => { return () => { if (timerRef.current) clearInterval(timerRef.current); if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); if (audioContextRef.current) audioContextRef.current.close().catch(() => {}); if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop()); if (mediaRecorderRef.current?.state === 'recording') { mediaRecorderRef.current.stop(); } }; }, []); // Автоподгон высоты textarea useEffect(() => { const el = inputRef.current; if (el) { el.style.height = '22px'; // Reset/Min height if (text) { el.style.height = Math.min(el.scrollHeight, 150) + 'px'; } } }, [text]); // При редактировании — заполнить текст useEffect(() => { if (editingMessage?.content) { setText(editingMessage.content); inputRef.current?.focus(); } }, [editingMessage]); // При ответе - фокус на поле ввода useEffect(() => { if (replyTo) { inputRef.current?.focus(); } }, [replyTo]); // Load draft when switching chats useEffect(() => { if (!editingMessage) { setText(getDraft(chatId)); } }, [chatId]); // Cleanup preview URLs useEffect(() => { (window as any).hasUnsavedAttachments = attachments.length > 0; return () => { attachments.forEach(a => { if (a.preview) URL.revokeObjectURL(a.preview); }); (window as any).hasUnsavedAttachments = false; }; }, [attachments]); // Typing events const emitTyping = useCallback(() => { const socket = getSocket(); if (!socket) return; socket.emit('typing_start', chatId); if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); typingTimeoutRef.current = setTimeout(() => { socket.emit('typing_stop', chatId); }, 2000); }, [chatId]); const handleSend = async (scheduledAt?: string) => { const trimmed = text.trim(); const hasAttachments = attachments.length > 0; if (!trimmed && !hasAttachments) return; if (isSending) return; const socket = getSocket(); if (!socket) return; // Остановить typing socket.emit('typing_stop', chatId); if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); if (editingMessage) { socket.emit('edit_message', { messageId: editingMessage.id, content: trimmed, chatId, }); setEditingMessage(null); setText(''); setDraft(chatId, ''); return; } if (hasAttachments) { setIsSending(true); try { const uploadPromises = attachments.map(a => ChatApi.uploadFile(a.file)); const results = await Promise.all(uploadPromises); const socketAttachments = results.map((res, i) => ({ type: attachments[i].type, url: res.url, fileName: res.filename, fileSize: res.size })); socket.emit('send_message', { chatId, content: trimmed || null, type: attachments.length > 0 ? (attachments.every(a => a.type === 'image') ? 'image' : 'file') : 'text', attachments: socketAttachments, replyToId: replyTo?.id || null, quote: replyTo?.quote || null, ...(scheduledAt ? { scheduledAt } : {}), }); setReplyTo(null); clearAttachments(); } catch (e) { console.error('Ошибка загрузки файла:', e); const { addNotification } = useNotificationStore.getState(); addNotification('error', t('uploadError') || 'Ошибка загрузки файла'); } finally { setIsSending(false); } } else { socket.emit('send_message', { chatId, content: trimmed, type: 'text', replyToId: replyTo?.id || null, quote: replyTo?.quote || null, ...(scheduledAt ? { scheduledAt } : {}), }); setReplyTo(null); } setText(''); setDraft(chatId, ''); }; const handleKeyDown = (e: React.KeyboardEvent) => { // Handle @mention navigation if (mentionQuery !== null && filteredMembers.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => (i + 1) % filteredMembers.length); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => (i - 1 + filteredMembers.length) % filteredMembers.length); return; } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertMention(filteredMembers[mentionIndex]); return; } if (e.key === 'Escape') { e.preventDefault(); setMentionQuery(null); return; } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const clearAttachments = () => { attachments.forEach(a => { if (a.preview) URL.revokeObjectURL(a.preview); }); setAttachments([]); }; const removeAttachment = (index: number) => { setAttachments(prev => { const newArr = [...prev]; if (newArr[index].preview) URL.revokeObjectURL(newArr[index].preview!); newArr.splice(index, 1); return newArr; }); }; const handleFileChange = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length > 0) { const { addNotification } = useNotificationStore.getState(); const newAttachments: Attachment[] = []; let tooLarge = false; let limitExceeded = false; for (const file of files) { if (attachments.length + newAttachments.length >= 20) { limitExceeded = true; break; } if (file.size > MAX_FILE_SIZE) { tooLarge = true; continue; } const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext)); newAttachments.push({ file, type: isAudio ? 'audio' : 'file' }); } if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Некоторые файлы слишком большие'); if (limitExceeded) addNotification('warning', 'Максимум 20 файлов'); setAttachments(prev => [...prev, ...newAttachments]); inputRef.current?.focus(); } e.target.value = ''; setShowAttachMenu(false); }; const handleImageChange = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length > 0) { const { addNotification } = useNotificationStore.getState(); const newAttachments: Attachment[] = []; let limitExceeded = false; for (const file of files) { if (attachments.length + newAttachments.length >= 20) { limitExceeded = true; break; } const isVideo = file.type.startsWith('video/'); const preview = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined; newAttachments.push({ file, preview, type: isVideo ? 'video' : 'image' }); } if (limitExceeded) addNotification('warning', 'Максимум 20 файлов'); setAttachments(prev => [...prev, ...newAttachments]); inputRef.current?.focus(); } e.target.value = ''; setShowAttachMenu(false); }; // Запись голосового const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); streamRef.current = stream; // Use ogg/opus for better compatibility, fallback to webm const mimeType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus') ? 'audio/ogg;codecs=opus' : MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'; const ext = mimeType.includes('ogg') ? 'ogg' : 'webm'; const recorder = new MediaRecorder(stream, { mimeType }); mediaRecorderRef.current = recorder; chunksRef.current = []; // Set up AnalyserNode for live waveform const actx = new AudioContext(); const source = actx.createMediaStreamSource(stream); const analyser = actx.createAnalyser(); analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.6; source.connect(analyser); audioContextRef.current = actx; analyserRef.current = analyser; const timeDomainData = new Uint8Array(analyser.frequencyBinCount); const updateBars = () => { if (!analyserRef.current) return; analyserRef.current.getByteTimeDomainData(timeDomainData); // Downsample to 32 bars const bars: number[] = []; const step = Math.floor(timeDomainData.length / 32); for (let i = 0; i < 32; i++) { let sum = 0; for (let j = 0; j < step; j++) { const val = Math.abs(timeDomainData[i * step + j] - 128); sum += val; } const avg = sum / step; // Map 0-128 to 8-100 with some exaggeration for visibility bars.push(Math.max(8, Math.min(100, avg * 1.8 + 8))); } setLiveBars(bars); animFrameRef.current = requestAnimationFrame(updateBars); }; animFrameRef.current = requestAnimationFrame(updateBars); recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; recorder.onstop = async () => { stream.getTracks().forEach((t) => t.stop()); streamRef.current = null; const blob = new Blob(chunksRef.current, { type: mimeType }); const file = new File([blob], `voice.${ext}`, { type: mimeType }); try { const result = await ChatApi.uploadFile(file); const socket = getSocket(); if (socket) { socket.emit('send_message', { chatId, content: null, type: 'voice', attachments: [{ type: 'voice', url: result.url, fileName: result.filename, fileSize: result.size }], replyToId: replyTo?.id || null, quote: replyTo?.quote || null, }); setReplyTo(null); } } catch (e) { console.error('Ошибка отправки голосового:', e); } }; recorder.start(); setIsRecording(true); setRecordingTime(0); recordingTimeRef.current = 0; timerRef.current = setInterval(() => { recordingTimeRef.current += 1; setRecordingTime((t) => t + 1); }, 1000); } catch (e) { console.error('Ошибка записи:', e); } }; const cleanupAnalyser = () => { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); analyserRef.current = null; if (audioContextRef.current) { audioContextRef.current.close().catch(() => {}); audioContextRef.current = null; } setLiveBars(Array(32).fill(5)); }; const stopRecording = () => { if (mediaRecorderRef.current?.state === 'recording') { mediaRecorderRef.current.stop(); } if (timerRef.current) clearInterval(timerRef.current); cleanupAnalyser(); setIsRecording(false); setRecordingTime(0); // recordingTimeRef is consumed in onstop, don't reset here }; const cancelRecording = () => { if (mediaRecorderRef.current?.state === 'recording') { mediaRecorderRef.current.ondataavailable = null; mediaRecorderRef.current.onstop = null; mediaRecorderRef.current.stop(); mediaRecorderRef.current.stream?.getTracks().forEach((t) => t.stop()); streamRef.current = null; } if (timerRef.current) clearInterval(timerRef.current); cleanupAnalyser(); setIsRecording(false); setRecordingTime(0); recordingTimeRef.current = 0; }; const formatTime = (sec: number) => { const m = Math.floor(sec / 60); const s = sec % 60; return `${m}:${s.toString().padStart(2, '0')}`; }; const handleInputContextMenu = (e: React.MouseEvent) => { const el = inputRef.current; if (el && el.selectionStart !== el.selectionEnd) { e.preventDefault(); setFormatMenu({ show: true, x: e.clientX, y: e.clientY }); } }; const applyFormat = (prefix: string, suffix: string) => { const el = inputRef.current; if (!el) return; const start = el.selectionStart; const end = el.selectionEnd; const val = el.value; const selected = val.substring(start, end); const newVal = val.substring(0, start) + prefix + selected + suffix + val.substring(end); setText(newVal); setFormatMenu({ show: false, x: 0, y: 0 }); // refocus and update cursor setTimeout(() => { el.focus(); el.setSelectionRange(start + prefix.length, end + prefix.length); }, 0); }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { const files = Array.from(e.dataTransfer.files); const { addNotification } = useNotificationStore.getState(); const newAttachments: Attachment[] = []; let tooLarge = false; let limitExceeded = false; for (const file of files) { if (attachments.length + newAttachments.length >= 20) { limitExceeded = true; break; } if (file.size > MAX_FILE_SIZE) { tooLarge = true; continue; } const isVideo = file.type.startsWith('video/'); const isImage = file.type.startsWith('image/'); const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma', '.opus']; const isAudio = file.type.startsWith('audio/') || audioExts.some(ext => file.name.toLowerCase().endsWith(ext)); const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file'; const preview = isImage ? URL.createObjectURL(file) : undefined; newAttachments.push({ file, type, preview }); } if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Некоторые файлы слишком большие'); if (limitExceeded) addNotification('warning', 'Максимум 20 файлов'); setAttachments(prev => [...prev, ...newAttachments]); inputRef.current?.focus(); } }; const hasContent = text.trim() || attachments.length > 0; return (
{/* Drag overlay */} {isDragging && (
upload_file

{t('dropFileHere')}

)}
{/* Reply / Edit indicator */} {(replyTo || editingMessage) && (
{editingMessage ? 'edit' : 'reply'}

{editingMessage ? t('editing') : `${t('replyTo')} ${replyTo?.sender?.displayName || replyTo?.sender?.userName || replyTo?.sender?.username || ''}`}

{replyTo?.quote ? `«${replyTo.quote}»` : (editingMessage || replyTo)?.content || t('media') || 'Медиа'}
)} {/* Attachment previews */} {attachments.length > 0 && (
{attachments.map((att, idx) => ( {att.preview ? ( ) : att.type === 'video' ? (
) : att.type === 'audio' ? (
) : (
)}

{att.file.name}

))}
{attachments.length} {t('files')} ({ (attachments.reduce((acc, a) => acc + a.file.size, 0) / 1024 / 1024).toFixed(2) } MB)
)}
{/* Recording UI */} {isRecording ? (
{formatTime(recordingTime)}
{liveBars.map((h, i) => (
))}
) : (
{/* Attach - Outside */}
{showAttachMenu && ( <>
setShowAttachMenu(false)} /> )}
{/* Main Input Pill */}