Files
vidconf/frontend/src/hooks/useChat.ts

178 lines
8.7 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'
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<ChatMessageOut[]>([])
// `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([])
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 }
}