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:
60
frontend/src/components/room/HandQueuePanel.tsx
Normal file
60
frontend/src/components/room/HandQueuePanel.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Hand, X } from 'lucide-react'
|
||||
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
|
||||
import type { HandQueueEntry } from '@/hooks/useChat'
|
||||
|
||||
interface HandQueuePanelProps {
|
||||
queue: HandQueueEntry[]
|
||||
onLower: (identity: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Панель очереди поднятых рук — видна только организатору (задача B1).
|
||||
* Визуально — тот же боковой контейнер, что и `ChatPanel` (`.chat-panel`,
|
||||
* включая мобильное поведение «во весь экран» на ≤900px), содержимое своё:
|
||||
* упорядоченный список с позицией и кнопкой «Опустить» на каждой строке —
|
||||
* организатору разрешено опускать чужую руку (решение оператора, задача B1).
|
||||
*
|
||||
* `RoomPage` гейтит рендер по `handQueueOpen` (как и `ChatPanel` по
|
||||
* `chatOpen`) — свой `useIsOrganizer()` здесь ДОПОЛНИТЕЛЬНАЯ, а не
|
||||
* единственная защита: `RoomToolbar` уже не показывает кнопку открытия
|
||||
* не-организатору, это подстраховка на случай прямого рендера.
|
||||
*/
|
||||
export function HandQueuePanel({ queue, onLower, onClose }: HandQueuePanelProps) {
|
||||
const isOrganizer = useIsOrganizer()
|
||||
if (!isOrganizer) return null
|
||||
|
||||
return (
|
||||
<aside className="chat-panel hand-queue-panel">
|
||||
<div className="chat-head">
|
||||
<h2>Поднятые руки</h2>
|
||||
<button type="button" aria-label="Закрыть очередь" onClick={onClose}>
|
||||
<X className="lucide" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{queue.length === 0 ? (
|
||||
<p className="chat-empty">Пока никто не поднял руку</p>
|
||||
) : (
|
||||
<ol className="hand-queue-list">
|
||||
{queue.map((entry, index) => (
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className="hand-queue-lower"
|
||||
onClick={() => onLower(entry.identity)}
|
||||
>
|
||||
Опустить
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user