Files
vidconf/frontend/src/hooks/useChat.ts
Max Ronzhin 8e5eda88a2 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-обработки, без которых кнопки принудительного мьюта не
скомпилировались бы; сама реализация мьюта — следующим коммитом.
2026-08-01 22:06:43 +03:00

276 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Сообщение чата в проводном формате сервера — 1:1 с pydantic-схемой
* `ChatMessageOut` backend (зафиксированный протокол WS).
* `created_at` — UTC ISO-8601 (в БД и API только UTC), конвертация в
* локальное время — на стороне ChatPanel (`lib/localTime.ts`).
*/
export interface ChatMessageOut {
id: number
author_id: string | null
author_name: string
is_guest: boolean
text: string
created_at: string
}
/**
* Статус WS-соединения чата: `connecting` — сокет открывается,
* `open` — транспорт открыт (auth уже отправлен, история может ещё
* загружаться), `closed` — соединение штатно закрыто/не запускалось,
* `error` — обрыв по невалидному токену или отказу в доступе
* (см. `statusMessage` для пояснения пользователю).
*/
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 не открываем. */
conferenceId: string | undefined
/** LiveKit-токен из JoinOut — им же авторизуем WS-сессию чата (см. протокол). */
token: string | undefined
/** `JoinOut.chat_enabled` — при `false` хук ничего не подключает. */
enabled: boolean
}
interface UseChatResult {
messages: ChatMessageOut[]
status: ChatConnectionStatus
/** Пояснение для баннера при обрыве/ошибке — `null`, если показывать нечего. */
statusMessage: string | null
/** `true` после close-кода 4404 (чат выключен на сервере/конференция не найдена) — панель нужно скрыть. */
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. */
const CLOSE_TOKEN_INVALID = 4401
const CLOSE_FORBIDDEN = 4403
const CLOSE_UNAVAILABLE = 4404
/** Собрать WS-адрес чата от текущего `window.location` (ws:// на http, wss:// на https). */
function buildChatWsUrl(conferenceId: string): string {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${wsProtocol}//${window.location.host}/api/v1/conferences/${conferenceId}/chat`
}
/**
* WS-клиент комнаты конференции (несмотря на имя — не только чат, задача
* B1). Реализует зафиксированный протокол: connect → `{type:"auth"}` →
* `{type:"history"}` → `{type:"hand_queue"}` → далее входящие
* `{type:"message"}`/`{type:"hand_queue"}`/`{type:"error"}`.
*
* Очередь поднятых рук (`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
// (эффект без активной подписки, только синхронизирующий производное
// значение, — по факту тот самый анти-паттерн, который правило и ловит).
// Начальное значение — `connecting`: типичный случай — подключение начинается
// сразу при монтировании хука (RoomPage монтируется один раз на комнату);
// при повторном подключении (смена conferenceId/token) статус до первого
// события нового сокета может на короткое время показывать значение от
// предыдущего соединения — не считается веб-сокет-ready до первого
// onopen/onerror/onclose. Когда чат выключен/данных для подключения ещё
// нет, наружу отдаём производный статус `closed` без всякого state — см.
// `status` ниже.
const [wsStatus, setWsStatus] = useState<ChatConnectionStatus>('connecting')
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const canConnect = enabled && Boolean(conferenceId) && Boolean(token)
useEffect(() => {
if (!canConnect || !conferenceId || !token) {
return
}
// Флаг «эффект пересоздан/компонент размонтирован» — без него обработчики
// СТАРОГО сокета (onclose и т.п.), сработавшие асинхронно уже после того,
// как эффект пересоздался для нового conferenceId/token, могли бы
// перетереть состояние уже актуального соединения.
let stale = false
const ws = new WebSocket(buildChatWsUrl(conferenceId))
wsRef.current = ws
ws.onopen = () => {
if (stale) return
setMessages([])
setHandQueue([])
setStatusMessage(null)
setUnavailable(false)
setWsStatus('open')
ws.send(JSON.stringify({ type: 'auth', token }))
}
ws.onmessage = (event) => {
if (stale) return
let frame: IncomingFrame
try {
frame = JSON.parse(event.data as string) as IncomingFrame
} catch {
return
}
if (frame.type === 'history') {
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}`)
}
}
ws.onerror = () => {
if (stale) return
setWsStatus('error')
}
ws.onclose = (event) => {
wsRef.current = null
if (stale) return
if (event.code === CLOSE_TOKEN_INVALID) {
setWsStatus('error')
setStatusMessage('Сессия чата истекла — обновите страницу, чтобы переподключиться')
} else if (event.code === CLOSE_FORBIDDEN) {
setWsStatus('error')
setStatusMessage('Нет доступа к чату этой конференции')
} else if (event.code === CLOSE_UNAVAILABLE) {
setWsStatus('closed')
setUnavailable(true)
} else {
setWsStatus('closed')
}
}
return () => {
stale = true
wsRef.current = null
ws.close()
}
}, [canConnect, conferenceId, token])
const sendMessage = useCallback((text: string) => {
const trimmed = text.trim()
if (!trimmed || trimmed.length > 2000) return
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
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,
handQueue,
raiseHand,
lowerHand,
lastForcedMute,
}
}