Compare commits
5 Commits
5e6c4f8bfd
...
5d39e0f076
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d39e0f076 | |||
| 43cc1ae3e8 | |||
| 82204553e2 | |||
| 3290eb5d07 | |||
| f627d827af |
@@ -1,7 +1,18 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
import { AlertCircle, Send, X } from 'lucide-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[]
|
||||
@@ -38,12 +49,21 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
||||
}
|
||||
}, [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('')
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
@@ -55,10 +75,27 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
||||
|
||||
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
|
||||
setDraft(event.target.value)
|
||||
// Авторасширение textarea до 4 строк, дальше — внутренний скролл.
|
||||
const el = event.target
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
|
||||
}
|
||||
|
||||
// Вставка эмодзи в позицию курсора, а не в конец поля, — ожидаемое поведение
|
||||
// при редактировании текста в середине.
|
||||
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 (
|
||||
@@ -101,6 +138,7 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
||||
handleSend()
|
||||
}}
|
||||
>
|
||||
<EmojiPickerButton onSelect={insertEmoji} disabled={status !== 'open'} />
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
@@ -118,3 +156,77 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { EyeOff, Users } from 'lucide-react'
|
||||
import { EyeOff, Mic, MicOff, Users } from 'lucide-react'
|
||||
import { Track, type Participant } from 'livekit-client'
|
||||
import {
|
||||
CarouselLayout,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isTrackReference,
|
||||
useRoomContext,
|
||||
useSpeakingParticipants,
|
||||
useTrackToggle,
|
||||
useTracks,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-react'
|
||||
@@ -93,6 +94,29 @@ function hasLiveVideo(track: TrackReferenceOrPlaceholder): boolean {
|
||||
return isTrackReference(track) && !track.publication.isMuted
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка микрофона поверх единственной плитки мини-плеера (задача A5): в
|
||||
* Document PiP своего тулбара нет вовсе, а звонок продолжается — без неё
|
||||
* замьютить себя можно, только развернув основное окно. `useTrackToggle` —
|
||||
* тот же хук, что и в `RoomToolbar`, читает/меняет состояние ЧЕРЕЗ
|
||||
* `RoomContext`, а не через DOM, поэтому работает одинаково что в основном
|
||||
* окне, что в портале, рендерящем эту сцену внутрь `pipWindow.document.body`
|
||||
* (React-контекст порталом не разрывается, в отличие от DOM-дерева).
|
||||
*/
|
||||
function PipMicToggle() {
|
||||
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...mic.buttonProps}
|
||||
className={`room-pip-mic-toggle${mic.enabled ? '' : ' is-off'}`}
|
||||
aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'}
|
||||
>
|
||||
{mic.enabled ? <Mic className="lucide" aria-hidden="true" /> : <MicOff className="lucide" aria-hidden="true" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Основная сцена конференции: превью остальных участников + крупная плитка
|
||||
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
|
||||
@@ -179,6 +203,7 @@ export function RoomStage({
|
||||
onHideOthers,
|
||||
initialFocusKey = null,
|
||||
onFocusKeyChange,
|
||||
onPinFocus,
|
||||
}: {
|
||||
variant?: 'full' | 'pip'
|
||||
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
|
||||
@@ -193,6 +218,14 @@ export function RoomStage({
|
||||
initialFocusKey?: string | null
|
||||
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
|
||||
onFocusKeyChange?: (key: string | null) => void
|
||||
/**
|
||||
* Участника только что закрепили (не открепили) в режиме без крупной
|
||||
* плитки — сцена сама переключиться не может (режим живёт в `RoomPage`),
|
||||
* поэтому просит переключить на `standard`, где закреплённого есть куда
|
||||
* показать крупно. Открепление НЕ вызывает колбэк — вид назад не
|
||||
* переключаем (см. докстринг `RoomPage`, обоснование решения в коммите).
|
||||
*/
|
||||
onPinFocus?: () => void
|
||||
}) {
|
||||
const room = useRoomContext()
|
||||
const isCompact = useIsCompactViewport()
|
||||
@@ -337,9 +370,16 @@ export function RoomStage({
|
||||
* Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает
|
||||
* закрепление. Ключ приходит из самой плитки (она знает свой трек из
|
||||
* контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов).
|
||||
*
|
||||
* Закрепление доступно из ЛЮБОГО режима показа (задача 3.1 → 3.3): в
|
||||
* плиточных режимах крупной плитки нет, поэтому само закрепление (не
|
||||
* открепление) просит `RoomPage` переключить вид на `standard` —
|
||||
* закреплённого иначе негде показать крупно.
|
||||
*/
|
||||
function handleTogglePin(key: string) {
|
||||
setPinnedKey((prev) => (prev === key ? null : key))
|
||||
const willPin = pinnedKey !== key
|
||||
setPinnedKey(willPin ? key : null)
|
||||
if (willPin) onPinFocus?.()
|
||||
}
|
||||
|
||||
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
|
||||
@@ -350,6 +390,7 @@ export function RoomStage({
|
||||
return (
|
||||
<section className="stage room-single-tile">
|
||||
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
|
||||
<PipMicToggle />
|
||||
<RoomAudioRenderer />
|
||||
</section>
|
||||
)
|
||||
@@ -372,23 +413,23 @@ export function RoomStage({
|
||||
: []
|
||||
: carouselTracks
|
||||
const showCarousel = !hideOthers && sideTracks.length > 0
|
||||
// Закрепление имеет смысл только там, где есть «крупная плитка» —
|
||||
// в плиточных режимах фокуса нет, поэтому и булавки на плитках нет.
|
||||
const pinProps =
|
||||
effectiveMode === 'standard' ? { pinnedKey, onTogglePin: handleTogglePin } : {}
|
||||
// Булавка закрепления есть на КАЖДОЙ плитке во всех режимах (задача 3.3):
|
||||
// сама кнопка вызывает переключение на `standard`, где закреплённый и
|
||||
// попадёт в фокус (см. `handleTogglePin`/`onPinFocus`).
|
||||
const pinProps = { pinnedKey, onTogglePin: handleTogglePin }
|
||||
|
||||
function renderMain(): ReactNode {
|
||||
if (effectiveMode === 'tiles') {
|
||||
return (
|
||||
<StageGrid tracks={tracks}>
|
||||
<RoomParticipantTile />
|
||||
<RoomParticipantTile {...pinProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
}
|
||||
if (effectiveMode === 'live-tiles') {
|
||||
return (
|
||||
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
|
||||
<RoomParticipantTile />
|
||||
<RoomParticipantTile {...pinProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@ import { useState } from 'react'
|
||||
import { pluralizeParticipants } from '@/lib/pluralize'
|
||||
|
||||
interface RoomTopbarProps {
|
||||
roomName: string
|
||||
/**
|
||||
* Название конференции (`ConferenceOut.title`/`ResolveOut.title`) —
|
||||
* `null`/не задано, если организатор его не указал. НЕ путать с
|
||||
* `JoinOut.room_name` (техническое имя комнаты LiveKit вида `hC-Dmos9KEM`,
|
||||
* которое сюда раньше и попадало по ошибке, см. задачу A6) — тот нужен
|
||||
* только серверу и в UI показывать нечего.
|
||||
*/
|
||||
title: string | null
|
||||
/** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */
|
||||
slug?: string
|
||||
number?: string
|
||||
@@ -19,7 +26,7 @@ interface RoomTopbarProps {
|
||||
* тёмных токенов темы `room` (см. `--color-room-tile*`), без новых
|
||||
* цветов и форм.
|
||||
*/
|
||||
export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) {
|
||||
export function RoomTopbar({ title, slug, number }: RoomTopbarProps) {
|
||||
const participants = useParticipants()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -39,7 +46,7 @@ export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) {
|
||||
return (
|
||||
<header className="room-topbar">
|
||||
<div className="room-title-block">
|
||||
<h1>{roomName}</h1>
|
||||
<h1>{title ?? 'Конференция без названия'}</h1>
|
||||
<p>
|
||||
<span className="rec-dot" aria-hidden="true" />
|
||||
Конференция активна · <Users className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />{' '}
|
||||
|
||||
@@ -143,7 +143,7 @@ export function JoinPage() {
|
||||
state: {
|
||||
livekitUrl: data.livekit_url,
|
||||
token: data.token,
|
||||
roomName: data.room_name,
|
||||
title: resolved.title,
|
||||
conferenceId: data.conference_id,
|
||||
chatEnabled: data.chat_enabled,
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ export function LobbyPage() {
|
||||
state: {
|
||||
livekitUrl: conference.join.livekit_url,
|
||||
token: conference.join.token,
|
||||
roomName: conference.join.room_name,
|
||||
title: conference.title,
|
||||
conferenceId: conference.join.conference_id,
|
||||
chatEnabled: conference.join.chat_enabled,
|
||||
number: conference.number,
|
||||
|
||||
@@ -23,7 +23,8 @@ import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '
|
||||
interface RoomJoinState {
|
||||
livekitUrl: string
|
||||
token: string
|
||||
roomName: string
|
||||
/** Название конференции (`ConferenceOut.title`/`ResolveOut.title`) — для топбара, см. `RoomTopbar` и задачу A6. */
|
||||
title?: string | null
|
||||
conferenceId?: string
|
||||
/** Номер конференции — для инвайт-чипа в топбаре (см. RoomTopbar). Есть только сразу после создания. */
|
||||
number?: string
|
||||
@@ -92,7 +93,7 @@ export function RoomPage() {
|
||||
setJoinState({
|
||||
livekitUrl: result.livekit_url,
|
||||
token: result.token,
|
||||
roomName: result.room_name,
|
||||
title: info.title,
|
||||
conferenceId: result.conference_id,
|
||||
chatEnabled: result.chat_enabled,
|
||||
})
|
||||
@@ -168,6 +169,15 @@ export function RoomPage() {
|
||||
saveStageLayoutMode(mode)
|
||||
}, [])
|
||||
|
||||
// Закрепление участника из плиточного режима переключает вид на «Стандарт»
|
||||
// (см. докстринг `RoomStage`, `onPinFocus`) — но это НЕ то же самое, что
|
||||
// явный выбор пользователя: переключение не сохраняется в localStorage
|
||||
// (в отличие от `handleLayoutModeChange`), иначе разовое закрепление в этой
|
||||
// встрече поменяло бы устойчивое предпочтение режима для всех следующих.
|
||||
const handlePinFocus = useCallback(() => {
|
||||
setLayoutMode('standard')
|
||||
}, [])
|
||||
|
||||
// Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только
|
||||
// внутри `RoomStage`, потому что при открытии мини-плеера сцена
|
||||
// размонтируется в основном окне и монтируется заново в PiP-окне (портал
|
||||
@@ -240,13 +250,13 @@ export function RoomPage() {
|
||||
serverUrl={joinState.livekitUrl}
|
||||
token={joinState.token}
|
||||
connect
|
||||
audio
|
||||
video
|
||||
audio={false}
|
||||
video={false}
|
||||
options={roomOptions}
|
||||
onDisconnected={handleDisconnected}
|
||||
>
|
||||
<div data-lk-theme="default" className="room-shell">
|
||||
<RoomTopbar roomName={joinState.roomName} slug={slug} number={joinState.number} />
|
||||
<RoomTopbar title={joinState.title ?? null} slug={slug} number={joinState.number} />
|
||||
<div className="room-main">
|
||||
{pip.mode === 'document' ? (
|
||||
// Сцена сейчас рисуется в PiP-окне (через createPortal ниже) —
|
||||
@@ -266,6 +276,7 @@ export function RoomPage() {
|
||||
onHideOthers={() => setHideOthers(true)}
|
||||
initialFocusKey={stageFocusKey}
|
||||
onFocusKeyChange={setStageFocusKey}
|
||||
onPinFocus={handlePinFocus}
|
||||
/>
|
||||
)}
|
||||
{chatVisible && chatOpen && (
|
||||
|
||||
@@ -133,9 +133,32 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
* `.stage-tiles` в основном окне) плюс подстраховочный `min-height`, чтобы
|
||||
* плитка не схлопывалась, если видео ещё не выдало реальные размеры.
|
||||
*/
|
||||
.room-single-tile { flex: 1; min-height: 0; display: flex; }
|
||||
.room-single-tile { position: relative; flex: 1; min-height: 0; display: flex; }
|
||||
.room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; }
|
||||
|
||||
/* Кнопка микрофона мини-плеера (задача A5) — своего тулбара в PiP нет,
|
||||
кнопка лежит поверх единственной плитки левым нижним углом (правый занят
|
||||
штатным `.lk-focus-toggle-button`/нашей булавкой закрепления — тут её нет,
|
||||
в PiP закреплять нечего, но геометрия зеркалит `.room-pin-toggle`). */
|
||||
.room-pip-mic-toggle {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
z-index: 5;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-room-mic-on);
|
||||
color: #10331f;
|
||||
cursor: pointer;
|
||||
}
|
||||
.room-pip-mic-toggle svg { width: 18px; height: 18px; }
|
||||
.room-pip-mic-toggle.is-off { background: var(--color-room-danger-bg); color: #fff; }
|
||||
|
||||
/*
|
||||
* ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ----------
|
||||
* Прямой ребёнок `.lk-focus-layout` — вместо самой карусели, потому что над
|
||||
@@ -497,6 +520,49 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
}
|
||||
.chat-input-row button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; }
|
||||
.chat-emoji-trigger {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-room-tile);
|
||||
border: 1px solid var(--color-room-tile-border);
|
||||
color: var(--color-room-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
|
||||
.chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
|
||||
.chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.chat-emoji-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--space-2));
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
width: 224px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 2px;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-room-tile-border);
|
||||
background: var(--color-room-surface-raised);
|
||||
box-shadow: var(--shadow-room-panel);
|
||||
}
|
||||
.chat-emoji-option {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-emoji-option:hover { background: var(--color-room-tile); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chat-panel {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user