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 (
)
}
interface EmojiPickerButtonProps {
onSelect: (emoji: string) => void
disabled: boolean
}
/**
* Кнопка с поповером выбора эмодзи слева от поля ввода. Поповер открывается
* ВВЕРХ от кнопки (как `.tb-menu` тулбара) — на мобильном чат-панель занята
* полем ввода снизу, а место над тулбаром свободно, так поповер никогда не
* перекрывает textarea.
*/
function EmojiPickerButton({ onSelect, disabled }: EmojiPickerButtonProps) {
const [open, setOpen] = useState(false)
const wrapRef = useRef(null)
// Закрытие по клику вне — тем же паттерном, что меню «Вид» (StageViewMenu):
// слушатель на document, пока поповер открыт.
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [open])
return (