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([]) const [handQueue, setHandQueue] = useState([]) const [lastForcedMute, setLastForcedMute] = useState(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('connecting') const [statusMessage, setStatusMessage] = useState(null) const [unavailable, setUnavailable] = useState(false) const wsRef = useRef(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, } }