Кнопка эмодзи и все кнопки внутри поповера лежат в DOM внутри .chat-input-row (форма отправки) — той же формы, что и круглая зелёная кнопка «Отправить» (.chat-input-row button, специфичность 0,1,1). Голого класса .chat-emoji-trigger/.chat-emoji-option (0,1,0) для победы над этим правилом не хватало: все кнопки поповера красились в зелёный независимо от порядка объявления в файле — оттуда и «поплывший» вид после добавления коня, дело было не в самом коне. Каждый селектор уточнён родительским классом (.chat-emoji-wrap/.chat-emoji-popover), чтобы обойти гонку специфичности. Заодно сетка приведена к ровным 5 колонкам × 6 строкам — добавлены 🦾 🚀 🦞 💯 🤷♂️, теперь 30 эмодзи без неполной последней строки.
236 lines
9.3 KiB
TypeScript
236 lines
9.3 KiB
TypeScript
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'
|
||
|
||
/**
|
||
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
|
||
* сотни килобайт ради десятка кнопок в поповере. Ровно 30 штук (6×5 —
|
||
* `.chat-emoji-popover` в room.css рассчитан на эту сетку без остатка;
|
||
* добавляя/убирая эмодзи, держи кратность 5).
|
||
*/
|
||
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, а не `<input>` из макета — сознательное отступление ради
|
||
* Enter/Shift+Enter (перенос строки), стили сохранены визуально идентичными
|
||
* пилюле-полю из макета.
|
||
*/
|
||
export function ChatPanel({ messages, status, statusMessage, onSend, onClose }: ChatPanelProps) {
|
||
const [draft, setDraft] = useState('')
|
||
const listRef = useRef<HTMLDivElement>(null)
|
||
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) {
|
||
if (event.key === 'Enter' && !event.shiftKey) {
|
||
event.preventDefault()
|
||
handleSend()
|
||
}
|
||
}
|
||
|
||
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
|
||
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 (
|
||
<aside className="chat-panel">
|
||
<div className="chat-head">
|
||
<h2>Чат встречи</h2>
|
||
<button type="button" aria-label="Свернуть чат" onClick={onClose}>
|
||
<X className="lucide" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
|
||
{statusMessage && (
|
||
<p className="chat-status-banner">
|
||
<AlertCircle className="lucide" aria-hidden="true" /> {statusMessage}
|
||
</p>
|
||
)}
|
||
|
||
<div className="chat-messages" ref={listRef}>
|
||
{messages.length === 0 && status === 'open' && !statusMessage && (
|
||
<p className="chat-empty">Сообщений пока нет — начните обсуждение</p>
|
||
)}
|
||
{messages.map((message) => (
|
||
<div className="msg" key={message.id}>
|
||
<div className="msg-meta">
|
||
<span className="msg-author">
|
||
{message.author_name}
|
||
{message.is_guest && <span className="msg-guest-badge">гость</span>}
|
||
</span>
|
||
<span className="msg-time">{formatLocalTime(message.created_at)}</span>
|
||
</div>
|
||
<div className="msg-bubble">{message.text}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<form
|
||
className="chat-input-row"
|
||
onSubmit={(event) => {
|
||
event.preventDefault()
|
||
handleSend()
|
||
}}
|
||
>
|
||
<EmojiPickerButton onSelect={insertEmoji} disabled={status !== 'open'} />
|
||
<textarea
|
||
ref={textareaRef}
|
||
rows={1}
|
||
placeholder="Написать сообщение…"
|
||
value={draft}
|
||
onChange={handleDraftChange}
|
||
onKeyDown={handleKeyDown}
|
||
disabled={status !== 'open'}
|
||
maxLength={2000}
|
||
/>
|
||
<button type="submit" aria-label="Отправить" disabled={!draft.trim() || status !== 'open'}>
|
||
<Send className="lucide" aria-hidden="true" />
|
||
</button>
|
||
</form>
|
||
</aside>
|
||
)
|
||
}
|
||
|
||
interface EmojiPickerButtonProps {
|
||
onSelect: (emoji: string) => void
|
||
disabled: boolean
|
||
}
|
||
|
||
/**
|
||
* Кнопка с поповером выбора эмодзи слева от поля ввода. Поповер открывается
|
||
* ВВЕРХ от кнопки (как `.tb-menu` тулбара) — на мобильном чат-панель занята
|
||
* полем ввода снизу, а место над тулбаром свободно, так поповер никогда не
|
||
* перекрывает textarea.
|
||
*/
|
||
function EmojiPickerButton({ onSelect, disabled }: EmojiPickerButtonProps) {
|
||
const [open, setOpen] = useState(false)
|
||
const wrapRef = useRef<HTMLDivElement>(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 (
|
||
<div className="chat-emoji-wrap" ref={wrapRef}>
|
||
<button
|
||
type="button"
|
||
className={`chat-emoji-trigger${open ? ' is-open' : ''}`}
|
||
aria-label="Вставить эмодзи"
|
||
aria-expanded={open}
|
||
aria-haspopup="dialog"
|
||
disabled={disabled}
|
||
onClick={() => setOpen((v) => !v)}
|
||
>
|
||
<SmilePlus className="lucide" aria-hidden="true" />
|
||
</button>
|
||
{open && (
|
||
<EmojiPopover
|
||
onSelect={(emoji) => {
|
||
onSelect(emoji)
|
||
setOpen(false)
|
||
}}
|
||
onClose={() => setOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
interface EmojiPopoverProps {
|
||
onSelect: (emoji: string) => void
|
||
onClose: () => void
|
||
}
|
||
|
||
/** Отдельный компонент — чтобы `useModalDismiss` (Escape + возврат фокуса) жил ровно то время, пока поповер открыт. */
|
||
function EmojiPopover({ onSelect, onClose }: EmojiPopoverProps) {
|
||
useModalDismiss(onClose)
|
||
|
||
return (
|
||
<div className="chat-emoji-popover" role="dialog" aria-label="Выбор эмодзи">
|
||
{EMOJI_OPTIONS.map((emoji) => (
|
||
<button key={emoji} type="button" className="chat-emoji-option" onClick={() => onSelect(emoji)}>
|
||
{emoji}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|