import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react' import { AlertCircle, Send, SmilePlus, X } from 'lucide-react' import type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat' import { formatLocalTime } from '@/lib/localTime' import { useModalDismiss } from '@/hooks/useModalDismiss' /** * Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на * сотни килобайт ради десятка кнопок в поповере. */ const EMOJI_OPTIONS = [ '😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢', '😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️', '🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝', '🐎', ] interface ChatPanelProps { messages: ChatMessageOut[] status: ChatConnectionStatus statusMessage: string | null onSend: (text: string) => void onClose: () => void } /** * Панель чата комнаты конференции (см. design/mockups/room.html, блок * `.chat-panel`). Всегда в развёрнутом виде рендерится только пока сама * панель открыта — сворачивание/разворачивание и счётчик непрочитанных * управляются на уровне RoomPage (WS-соединение живёт независимо от того, * открыта ли панель, — иначе при сворачивании терялась бы история). * * Textarea, а не `` из макета — сознательное отступление ради * Enter/Shift+Enter (перенос строки), стили сохранены визуально идентичными * пилюле-полю из макета. */ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }: ChatPanelProps) { const [draft, setDraft] = useState('') const listRef = useRef(null) const textareaRef = useRef(null) // Автоскролл к последнему сообщению — только если пользователь и так был // внизу списка, чтобы не мешать чтению прокрученной вверх истории. useEffect(() => { const el = listRef.current if (!el) return const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight if (distanceFromBottom < 120) { el.scrollTop = el.scrollHeight } }, [messages]) // Авторасширение textarea до 4 строк, дальше — внутренний скролл. Эффектом, // а не только в обработчике ввода, — так пересчёт срабатывает и после // программной вставки эмодзи, и после очистки поля отправкой. useEffect(() => { const el = textareaRef.current if (!el) return el.style.height = 'auto' el.style.height = `${Math.min(el.scrollHeight, 96)}px` }, [draft]) function handleSend() { const text = draft.trim() if (!text || status !== 'open') return onSend(text) setDraft('') } function handleKeyDown(event: KeyboardEvent) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() handleSend() } } function handleDraftChange(event: ChangeEvent) { setDraft(event.target.value) } // Вставка эмодзи в позицию курсора, а не в конец поля, — ожидаемое поведение // при редактировании текста в середине. function insertEmoji(emoji: string) { const el = textareaRef.current if (!el) { setDraft((current) => current + emoji) return } const start = el.selectionStart ?? draft.length const end = el.selectionEnd ?? draft.length setDraft(draft.slice(0, start) + emoji + draft.slice(end)) // value обновится реальным React-рендером только на следующий тик — // курсор возвращаем после него, иначе setSelectionRange встанет на // ещё старую (более короткую) строку. requestAnimationFrame(() => { const pos = start + emoji.length el.focus() el.setSelectionRange(pos, pos) }) } return (