feat(room): поднятие руки и очередь для организатора

Транспорт — существующий аутентифицированный WS чата (api/chat.py), а не
отдельный эндпоинт: сервер уже держит это соединение на каждого участника
(обоснование — докстринг chat_websocket и useChat.ts). Состояние очереди —
Redis (services/hand_queue.py), не Postgres: это эфемерное состояние звонка,
а не история, и два процесса uvicorn делают наивную память одного процесса
недостаточной. HSETNX даёт идемпотентное «поднять» (повторный клик не
переставляет в конец очереди), снапшот шлётся всем участникам при любом
изменении — организатор, зашедший позже, сразу видит актуальную картину.

Опустить чужую руку может организатор (решение оператора) — проверка через
conference.owner_id, не через identity клиента. Участник, вышедший из
комнаты LiveKit (webhook participant_left), теряет место в очереди
автоматически; переподключение WS чата место не сбрасывает (Redis не привязан
к жизни соединения). room_finished чистит очередь целиком — она не должна
пережить завершение звонка.

Побочный эффект транспортного решения: поднять руку нельзя, если чат выключен
настройкой инстанса (WS вообще не открывается) — принятый компромисс ради
переиспользования уже готового канала.

UI: кнопка «Рука» в тулбаре (у всех, бейдж — общий счётчик), бейдж на плитке
говорящего (видно всем), панель «Очередь» организатору (HandQueuePanel).
Кнопка «Рука» и панель «Очередь» намеренно НЕ прячутся в мобильную шторку
настроек, в отличие от «Вида», — поднятие руки посреди разговора требует
кнопки под рукой, а не в два клика вглубь настроек.

Этим же коммитом (файлы разделяемые с задачей B2, RoomParticipantTile.tsx/
useChat.ts/RoomStage.tsx/RoomPage.tsx/room.css) — проброс conferenceId и
каркас forced_mute-обработки, без которых кнопки принудительного мьюта не
скомпилировались бы; сама реализация мьюта — следующим коммитом.
This commit is contained in:
2026-08-01 22:06:43 +03:00
parent 42bfb88a22
commit 8e5eda88a2
14 changed files with 1147 additions and 62 deletions

View File

