Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View 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 }
}