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' type IncomingFrame = | { type: 'history'; messages: ChatMessageOut[] } | { type: 'message'; message: ChatMessageOut } | { type: 'error'; code: string } 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 } /** 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-клиент чата комнаты конференции. Реализует зафиксированный протокол: * connect → `{type:"auth"}` → `{type:"history"}` → далее входящие * `{type:"message"}`/`{type:"error"}`. * * Optimistic-append собственных сообщений НЕ делается: сервер всегда * присылает наше же сообщение обратно echo-фреймом `message` — если * добавлять его на клиенте сразу при отправке, оно задублируется в списке. */ export function useChat({ conferenceId, token, enabled }: UseChatOptions): UseChatResult { const [messages, setMessages] = useState([]) // `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([]) 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 === 'error') { // Ошибка отдельной операции (например, отклонённое сообщение) — соединение // не рвётся, просто короткое пояснение пользователю. 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 })) }, []) // Наружу — производный статус: пока подключаться нечем (выключено/нет // conferenceId/token), всегда `closed`, даже если внутренний `wsStatus` // ещё хранит значение от предыдущего подключения. const status: ChatConnectionStatus = canConnect ? wsStatus : 'closed' return { messages, status, statusMessage, unavailable, sendMessage } }