@@ -1,4 +1,4 @@
import { Pin, PinOff, ScreenShare } from 'lucide-react'
import { Hand, Mic, Pin, PinOff, ScreenShare, Video } from 'lucide-react'
import { Track } from 'livekit-client'
import {
AudioTrack,
@@ -20,21 +20,79 @@ import {
} from '@livekit/components-react'
import { Avatar } from '@/components/ui/Avatar'
import { stageTrackKey } from '@/components/room/stageFocus'
import { parseParticipantMetadata } from '@/lib/participantMetadata'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import { useToast } from '@/components/ui/ToastProvider'
import { muteParticipant } from '@/api/conferences'
/** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */
interface ParticipantMetadata {
avatar_url?: string | null
/** Достаёт URL аватара из метаданных участника — `null`, если поля нет (гость/без аватара). */
function parseAvatarUrl(metadata: string | undefined): string | null {
const parsed = parseParticipantMetadata(metadata)
return typeof parsed.avatar_url === 'string' && parsed.avatar_url ? parsed.avatar_url : null
}
/** Разбирает `participant.metadata` в URL аватара — `null`, если поля нет, метаданные пусты или невалидны (гость). */
function parseAvatarUrl(metadata: string | undefined): string | null {
if (!metadata) return null
try {
const parsed = JSON.parse(metadata) as ParticipantMetadata
return typeof parsed.avatar_url === 'string' && parsed.avatar_url ? parsed.avatar_url : null
} catch {
return null
/**
* Кнопки принудительного мьюта организатором (задача B2) — микрофон/камера
* ЧУЖОГО участника. Видны только организатору (`useIsOrganizer`, подсказка
* UI — сервер перепроверяет права по владельцу конференции в БД) и только на
* чужой плитке камеры (на своей — обычный тулбарный toggle, мьютить себя
* через «принудительное» действие не нужно).
*
* Не проверяют текущее состояние мьюта заранее (усложнило бы плитку ради
* малополезной оптимизации): клик по уже выключенному треку — не ошибка, а
* no-op на backend (`muted: false` в ответе, см. `services/room_control.py`).
*/
function OrganizerMuteControls({
conferenceId,
identity,
displayName,
}: {
conferenceId: string
identity: string
displayName: string
}) {
const toast = useToast()
async function handleMute(source: 'microphone' | 'camera') {
try {
const result = await muteParticipant(conferenceId, identity, source)
if (!result.muted) {
toast.show(
source === 'microphone' ? 'Микрофон и так выключен' : 'Камера и так выключена',
'info',
)
}
} catch {
toast.show('Не удалось выключить трек участника', 'error')
}
}
return (
<div className="room-organizer-controls">
<button
type="button"
title={`Выключить микрофон: ${displayName}`}
aria-label={`Выключить микрофон: ${displayName}`}
onClick={(e) => {
e.stopPropagation()
void handleMute('microphone')
}}
>
<Mic aria-hidden="true" />
</button>
<button
type="button"
title={`Выключить камеру: ${displayName}`}
aria-label={`Выключить камеру: ${displayName}`}
onClick={(e) => {
e.stopPropagation()
void handleMute('camera')
}}
>
<Video aria-hidden="true" />
</button>
</div>
)
}
/**
@@ -44,8 +102,15 @@ function parseAvatarUrl(metadata: string | undefined): string | null {
* разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`,
* версия 2.9.23 — источник этой копии).
*/
function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps) {
function TileBody({
onStopSharing,
pinnedKey,
onTogglePin,
raisedHandIdentities,
conferenceId,
}: TileControlsProps) {
const trackReference = useEnsureTrackRef()
const isOrganizer = useIsOrganizer()
const isEncrypted = useIsEncrypted(trackReference.participant)
const autoManageSubscription = useFeatureContext()?.autoSubscription
// useParticipantInfo — реактивные name/metadata участника (переподписка на
@@ -67,6 +132,18 @@ function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps)
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
const tileKey = stageTrackKey(trackReference)
const isPinned = pinnedKey === tileKey
// Бейдж поднятой руки (задача B1) — только на плитке КАМЕРЫ участника, не
// на плитке его демонстрации экрана (рука — про человека, не про экран).
const isHandRaised =
trackReference.source === Track.Source.Camera &&
Boolean(raisedHandIdentities?.has(trackReference.participant.identity))
// Кнопки принудительного мьюта (задача B2) — организатору, только на
// чужой плитке камеры (см. докстринг `OrganizerMuteControls`).
const showOrganizerMuteControls =
isOrganizer &&
Boolean(conferenceId) &&
trackReference.source === Track.Source.Camera &&
!trackReference.participant.isLocal
return (
<>
@@ -88,6 +165,11 @@ function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps)
<div className="lk-participant-placeholder">
<Avatar name={displayName} avatarUrl={avatarUrl} className="room-tile-avatar" />
</div>
{isHandRaised && (
<div className="room-hand-badge" title={`${displayName}: поднята рука`}>
<Hand className="lucide" aria-hidden="true" />
</div>
)}
<div className="lk-participant-metadata">
<div className="lk-participant-metadata-item">
{trackReference.source === Track.Source.Camera ? (
@@ -141,6 +223,13 @@ function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps)
</button>
</div>
)}
{showOrganizerMuteControls && conferenceId && (
<OrganizerMuteControls
conferenceId={conferenceId}
identity={trackReference.participant.identity}
displayName={displayName}
/>
)}
</>
)
}
@@ -166,6 +255,17 @@ interface TileControlsProps {
* Не передан — кнопки-булавки на плитке нет (мини-плеер: плитка одна).
*/
onTogglePin?: (key: string) => void
/**
* Identity участников с поднятой рукой прямо сейчас (задача B1, из
* `useChat().handQueue`) — плитка сама решает, её ли это identity. Не
* передан — бейдж нигде не рендерится (мини-плеер).
*/
raisedHandIdentities?: Set<string>
/**
* Id конференции (не slug/номер) — нужен для вызова эндпоинта мьюта
* (задача B2). Не передан — кнопок мьюта на плитке нет (мини-плеер).
*/
conferenceId?: string
}
interface RoomParticipantTileProps extends TileControlsProps {
@@ -197,6 +297,8 @@ export function RoomParticipantTile({
onStopSharing,
pinnedKey,
onTogglePin,
raisedHandIdentities,
conferenceId,
}: RoomParticipantTileProps) {
return (
<ParticipantTile
@@ -204,7 +306,13 @@ export function RoomParticipantTile({
disableSpeakingIndicator={disableSpeakingIndicator}
onParticipantClick={onParticipantClick}
>
<TileBody onStopSharing={onStopSharing} pinnedKey={pinnedKey} onTogglePin={onTogglePin} />
<TileBody
onStopSharing={onStopSharing}
pinnedKey={pinnedKey}
onTogglePin={onTogglePin}
raisedHandIdentities={raisedHandIdentities}
conferenceId={conferenceId}
/>
</ParticipantTile>
)
}