Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь видит любой участник, но опустить чужую руку по-прежнему может только организатор (сервер это уже проверял, менял только фронт). Кнопка «Опустить» показывается у записи, только если это своя рука или пользователь — организатор. Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый в админке (instance_settings.hand_queue, дефолт enabled=true, как у chat_enabled). Настройка едет участнику в JoinOut ещё до входа в комнату; выключенный модуль гасит кнопки и на фронте, и на бэке — raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если модуль выключен, даже если у клиента на руках старый JoinOut.
278 lines
14 KiB
TypeScript
278 lines
14 KiB
TypeScript
/**
|
||
* API-функции конференций: предустановленных комнат нет, конференции
|
||
* создаются динамически.
|
||
*
|
||
* Все даты — UTC ISO-8601 со 'Z' (в БД и API только UTC).
|
||
*/
|
||
import { apiRequest } from '@/api/client'
|
||
|
||
/** Жизненный цикл конференции (см. ADR-001; `draft` не используется). */
|
||
export type ConferenceStatus = 'scheduled' | 'active' | 'ended'
|
||
|
||
/** Тип повторения закреплённой конференции. */
|
||
export type RecurrenceType = 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
|
||
|
||
/**
|
||
* Режим рассылки саммари конференции — переопределение дефолта
|
||
* инстанса (`null` = «по умолчанию», см. `SettingsOut.summary_recipients`
|
||
* в `src/api/admin.ts`).
|
||
*/
|
||
export type SummaryRecipientsMode = 'all' | 'owner'
|
||
|
||
/**
|
||
* Потолок качества исходящего видео участника (`instance_settings.media_limits`,
|
||
* см. `SettingsOut`/`SettingsUpdateIn` в `src/api/admin.ts`). `off` — без
|
||
* ограничения. Применяется на клиенте через `publishDefaults`
|
||
* (`lib/publishQualityCap.ts`) — режет битрейт верхнего слоя симулкаста, а не
|
||
* жёсткое разрешение захвата камеры.
|
||
*/
|
||
export type PublishQualityCap = 'off' | '180p' | '360p' | '720p'
|
||
|
||
/**
|
||
* Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью
|
||
* `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там).
|
||
* Поля, специфичные для типа (`weekdays` для weekly/biweekly, `day_of_month`
|
||
* для monthly, `interval_days` для every_n_days), обязательны только для
|
||
* своего типа — см. валидацию на backend.
|
||
*/
|
||
export interface ConferenceRecurrence {
|
||
type: RecurrenceType
|
||
/** 0 (понедельник) .. 6 (воскресенье) — обязателен для weekly/biweekly. */
|
||
weekdays?: number[]
|
||
/** 1..31 — обязателен для monthly (переносится на последний день короткого месяца). */
|
||
day_of_month?: number
|
||
/** >= 1 — обязателен для every_n_days. */
|
||
interval_days?: number
|
||
/** Дата первого вхождения серии (YYYY-MM-DD) — якорь отсчёта для biweekly/every_n_days. */
|
||
anchor_date: string
|
||
/** Локальное время начала вхождения, формат HH:MM. */
|
||
time_local: string
|
||
/** IANA-таймзона (браузера пользователя на момент создания/редактирования). */
|
||
timezone: string
|
||
/** Длительность вхождения в минутах — дублирует верхнеуровневое поле конференции (того требует модель RecurrenceRule). */
|
||
duration_minutes: number
|
||
}
|
||
|
||
/** Данные для входа в LiveKit-комнату конференции. */
|
||
export interface ConferenceJoinData {
|
||
livekit_url: string
|
||
token: string
|
||
room_name: string
|
||
conference_id: string
|
||
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
|
||
chat_enabled: boolean
|
||
/** Включён ли модуль «поднятие руки» — при `false` кнопка «Рука» и очередь не рендерятся. */
|
||
hand_queue_enabled: boolean
|
||
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
|
||
publish_quality_cap: PublishQualityCap
|
||
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */
|
||
stage_max_tiles: number
|
||
}
|
||
|
||
/**
|
||
* Приглашённый участник конференции — либо
|
||
* зарегистрированный пользователь (`user_id` заполнен), либо внешний гость
|
||
* по email (`user_id === null`). Организатор всегда присутствует первым
|
||
* элементом (`is_organizer: true`), добавляется backend'ом автоматически.
|
||
*/
|
||
export interface InviteeOut {
|
||
user_id: string | null
|
||
email: string
|
||
name: string | null
|
||
avatar_url: string | null
|
||
is_organizer: boolean
|
||
}
|
||
|
||
/**
|
||
* Приглашаемый участник в теле создания/обновления — либо `user_id`
|
||
* зарегистрированного пользователя, либо `email` внешнего гостя (ровно одно
|
||
* из полей). Организатора указывать не нужно — backend добавляет его
|
||
* автоматически и обязательно.
|
||
*/
|
||
export type InviteeIn = { user_id: string } | { email: string }
|
||
|
||
/** Конференция (ответ API). */
|
||
export interface ConferenceOut {
|
||
id: string
|
||
number: string
|
||
slug: string
|
||
title: string | null
|
||
status: ConferenceStatus
|
||
is_pinned: boolean
|
||
is_closed: boolean
|
||
scheduled_at: string | null
|
||
duration_minutes: number | null
|
||
recurrence: ConferenceRecurrence | null
|
||
next_occurrence: string | null
|
||
created_at: string
|
||
/** `null` — используется дефолт инстанса (см. `SettingsOut.summary_recipients`). */
|
||
summary_recipients: SummaryRecipientsMode | null
|
||
/** Присутствует только у мгновенной конференции — сразу входим, не дожидаясь отдельного join. */
|
||
join?: ConferenceJoinData
|
||
/** Владелец (организатор) конференции. */
|
||
owner_id: string
|
||
/** Организатор ли конференции текущий пользователь — относительно него же скрываются кнопки правки/удаления. */
|
||
is_owner: boolean
|
||
organizer_name: string | null
|
||
/** Присутствует ТОЛЬКО в ответе детального эндпоинта (`GET /conferences/{id}`, owner|admin) — списковые эндпоинты (`/conferences/my`, календарь) его не возвращают. */
|
||
participants?: InviteeOut[]
|
||
}
|
||
|
||
/** Тело запроса на создание конференции. Без `scheduled_at` — мгновенная (ответ сразу содержит `join`). */
|
||
export interface ConferenceCreatePayload {
|
||
title?: string
|
||
/** UTC ISO-8601. */
|
||
scheduled_at?: string
|
||
duration_minutes?: number
|
||
is_pinned?: boolean
|
||
recurrence?: ConferenceRecurrence
|
||
is_closed?: boolean
|
||
password?: string
|
||
/** `null`/не передано — дефолт инстанса; `'all'`/`'owner'` — явное переопределение. */
|
||
summary_recipients?: SummaryRecipientsMode | null
|
||
/** `undefined` — состав не менялся (не отправлять поле); `null`/массив — полная замена состава (кроме организатора, добавляется backend'ом сам). */
|
||
participants?: InviteeIn[] | null
|
||
}
|
||
|
||
/**
|
||
* Краткие сведения о конференции по ссылке/номеру — для экрана подключения
|
||
* (JoinPage). Для `status === 'ended'` (ADR-001) backend отдаёт минимальный
|
||
* ответ — `is_closed`/`requires_password` отсутствуют (незачем: повторный
|
||
* вход недоступен независимо от закрытости).
|
||
*/
|
||
export interface ConferenceResolveOut {
|
||
id: string
|
||
title: string | null
|
||
status: ConferenceStatus
|
||
is_closed?: boolean
|
||
requires_password?: boolean
|
||
}
|
||
|
||
/** Тело входа авторизованного пользователя в конференцию. */
|
||
export interface ConferenceJoinPayload {
|
||
password?: string
|
||
}
|
||
|
||
/** Тело гостевого входа — представление обязательно, email факультативен (для рассылки саммари). */
|
||
export interface ConferenceGuestJoinPayload {
|
||
display_name: string
|
||
email?: string
|
||
password?: string
|
||
}
|
||
|
||
/** Источник трека, который организатор может принудительно выключить (задача B2). */
|
||
export type MuteSource = 'microphone' | 'camera'
|
||
|
||
/** Ответ на принудительный мьют — `false`, если трек и так не был опубликован (нечего было мьютить). */
|
||
export interface MuteParticipantResult {
|
||
muted: boolean
|
||
}
|
||
|
||
/** Тело частичного обновления конференции — те же поля, что и при создании, все опциональны. */
|
||
export type ConferenceUpdatePayload = Partial<ConferenceCreatePayload>
|
||
|
||
/** Одно вхождение (развёрнутое по recurrence или разовое) в календарной выборке. */
|
||
export interface OccurrenceOut {
|
||
conference_id: string
|
||
title: string | null
|
||
/** UTC ISO-8601. */
|
||
starts_at: string
|
||
/** UTC ISO-8601. */
|
||
ends_at: string
|
||
number: string
|
||
slug: string
|
||
is_pinned: boolean
|
||
is_closed: boolean
|
||
}
|
||
|
||
/**
|
||
* Создать конференцию. Без `scheduled_at` тела — мгновенная: создатель сразу
|
||
* входит (см. `ConferenceOut.join`).
|
||
*/
|
||
export async function createConference(payload: ConferenceCreatePayload = {}): Promise<ConferenceOut> {
|
||
return apiRequest<ConferenceOut>('/conferences', { method: 'POST', body: payload })
|
||
}
|
||
|
||
/**
|
||
* Резолв конференции по ссылке (slug) или номеру — публичный эндпоинт,
|
||
* доступен без авторизации (нужен и гостю до входа). 404 — не найдена/недоступна
|
||
* (в т.ч. незакреплённая, уже завершившаяся).
|
||
*/
|
||
export async function resolveConference(query: string): Promise<ConferenceResolveOut> {
|
||
const params = new URLSearchParams({ q: query })
|
||
return apiRequest<ConferenceResolveOut>(`/conferences/resolve?${params.toString()}`, {
|
||
skipAuthRefresh: true,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Вход авторизованного пользователя в конференцию.
|
||
* 403 (`password_required`/`invalid_password`), 410 (`conference_ended`).
|
||
*/
|
||
export async function joinConference(id: string, payload?: ConferenceJoinPayload): Promise<ConferenceJoinData> {
|
||
return apiRequest<ConferenceJoinData>(`/conferences/${id}/join`, { method: 'POST', body: payload })
|
||
}
|
||
|
||
/** Гостевой вход без аккаунта — те же коды ошибок, что и `joinConference`. */
|
||
export async function guestJoinConference(
|
||
id: string,
|
||
payload: ConferenceGuestJoinPayload,
|
||
): Promise<ConferenceJoinData> {
|
||
return apiRequest<ConferenceJoinData>(`/conferences/${id}/guest-join`, {
|
||
method: 'POST',
|
||
body: payload,
|
||
skipAuthRefresh: true,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Принудительно выключить микрофон/камеру участника (задача B2) — только
|
||
* владелец конференции/администратор, иначе 403 (`not_owner`). 404
|
||
* (`participant_not_in_room`) — участника с таким `identity` сейчас нет в
|
||
* комнате LiveKit.
|
||
*/
|
||
export async function muteParticipant(
|
||
conferenceId: string,
|
||
identity: string,
|
||
source: MuteSource,
|
||
): Promise<MuteParticipantResult> {
|
||
return apiRequest<MuteParticipantResult>(`/conferences/${conferenceId}/mute-participant`, {
|
||
method: 'POST',
|
||
body: { identity, source },
|
||
})
|
||
}
|
||
|
||
/** Список «моих» конференций — закреплённые (повторяющиеся) и предстоящие разовые владельца. */
|
||
export async function getMyConferences(): Promise<ConferenceOut[]> {
|
||
return apiRequest<ConferenceOut[]>('/conferences/my')
|
||
}
|
||
|
||
/**
|
||
* Вхождения конференций в календарном диапазоне `[from, to]` (обе границы —
|
||
* UTC ISO, диапазон не длиннее 62 дней — ограничение backend).
|
||
*/
|
||
export async function getCalendarOccurrences(fromIso: string, toIso: string): Promise<OccurrenceOut[]> {
|
||
const params = new URLSearchParams({ from: fromIso, to: toIso })
|
||
return apiRequest<OccurrenceOut[]>(`/conferences/calendar?${params.toString()}`)
|
||
}
|
||
|
||
/** Частично обновить конференцию (название, время, повторение, закрытость и т.п.). */
|
||
export async function updateConference(id: string, payload: ConferenceUpdatePayload): Promise<ConferenceOut> {
|
||
return apiRequest<ConferenceOut>(`/conferences/${id}`, { method: 'PATCH', body: payload })
|
||
}
|
||
|
||
/** Удалить конференцию навсегда. 409 — конференция сейчас идёт (`status: 'active'`). */
|
||
export async function deleteConference(id: string): Promise<void> {
|
||
await apiRequest(`/conferences/${id}`, { method: 'DELETE' })
|
||
}
|
||
|
||
/**
|
||
* Детали одной конференции с полным составом участников (`participants`).
|
||
* Доступно только владельцу или admin — иначе 403.
|
||
* Используется ховер-карточкой (§F2) и формой редактирования для подгрузки
|
||
* состава участников.
|
||
*/
|
||
export async function getConference(id: string): Promise<ConferenceOut> {
|
||
return apiRequest<ConferenceOut>(`/conferences/${id}`)
|
||
}
|