Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь видит любой участник, но опустить чужую руку по-прежнему может только организатор (сервер это уже проверял, менял только фронт). Кнопка «Опустить» показывается у записи, только если это своя рука или пользователь — организатор. Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый в админке (instance_settings.hand_queue, дефолт enabled=true, как у chat_enabled). Настройка едет участнику в JoinOut ещё до входа в комнату; выключенный модуль гасит кнопки и на фронте, и на бэке — raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если модуль выключен, даже если у клиента на руках старый JoinOut.
113 lines
5.2 KiB
TypeScript
113 lines
5.2 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { Hand, ListOrdered } from 'lucide-react'
|
||
import { useLocalParticipant } from '@livekit/components-react'
|
||
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
|
||
import type { HandQueueEntry } from '@/hooks/useChat'
|
||
|
||
interface HandQueueMenuProps {
|
||
queue: HandQueueEntry[]
|
||
onLower: (identity: string) => void
|
||
}
|
||
|
||
/**
|
||
* Кнопка «Очередь» в тулбаре с поповером над ней — видна ВСЕМ участникам
|
||
* (сессия «28-hand-queue-for-all»: раньше очередь видел только организатор).
|
||
* Опустить чужую запись может по-прежнему только организатор — обычный
|
||
* участник видит кнопку «Опустить» только напротив СВОЕЙ записи (или не
|
||
* видит её вовсе, если сам руку не поднимал): сервер (`api/chat.py`) всё
|
||
* равно отклонит попытку опустить чужую руку кодом `forbidden`, но мёртвая
|
||
* кнопка, которая молча не работает, хуже отсутствующей.
|
||
*
|
||
* Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»):
|
||
* собственное состояние открытия, закрытие по клику вне/Escape, поповер
|
||
* `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат):
|
||
* очередь рук — короткий список, а не история переписки, разворачивать её
|
||
* во весь экран незачем и на мобильном.
|
||
*
|
||
* Размер поповера подстраивается под число записей — `.hand-queue-list`
|
||
* растёт вместе со списком и не даёт пустого места при 1–2 поднятых руках,
|
||
* но не бесконечно: после ~10 строк список упирается в `max-height` и дальше
|
||
* скроллится (см. room.css) — иначе участник на энергичной встрече получил
|
||
* бы поповер выше экрана.
|
||
*/
|
||
export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
|
||
const isOrganizer = useIsOrganizer()
|
||
const { localParticipant } = useLocalParticipant()
|
||
const [open, setOpen] = useState(false)
|
||
const wrapRef = useRef<HTMLDivElement>(null)
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
|
||
function handlePointerDown(event: MouseEvent) {
|
||
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
|
||
setOpen(false)
|
||
}
|
||
}
|
||
function handleKeydown(event: KeyboardEvent) {
|
||
if (event.key === 'Escape') setOpen(false)
|
||
}
|
||
|
||
document.addEventListener('mousedown', handlePointerDown)
|
||
document.addEventListener('keydown', handleKeydown)
|
||
return () => {
|
||
document.removeEventListener('mousedown', handlePointerDown)
|
||
document.removeEventListener('keydown', handleKeydown)
|
||
}
|
||
}, [open])
|
||
|
||
return (
|
||
<div className="tb-menu-wrap" ref={wrapRef}>
|
||
<button
|
||
type="button"
|
||
className={`tb-btn${open ? ' is-panel-open' : ''}`}
|
||
aria-pressed={open}
|
||
aria-expanded={open}
|
||
aria-haspopup="dialog"
|
||
aria-label={open ? 'Свернуть очередь поднятых рук' : 'Открыть очередь поднятых рук'}
|
||
onClick={() => setOpen((v) => !v)}
|
||
>
|
||
<span className="icon-shell">
|
||
<ListOrdered className="lucide" aria-hidden="true" />
|
||
{queue.length > 0 && (
|
||
<span className="badge-count">{queue.length > 9 ? '9+' : queue.length}</span>
|
||
)}
|
||
</span>
|
||
<span className="label">Очередь</span>
|
||
</button>
|
||
{open && (
|
||
<div className="tb-menu hand-queue-menu" role="dialog" aria-label="Очередь поднятых рук">
|
||
{queue.length === 0 ? (
|
||
<p className="chat-empty">Пока никто не поднял руку</p>
|
||
) : (
|
||
<ol className="hand-queue-list">
|
||
{queue.map((entry, index) => {
|
||
const isOwn = entry.identity === localParticipant.identity
|
||
const canLower = isOrganizer || isOwn
|
||
return (
|
||
<li className="hand-queue-item" key={entry.identity}>
|
||
<span className="hand-queue-position">{index + 1}</span>
|
||
<span className="hand-queue-name">
|
||
<Hand className="lucide" aria-hidden="true" />
|
||
{entry.name}
|
||
</span>
|
||
{canLower && (
|
||
<button
|
||
type="button"
|
||
className="hand-queue-lower"
|
||
onClick={() => onLower(entry.identity)}
|
||
>
|
||
Опустить
|
||
</button>
|
||
)}
|
||
</li>
|
||
)
|
||
})}
|
||
</ol>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|