Первоначальная версия VidConf
This commit is contained in:
177
frontend/src/hooks/useChat.ts
Normal file
177
frontend/src/hooks/useChat.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user