Files
vidconf/frontend/src/pages/JoinPage.tsx
Max Ronzhin 10a3f8b3b4 feat(room): очередь поднятых рук видна всем + отключаемый модуль
Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь
видит любой участник, но опустить чужую руку по-прежнему может только
организатор (сервер это уже проверял, менял только фронт). Кнопка
«Опустить» показывается у записи, только если это своя рука или
пользователь — организатор.

Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый
в админке (instance_settings.hand_queue, дефолт enabled=true, как у
chat_enabled). Настройка едет участнику в JoinOut ещё до входа в
комнату; выключенный модуль гасит кнопки и на фронте, и на бэке —
raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если
модуль выключен, даже если у клиента на руках старый JoinOut.
2026-08-04 18:27:14 +03:00

420 lines
18 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 { useEffect, useRef, useState, type FormEvent } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { AlertCircle, ArrowRight, CheckCircle2, Eye, EyeOff, Hash, Link2, Lock, Mail, User, Video } from 'lucide-react'
import {
guestJoinConference,
joinConference,
resolveConference,
type ConferenceJoinData,
} from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import { parseJoinQuery } from '@/lib/parseJoinQuery'
import '@/styles/join.css'
type Step = 'input' | 'guest-info' | 'password' | 'connecting' | 'ended'
interface ResolvedTarget {
id: string
title: string | null
isClosed: boolean
requiresPassword: boolean
/** Нормализованный запрос пользователя (slug/номер) — используется как параметр `/room/:slug`. */
query: string
}
/**
* Подключение к конференции (см. design/mockups/join.html) — три состояния
* одного флоу: (а) ввод ссылки/номера, (б) «представиться» для гостя,
* (в) пароль закрытой конференции. Экран публичный — доступен без сессии
* (гость входит без аккаунта), поэтому не завёрнут в `RequireAuth`.
*
* Маршруты: `/join` (ручной ввод, опционально `?q=` — например, после
* редиректа из RoomPage при потере join-состояния) и `/j/:slug` (прямая
* ссылка-приглашение — резолвится автоматически при заходе).
*
* Авторизованный пользователь пропускает «представиться»: после резолва либо
* сразу входит (если конференция открыта), либо сразу видит экран пароля
* (если закрыта). Гость всегда проходит «представиться»; если конференция
* закрыта, экран пароля показывается вторым шагом, после чего оба поля
* (имя/email + пароль) уходят одним запросом `guest-join`.
*/
export function JoinPage() {
const navigate = useNavigate()
const { slug: slugParam } = useParams<{ slug?: string }>()
const [searchParams] = useSearchParams()
const { status } = useAuth()
const isAuthenticated = status === 'authenticated'
const initialQuery = slugParam ?? searchParams.get('q') ?? ''
const [query, setQuery] = useState(initialQuery)
const [resolving, setResolving] = useState(false)
const [resolveError, setResolveError] = useState<string | null>(null)
const [step, setStep] = useState<Step>('input')
const [target, setTarget] = useState<ResolvedTarget | null>(null)
const [guestName, setGuestName] = useState('')
const [guestEmail, setGuestEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [invalidPassword, setInvalidPassword] = useState(false)
const [joining, setJoining] = useState(false)
const autoResolvedRef = useRef(false)
// Прямая ссылка (/j/:slug) или ?q= из редиректа — резолвим сразу, не
// дожидаясь клика по «Продолжить», но только после того как известен
// статус сессии (нужен для выбора «гость / авторизованный» флоу).
useEffect(() => {
if (status === 'loading' || autoResolvedRef.current || !initialQuery) return
autoResolvedRef.current = true
void handleResolve(initialQuery)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status])
async function handleResolve(rawQuery: string) {
const normalized = parseJoinQuery(rawQuery)
if (!normalized) return
setResolveError(null)
setResolving(true)
try {
const info = await resolveConference(normalized)
// Завершённая конференция (ADR-001) — backend отдаёт минимальный ответ
// без is_closed/requires_password: сразу показываем «завершена», не
// дожидаясь шага «представиться»/пароль и попытки join (которая всё
// равно вернёт 410).
if (info.status === 'ended') {
setTarget({ id: info.id, title: info.title, isClosed: false, requiresPassword: false, query: normalized })
setStep('ended')
return
}
const nextTarget: ResolvedTarget = {
id: info.id,
title: info.title,
isClosed: info.is_closed ?? false,
requiresPassword: info.requires_password ?? false,
query: normalized,
}
setTarget(nextTarget)
if (nextTarget.requiresPassword) {
setStep('password')
} else if (isAuthenticated) {
setStep('connecting')
await performJoin(nextTarget, {})
} else {
setStep('guest-info')
}
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setResolveError('Конференция не найдена — проверьте ссылку или номер')
} else {
setResolveError('Не удалось найти конференцию. Попробуйте ещё раз')
}
setStep('input')
} finally {
setResolving(false)
}
}
async function performJoin(
resolved: ResolvedTarget,
opts: { password?: string; guestName?: string; guestEmail?: string },
) {
setJoining(true)
setInvalidPassword(false)
try {
let data: ConferenceJoinData
if (isAuthenticated) {
data = await joinConference(resolved.id, opts.password ? { password: opts.password } : undefined)
} else {
data = await guestJoinConference(resolved.id, {
display_name: opts.guestName ?? guestName.trim(),
email: (opts.guestEmail ?? guestEmail).trim() || undefined,
password: opts.password,
})
}
navigate(`/room/${encodeURIComponent(resolved.query)}`, {
replace: true,
state: {
livekitUrl: data.livekit_url,
token: data.token,
title: resolved.title,
conferenceId: data.conference_id,
chatEnabled: data.chat_enabled,
handQueueEnabled: data.hand_queue_enabled,
publishQualityCap: data.publish_quality_cap,
stageMaxTiles: data.stage_max_tiles,
},
})
} catch (err) {
if (err instanceof ApiError && err.status === 403 && errorDetail(err) === 'password_required') {
setStep('password')
} else if (err instanceof ApiError && err.status === 403 && errorDetail(err) === 'invalid_password') {
setInvalidPassword(true)
setStep('password')
} else if (err instanceof ApiError && err.status === 410) {
setStep('ended')
} else {
setResolveError('Не удалось подключиться к конференции. Попробуйте ещё раз позже')
setStep('input')
}
} finally {
setJoining(false)
}
}
function handleInputSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
void handleResolve(query)
}
function handleGuestInfoSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!target || !guestName.trim()) return
if (target.requiresPassword) {
setStep('password')
} else {
void performJoin(target, {})
}
}
function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!target || !password) return
void performJoin(target, { password })
}
function resetToInput() {
setResolveError(null)
setInvalidPassword(false)
setStep('input')
}
if (step === 'connecting') {
return (
<div className="app-loader" role="status" aria-live="polite">
Подключаемся к конференции
</div>
)
}
return (
<div className="join-shell">
<header className="join-topbar">
<Link to="/lobby" className="brand-mark">
<LogoMark /> VidConf
</Link>
<ThemeToggle />
</header>
<main className="join-main">
{step === 'input' && (
<section className="screen-block">
<div className="flow-card">
<div className="flow-icon" aria-hidden="true">
<Link2 className="icon" aria-hidden="true" />
</div>
<h1>Подключиться к конференции</h1>
<p className="flow-sub">Вставьте ссылку-приглашение или введите номер конференции</p>
<form onSubmit={handleInputSubmit}>
<div className={`field${resolveError ? ' has-error' : ''}`}>
<label htmlFor="join-input">Ссылка или номер</label>
<div className="input-wrap">
<Hash className="icon" aria-hidden="true" />
<input
id="join-input"
type="text"
placeholder="vidconf.ru/j/... или 8842 104"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</div>
{resolveError ? (
<p className="error-msg">
<AlertCircle style={{ width: 16, height: 16 }} aria-hidden="true" /> {resolveError}
</p>
) : (
<p className="field-hint">Найдите номер или ссылку в приглашении организатора</p>
)}
</div>
<button type="submit" className="btn btn-primary" disabled={!query.trim() || resolving}>
{resolving ? 'Ищем конференцию…' : 'Продолжить'}
{!resolving && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}
</button>
</form>
<Link to="/lobby" className="link-back">
Назад в лобби
</Link>
</div>
</section>
)}
{step === 'guest-info' && target && (
<section className="screen-block">
<div className="flow-card">
<div className="target-conf">
<span className="icon-wrap" aria-hidden="true">
{target.isClosed ? <Lock className="icon" aria-hidden="true" /> : <Video className="icon" aria-hidden="true" />}
</span>
<div>
<strong>{target.title ?? 'Конференция без названия'}</strong>
<span> {target.query}</span>
</div>
</div>
<div className="flow-icon" aria-hidden="true">
<User className="icon" aria-hidden="true" />
</div>
<h1>Как вас зовут?</h1>
<p className="flow-sub">У вас нет аккаунта VidConf представьтесь, чтобы войти как гость</p>
<form onSubmit={handleGuestInfoSubmit}>
<div className="field">
<label htmlFor="guest-name">
Имя<span className="field-optional">· обязательно</span>
</label>
<div className="input-wrap">
<User className="icon" aria-hidden="true" />
<input
id="guest-name"
type="text"
placeholder="Как к вам обращаться"
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
autoFocus
/>
</div>
</div>
<div className="field">
<label htmlFor="guest-email">
Email<span className="field-optional">· необязательно</span>
</label>
<div className="input-wrap">
<Mail className="icon" aria-hidden="true" />
<input
id="guest-email"
type="email"
placeholder="you@mail.ru"
value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)}
/>
</div>
<p className="field-hint">Укажите, если хотите получить саммари конференции на почту</p>
</div>
<button type="submit" className="btn btn-primary" disabled={!guestName.trim() || joining}>
{joining ? 'Входим…' : 'Войти в конференцию'}
{!joining && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}
</button>
</form>
<button type="button" className="link-back" onClick={resetToInput}>
Назад
</button>
</div>
</section>
)}
{step === 'password' && target && (
<section className="screen-block">
<div className="flow-card">
<div className="target-conf">
<span className="icon-wrap" aria-hidden="true">
<Lock className="icon" aria-hidden="true" />
</span>
<div>
<strong>{target.title ?? 'Конференция без названия'}</strong>
<span> {target.query} · закрытая</span>
</div>
</div>
<div className="flow-icon" aria-hidden="true">
<Lock className="icon" aria-hidden="true" />
</div>
<h1>Введите пароль</h1>
<p className="flow-sub">Эта конференция закрыта доступ только по паролю от организатора</p>
<form onSubmit={handlePasswordSubmit}>
<div className={`field${invalidPassword ? ' has-error' : ''}`}>
<label htmlFor="join-pass">Пароль конференции</label>
<div className="input-wrap">
<Lock className="icon" aria-hidden="true" />
<input
id="join-pass"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value)
setInvalidPassword(false)
}}
autoFocus
/>
<button
type="button"
className="toggle-eye"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOff className="icon" aria-hidden="true" /> : <Eye className="icon" aria-hidden="true" />}
</button>
</div>
{invalidPassword && (
<p className="error-msg">
<AlertCircle style={{ width: 16, height: 16 }} aria-hidden="true" /> Неверный пароль, попробуйте ещё раз
</p>
)}
</div>
<button type="submit" className="btn btn-primary" disabled={!password || joining}>
{joining ? 'Входим…' : 'Войти'}
{!joining && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}
</button>
</form>
<button type="button" className="link-back" onClick={resetToInput}>
Назад
</button>
</div>
</section>
)}
{step === 'ended' && (
<section className="screen-block">
<div className="flow-card">
{target && (
<div className="target-conf">
<span className="icon-wrap" aria-hidden="true">
<CheckCircle2 className="icon" aria-hidden="true" />
</span>
<div>
<strong>{target.title ?? 'Конференция без названия'}</strong>
<span> {target.query} · завершена</span>
</div>
</div>
)}
<div className="flow-icon" aria-hidden="true">
<CheckCircle2 className="icon" aria-hidden="true" />
</div>
<h1>Конференция завершена</h1>
<p className="flow-sub">Эта конференция уже завершилась и недоступна для повторного входа</p>
<button type="button" className="btn btn-primary" onClick={resetToInput}>
Ввести другую ссылку или номер
</button>
<Link to="/lobby" className="link-back">
Назад в лобби
</Link>
</div>
</section>
)}
</main>
<AppFooter />
</div>
)
}