178 lines
8.7 KiB
TypeScript
178 lines
8.7 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'
|
||
|
||
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 }
|
||
}
|