Транспорт — существующий аутентифицированный 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-обработки, без которых кнопки принудительного мьюта не скомпилировались бы; сама реализация мьюта — следующим коммитом.
276 lines
14 KiB
TypeScript
276 lines
14 KiB
TypeScript
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,
|
||
}
|
||
}
|