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:
@@ -24,10 +24,44 @@ export interface ChatMessageOut {
|
||||
*/
|
||||
export type ChatConnectionStatus = 'connecting' | 'open' | 'closed' | 'error'
|
||||
|
||||
/**
|
||||
* Один участник в очереди поднятых рук (задача B1) — 1:1 с pydantic-схемой
|
||||
* `HandQueueEntryOut` backend. `identity` — тот же формат, что и
|
||||
* `Participant.identity` в LiveKit (`str(user_id)` либо `guest:{id}`),
|
||||
* пригоден для прямого сравнения с `localParticipant.identity`/
|
||||
* `participant.identity` на сцене.
|
||||
*/
|
||||
export interface HandQueueEntry {
|
||||
identity: string
|
||||
name: string
|
||||
raised_at: string
|
||||
}
|
||||
|
||||
/** Источник трека, принудительно выключенного организатором (задача B2). */
|
||||
export type ForcedMuteSource = 'microphone' | 'camera'
|
||||
|
||||
/**
|
||||
* Одно событие принудительного мьюта (задача B2) — рассылается ВСЕМ
|
||||
* участникам конференции (канал общий, адресной доставки нет), поэтому
|
||||
* несёт `identity` затронутого: получатель сам решает, про него ли это
|
||||
* (см. `ForcedMuteWatcher` — сравнивает с `localParticipant.identity`).
|
||||
* `nonce` — счётчик хука, растёт на каждое полученное событие: тот же
|
||||
* `source`/`identity` два раза подряд (например, повторный клик
|
||||
* организатора на уже выключенный трек) должен переоткрыть тост, а не
|
||||
* молча схлопнуться в один и тот же объект по `useEffect`-сравнению.
|
||||
*/
|
||||
export interface ForcedMuteEvent {
|
||||
identity: string
|
||||
source: ForcedMuteSource
|
||||
nonce: number
|
||||
}
|
||||
|
||||
type IncomingFrame =
|
||||
| { type: 'history'; messages: ChatMessageOut[] }
|
||||
| { type: 'message'; message: ChatMessageOut }
|
||||
| { type: 'error'; code: string }
|
||||
| { type: 'hand_queue'; queue: HandQueueEntry[] }
|
||||
| { type: 'forced_mute'; identity: string; source: ForcedMuteSource }
|
||||
|
||||
interface UseChatOptions {
|
||||
/** id конференции — пока не известен (страница ещё не подключилась к LiveKit), WS не открываем. */
|
||||
@@ -47,6 +81,23 @@ interface UseChatResult {
|
||||
unavailable: boolean
|
||||
/** Отправить сообщение (1..2000 символов после strip, пустое/слишком длинное — игнорируется). */
|
||||
sendMessage: (text: string) => void
|
||||
/**
|
||||
* Очередь поднятых рук, упорядоченная по времени поднятия — сервер
|
||||
* присылает полный снапшот при любом изменении (см. `schemas/room_events.py`
|
||||
* backend), поэтому клиенту не нужно вести собственное состояние очереди.
|
||||
* Пуста, пока WS не открыт/не пришёл первый снапшот.
|
||||
*/
|
||||
handQueue: HandQueueEntry[]
|
||||
/** Поднять СВОЮ руку — повторный вызов на уже поднятой руке безвреден (идемпотентно на сервере). */
|
||||
raiseHand: () => void
|
||||
/**
|
||||
* Опустить руку — свою (без аргумента) либо чужую по `identity` (только
|
||||
* организатору, иначе сервер отклонит `{type:"error",code:"forbidden"}`,
|
||||
* см. `statusMessage`).
|
||||
*/
|
||||
lowerHand: (identity?: string) => void
|
||||
/** Последнее событие принудительного мьюта (задача B2) — `null` до первого. */
|
||||
lastForcedMute: ForcedMuteEvent | null
|
||||
}
|
||||
|
||||
/** Close-коды сервера — см. зафиксированный протокол WS. */
|
||||
@@ -61,16 +112,30 @@ function buildChatWsUrl(conferenceId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* WS-клиент чата комнаты конференции. Реализует зафиксированный протокол:
|
||||
* connect → `{type:"auth"}` → `{type:"history"}` → далее входящие
|
||||
* `{type:"message"}`/`{type:"error"}`.
|
||||
* WS-клиент комнаты конференции (несмотря на имя — не только чат, задача
|
||||
* B1). Реализует зафиксированный протокол: connect → `{type:"auth"}` →
|
||||
* `{type:"history"}` → `{type:"hand_queue"}` → далее входящие
|
||||
* `{type:"message"}`/`{type:"hand_queue"}`/`{type:"error"}`.
|
||||
*
|
||||
* Optimistic-append собственных сообщений НЕ делается: сервер всегда
|
||||
* Очередь поднятых рук (`raiseHand`/`lowerHand`/`handQueue`) едет по тому же
|
||||
* соединению, что и чат, — переиспользование уже открытого аутентифицированного
|
||||
* WS дешевле отдельного эндпоинта (см. `backend/api/chat.py`). Следствие:
|
||||
* поднять руку нельзя, если чат выключен настройкой инстанса (`enabled=false`,
|
||||
* соединение вообще не открывается) — принятый компромисс, обоснование в
|
||||
* коммите задачи B1.
|
||||
*
|
||||
* Optimistic-append собственных сообщений чата НЕ делается: сервер всегда
|
||||
* присылает наше же сообщение обратно echo-фреймом `message` — если
|
||||
* добавлять его на клиенте сразу при отправке, оно задублируется в списке.
|
||||
* Очередь рук устроена иначе: сервер шлёт ПОЛНЫЙ снапшот на каждое
|
||||
* изменение, поэтому `raiseHand`/`lowerHand` ничего не трогают в состоянии
|
||||
* сами — ждут снапшот.
|
||||
*/
|
||||
export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseChatResult {
|
||||
const [messages, setMessages] = useState<ChatMessageOut[]>([])
|
||||
const [handQueue, setHandQueue] = useState<HandQueueEntry[]>([])
|
||||
const [lastForcedMute, setLastForcedMute] = useState<ForcedMuteEvent | null>(null)
|
||||
const forcedMuteNonceRef = useRef(0)
|
||||
// `wsStatus` меняется ТОЛЬКО из колбэков реального WS-соединения (см. ниже) —
|
||||
// никогда синхронно в теле эффекта, иначе react-hooks/set-state-in-effect
|
||||
// (эффект без активной подписки, только синхронизирующий производное
|
||||
@@ -106,6 +171,7 @@ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseCh
|
||||
ws.onopen = () => {
|
||||
if (stale) return
|
||||
setMessages([])
|
||||
setHandQueue([])
|
||||
setStatusMessage(null)
|
||||
setUnavailable(false)
|
||||
setWsStatus('open')
|
||||
@@ -124,9 +190,19 @@ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseCh
|
||||
setMessages(frame.messages)
|
||||
} else if (frame.type === 'message') {
|
||||
setMessages((prev) => [...prev, frame.message])
|
||||
} else if (frame.type === 'hand_queue') {
|
||||
setHandQueue(frame.queue)
|
||||
} else if (frame.type === 'forced_mute') {
|
||||
forcedMuteNonceRef.current += 1
|
||||
setLastForcedMute({
|
||||
identity: frame.identity,
|
||||
source: frame.source,
|
||||
nonce: forcedMuteNonceRef.current,
|
||||
})
|
||||
} else if (frame.type === 'error') {
|
||||
// Ошибка отдельной операции (например, отклонённое сообщение) — соединение
|
||||
// не рвётся, просто короткое пояснение пользователю.
|
||||
// Ошибка отдельной операции (например, отклонённое сообщение или
|
||||
// запрет опустить чужую руку не-организатору, code:"forbidden") —
|
||||
// соединение не рвётся, просто короткое пояснение пользователю.
|
||||
setStatusMessage(`Ошибка чата: ${frame.code}`)
|
||||
}
|
||||
}
|
||||
@@ -168,10 +244,32 @@ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseCh
|
||||
ws.send(JSON.stringify({ type: 'message', text: trimmed }))
|
||||
}, [])
|
||||
|
||||
const raiseHand = useCallback(() => {
|
||||
const ws = wsRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({ type: 'raise_hand' }))
|
||||
}, [])
|
||||
|
||||
const lowerHand = useCallback((identity?: string) => {
|
||||
const ws = wsRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({ type: 'lower_hand', identity: identity ?? null }))
|
||||
}, [])
|
||||
|
||||
// Наружу — производный статус: пока подключаться нечем (выключено/нет
|
||||
// conferenceId/token), всегда `closed`, даже если внутренний `wsStatus`
|
||||
// ещё хранит значение от предыдущего подключения.
|
||||
const status: ChatConnectionStatus = canConnect ? wsStatus : 'closed'
|
||||
|
||||
return { messages, status, statusMessage, unavailable, sendMessage }
|
||||
return {
|
||||
messages,
|
||||
status,
|
||||
statusMessage,
|
||||
unavailable,
|
||||
sendMessage,
|
||||
handQueue,
|
||||
raiseHand,
|
||||
lowerHand,
|
||||
lastForcedMute,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user