Files
vidconf/frontend/src/components/room/ChatPanel.tsx
Max Ronzhin 826b7639b1 fix(chat): специфичность CSS эмодзи-поповера + сетка 6×5
Кнопка эмодзи и все кнопки внутри поповера лежат в 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 эмодзи без неполной последней строки.
2026-08-02 04:57:39 +03:00

236 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}