feat(room): очередь поднятых рук видна всем + отключаемый модуль

Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь
видит любой участник, но опустить чужую руку по-прежнему может только
организатор (сервер это уже проверял, менял только фронт). Кнопка
«Опустить» показывается у записи, только если это своя рука или
пользователь — организатор.

Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый
в админке (instance_settings.hand_queue, дефолт enabled=true, как у
chat_enabled). Настройка едет участнику в JoinOut ещё до входа в
комнату; выключенный модуль гасит кнопки и на фронте, и на бэке —
raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если
модуль выключен, даже если у клиента на руках старый JoinOut.
This commit is contained in:
2026-08-04 18:27:14 +03:00
parent 65fbcf952c
commit 10a3f8b3b4
21 changed files with 274 additions and 62 deletions

View File

@@ -17,7 +17,6 @@ import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
import { HandQueueMenu } from '@/components/room/HandQueueMenu'
@@ -59,6 +58,8 @@ interface RoomToolbarProps extends StageViewProps {
pipSupported: boolean
pipActive: boolean
onTogglePiP: () => void
/** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся вовсе. */
handQueueEnabled: boolean
/**
* Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама
* решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity`
@@ -94,9 +95,11 @@ interface RoomToolbarProps extends StageViewProps {
* (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* экономии: поднять руку посреди разговора — действие со временем жизни в
* секунды, прятать его в шторку настроек означало бы делать его практически
* недоступным с телефона. «Очередь» показывается только организатору —
* встречается редко, но по той же причине оставлена в тулбаре, а не в
* шторке: организатору с телефона тоже нужно видеть очередь сразу.
* недоступным с телефона. «Очередь» видна ЛЮБОМУ участнику (сессия
* «28-hand-queue-for-all»: раньше только организатору), по той же причине
* оставлена в тулбаре, а не в шторке: любому участнику с телефона тоже нужно
* видеть очередь сразу. Обе кнопки целиком гасятся `handQueueEnabled`
* (`JoinOut.hand_queue_enabled`, отключаемый модуль в админке).
* Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку,
* как демонстрация/мини-плеер) — по решению оператора вход в него должен
* быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один
@@ -115,6 +118,7 @@ export function RoomToolbar({
pipSupported,
pipActive,
onTogglePiP,
handQueueEnabled,
handQueue,
onRaiseHand,
onLowerHand,
@@ -128,21 +132,22 @@ export function RoomToolbar({
}: RoomToolbarProps) {
const toast = useToast()
const isCompact = useIsCompactViewport()
const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant()
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
// Сколько кнопок реально видно на мобильном (демонстрация/мини-окно там
// скрыты через CSS всегда, «Вид» не рендерится вовсе — см. докстринг) —
// считаем в JS, а не через CSS-селекторы вида `:nth-child`: у «Очереди»
// (только у организатора) в DOM есть своя обёртка `.tb-menu-wrap`, из-за
// которой позиция остальных кнопок «плывёт», и подсчёт по структуре DOM
// был бы хрупким. От порога зависит `.tb-wrap-grid` в room.css — ниже он
// переключает перенос на 2 строки с «как поместится» (flex-wrap) на
// равномерную сетку 4 колонки (7 → 4+3, 8 → 4+4).
// в DOM есть своя обёртка `.tb-menu-wrap`, из-за которой позиция остальных
// кнопок «плывёт», и подсчёт по структуре DOM был бы хрупким. От порога
// зависит `.tb-wrap-grid` в room.css — ниже он переключает перенос на
// 2 строки с «как поместится» (flex-wrap) на равномерную сетку 4 колонки
// (7 → 4+3, 8 → 4+4). «Рука»/«Очередь» — обе теперь видны любому участнику
// (сессия «28-hand-queue-for-all»), их наличие зависит только от
// `handQueueEnabled`, не от роли.
const mobileButtonCount =
3 /* микрофон, камера, рука */ +
(isOrganizer ? 1 : 0) /* очередь */ +
2 /* микрофон, камера */ +
(handQueueEnabled ? 2 : 0) /* рука, очередь */ +
1 /* настройки */ +
(fullscreenSupported ? 1 : 0) +
(chatVisible ? 1 : 0) +
@@ -214,23 +219,25 @@ export function RoomToolbar({
<span className="label">Демонстрация</span>
</button>
<button
type="button"
className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`}
aria-pressed={handRaised}
aria-label={handRaised ? 'Опустить руку' : 'Поднять руку'}
onClick={() => (handRaised ? onLowerHand() : onRaiseHand())}
>
<span className="icon-shell">
<Hand className="lucide" aria-hidden="true" />
{handQueue.length > 0 && (
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
)}
</span>
<span className="label">Рука</span>
</button>
{handQueueEnabled && (
<button
type="button"
className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`}
aria-pressed={handRaised}
aria-label={handRaised ? 'Опустить руку' : 'Поднять руку'}
onClick={() => (handRaised ? onLowerHand() : onRaiseHand())}
>
<span className="icon-shell">
<Hand className="lucide" aria-hidden="true" />
{handQueue.length > 0 && (
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
)}
</span>
<span className="label">Рука</span>
</button>
)}
<HandQueueMenu queue={handQueue} onLower={onLowerHandById} />
{handQueueEnabled && <HandQueueMenu queue={handQueue} onLower={onLowerHandById} />}
{!isCompact && (
<StageViewMenu