feat(web): звонки в беседах — API, стор и события (Фаза 7)

Ручной модуль api/calls.ts повторяет контракт сервера: разбор звонка один
и тот же для REST-ручек, READY-снапшота и событий DM_CALL_*, поэтому
состояние разбирается одним кодом.

Стор dmCall ведёт медиа-сессию поверх общей обёртки lib/livekit: те же
плитки, устройства, качество публикации и продление токена, но без
серверных ролей — мьют и деаф только свои. Рингтон синтезируется Web Audio
(файлов в проекте нет): входящий — пока звонят нам, исходящий — пока ждём
ответа; свёрнутая вкладка замолкает.

Карточка беседы несёт active_call: маркер «идёт звонок» приходит в READY и
в списке бесед, а дальше обновляется событиями DM_CALL_RING/UPDATE/END.
Завершение гасит локальную сессию и играет сигнал тем, кто был в звонке.
This commit is contained in:
2026-09-26 17:12:52 +03:00
parent 0b55fbdfe2
commit 431013c3e3
10 changed files with 999 additions and 6 deletions
+252
View File
@@ -0,0 +1,252 @@
import { request, type RequestOptions } from './client';
/**
* Звонки в личных и групповых беседах (AGENT.md §7.8, §7.14): создание,
* принятие, отклонение, выход, флаги участника и история. Голос тот же, что в
* серверных комнатах, — те же токены LiveKit, но серверных ролей в беседе нет.
*/
/** Состояние звонка: гудки, разговор или завершение. */
export type DmCallStatus = 'ringing' | 'active' | 'ended';
/** Состояние участника звонка. */
export type DmCallParticipantState = 'invited' | 'joined' | 'declined' | 'left' | 'missed';
/** Чем открыли звонок: влияет на историю и на включение камеры при входе. */
export type DmCallMedia = 'audio' | 'video';
/** Причина завершения: по ней история отличает пропущенный от разговора. */
export type DmCallEndReason = 'completed' | 'canceled' | 'declined' | 'missed' | 'failed';
export interface DmCallParticipant {
user_id: string;
state: DmCallParticipantState;
self_mute: boolean;
self_deaf: boolean;
camera: boolean;
screen: boolean;
joined_at?: string;
left_at?: string;
}
/** Звонок беседы: состояние, состав и длительность. */
export interface DmCall {
id: string;
channel_id: string;
initiator_id: string;
status: DmCallStatus;
media: DmCallMedia;
created_at: string;
started_at?: string;
ended_at?: string;
ended_by?: string;
end_reason?: DmCallEndReason;
duration_seconds: number;
participants: DmCallParticipant[];
}
/** Ответ `POST /channels/{id}/call` и `/call/accept`: токен и адрес LiveKit. */
export interface DmCallJoinResult {
call: DmCall;
token: string;
url: string;
room: string;
}
/** Тело `PATCH /channels/{id}/call/@me`. */
export interface DmCallSelfPatch {
self_mute?: boolean;
self_deaf?: boolean;
camera?: boolean;
screen?: boolean;
}
export const dmCallHistoryQueryKey = (channelId: string) =>
['channels', channelId, 'calls'] as const;
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
function asStatus(value: unknown): DmCallStatus {
return value === 'active' || value === 'ended' ? value : 'ringing';
}
function asParticipantState(value: unknown): DmCallParticipantState {
switch (value) {
case 'joined':
case 'declined':
case 'left':
case 'missed':
return value;
default:
return 'invited';
}
}
/**
* Разбирает звонок, отбрасывая кадры не по контракту. Форма одна и та же у
* REST-ручек, READY-снапшота и событий `DM_CALL_*`, поэтому разбор общий.
*/
export function parseDmCall(value: unknown): DmCall | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const channelId = asString(record['channel_id']);
const initiatorId = asString(record['initiator_id']);
if (id === undefined || channelId === undefined || initiatorId === undefined) {
return null;
}
const call: DmCall = {
id,
channel_id: channelId,
initiator_id: initiatorId,
status: asStatus(record['status']),
media: record['media'] === 'video' ? 'video' : 'audio',
created_at: asString(record['created_at']) ?? '',
duration_seconds:
typeof record['duration_seconds'] === 'number' ? record['duration_seconds'] : 0,
participants: Array.isArray(record['participants'])
? record['participants']
.map(parseDmCallParticipant)
.filter((item): item is DmCallParticipant => item !== null)
: [],
};
const startedAt = asString(record['started_at']);
const endedAt = asString(record['ended_at']);
const endedBy = asString(record['ended_by']);
const endReason = asString(record['end_reason']);
if (startedAt !== undefined) {
call.started_at = startedAt;
}
if (endedAt !== undefined) {
call.ended_at = endedAt;
}
if (endedBy !== undefined) {
call.ended_by = endedBy;
}
if (endReason !== undefined) {
call.end_reason = endReason as DmCallEndReason;
}
return call;
}
function parseDmCallParticipant(value: unknown): DmCallParticipant | null {
const record = asRecord(value);
const userId = asString(record?.['user_id']);
if (record === null || userId === undefined) {
return null;
}
const participant: DmCallParticipant = {
user_id: userId,
state: asParticipantState(record['state']),
self_mute: record['self_mute'] === true,
self_deaf: record['self_deaf'] === true,
camera: record['camera'] === true,
screen: record['screen'] === true,
};
const joinedAt = asString(record['joined_at']);
const leftAt = asString(record['left_at']);
if (joinedAt !== undefined) {
participant.joined_at = joinedAt;
}
if (leftAt !== undefined) {
participant.left_at = leftAt;
}
return participant;
}
/** Участник звонка по идентификатору пользователя. */
export function callParticipant(call: DmCall, userId: string): DmCallParticipant | undefined {
return call.participants.find((participant) => participant.user_id === userId);
}
/** Идёт ли звонок прямо сейчас (гудки тоже считаются). */
export function isCallLive(call: DmCall | undefined): boolean {
return call !== undefined && call.status !== 'ended';
}
/** `POST /channels/{id}/call` — позвонить в беседе. */
export function startDmCall(channelId: string, media: DmCallMedia): Promise<DmCallJoinResult> {
return joinRequest(`/channels/${channelId}/call`, { method: 'POST', body: { media } });
}
/** `POST /channels/{id}/call/accept` — принять звонок. */
export function acceptDmCall(channelId: string): Promise<DmCallJoinResult> {
return joinRequest(`/channels/${channelId}/call/accept`, { method: 'POST' });
}
async function joinRequest(path: string, options: RequestOptions): Promise<DmCallJoinResult> {
const payload = await request<{ call?: unknown; token?: unknown; url?: unknown; room?: unknown }>(
path,
options,
);
const call = parseDmCall(payload.call);
if (call === null || typeof payload.token !== 'string' || typeof payload.url !== 'string') {
throw new Error('malformed call payload');
}
return {
call,
token: payload.token,
url: payload.url,
room: typeof payload.room === 'string' ? payload.room : '',
};
}
/** `POST /channels/{id}/call/decline` — отклонить звонок. */
export async function declineDmCall(channelId: string): Promise<DmCall> {
return callResponse(`/channels/${channelId}/call/decline`, { method: 'POST' });
}
/** `POST /channels/{id}/call/end` — выйти из звонка или завершить его. */
export async function endDmCall(channelId: string): Promise<DmCall> {
return callResponse(`/channels/${channelId}/call/end`, { method: 'POST' });
}
/** `GET /channels/{id}/call` — текущий звонок беседы (null, если никто не звонит). */
export async function fetchDmCall(channelId: string, signal?: AbortSignal): Promise<DmCall | null> {
const payload = await request<{ call?: unknown }>(
`/channels/${channelId}/call`,
signal === undefined ? {} : { signal },
);
return parseDmCall(payload.call);
}
/** `GET /channels/{id}/calls` — история звонков беседы. */
export async function fetchDmCallHistory(
channelId: string,
before?: string,
signal?: AbortSignal,
): Promise<DmCall[]> {
const query = before === undefined ? '' : `?before=${encodeURIComponent(before)}`;
const payload = await request<{ calls?: unknown }>(`/channels/${channelId}/calls${query}`, {
...(signal === undefined ? {} : { signal }),
});
if (!Array.isArray(payload.calls)) {
return [];
}
return payload.calls.map(parseDmCall).filter((item): item is DmCall => item !== null);
}
/** `PATCH /channels/{id}/call/@me` — флаги микрофона, звука, камеры и экрана. */
export async function updateDmCallState(
channelId: string,
patch: DmCallSelfPatch,
): Promise<DmCall> {
return callResponse(`/channels/${channelId}/call/@me`, { method: 'PATCH', body: patch });
}
async function callResponse(path: string, options: RequestOptions) {
const payload = await request<{ call?: unknown }>(path, options);
const call = parseDmCall(payload.call);
if (call === null) {
throw new Error('malformed call payload');
}
return call;
}
+4
View File
@@ -1,3 +1,4 @@
import { parseDmCall } from './calls';
import { request } from './client'; import { request } from './client';
import type { DmRecipient, MemberCosmetics } from './types'; import type { DmRecipient, MemberCosmetics } from './types';
import { userStatuses, type Channel, type UserStatus } from './types'; import { userStatuses, type Channel, type UserStatus } from './types';
@@ -252,6 +253,9 @@ export function parseDirectChannel(value: unknown): Channel | null {
if (recipientId !== undefined) { if (recipientId !== undefined) {
channel.recipient_id = recipientId; channel.recipient_id = recipientId;
} }
// Поле ставится всегда, в том числе пустым: слияние в сторе не должно
// сохранять завершённый звонок после перечитывания списка бесед.
channel.active_call = parseDmCall(record['active_call']) ?? undefined;
if (avatar !== undefined) { if (avatar !== undefined) {
channel.icon_file_id = avatar; channel.icon_file_id = avatar;
} }
+4
View File
@@ -1,3 +1,4 @@
import { parseDmCall } from './calls';
import { parseGuildEmojiList } from './emojis'; import { parseGuildEmojiList } from './emojis';
import { parseGuildSoundList } from './sounds'; import { parseGuildSoundList } from './sounds';
import { userStatuses, type Channel, type ChannelOverwrite, type UserStatus } from './types'; import { userStatuses, type Channel, type ChannelOverwrite, type UserStatus } from './types';
@@ -401,6 +402,9 @@ export function parseChannelPayload(value: unknown): Channel | null {
if (lastMessageAt !== undefined) { if (lastMessageAt !== undefined) {
channel.last_message_at = lastMessageAt; channel.last_message_at = lastMessageAt;
} }
// Звонок в беседе (AGENT.md 7.8, Фаза 7): READY уже знает про идущий звонок,
// поэтому маркер «идёт звонок» не теряется после перезагрузки страницы.
channel.active_call = parseDmCall(record['active_call']) ?? undefined;
// Групповая личная беседа (AGENT.md 7.8, Фаза 7): имя, состав и владелец. // Групповая личная беседа (AGENT.md 7.8, Фаза 7): имя, состав и владелец.
if (record['is_group'] === true) { if (record['is_group'] === true) {
channel.is_group = true; channel.is_group = true;
+11
View File
@@ -4,6 +4,8 @@
* в зависимости от прав и состояния сервера). * в зависимости от прав и состояния сервера).
*/ */
import type { DmCall } from './calls';
/** Виды присутствия, которые сервер принимает в PATCH /users/@me. */ /** Виды присутствия, которые сервер принимает в PATCH /users/@me. */
export const userStatuses = ['online', 'idle', 'dnd', 'invisible', 'offline'] as const; export const userStatuses = ['online', 'idle', 'dnd', 'invisible', 'offline'] as const;
export type UserStatus = (typeof userStatuses)[number]; export type UserStatus = (typeof userStatuses)[number];
@@ -121,6 +123,15 @@ export interface Channel {
member_ids?: string[]; member_ids?: string[];
owner_id?: string; owner_id?: string;
recipients?: DmRecipient[]; recipients?: DmRecipient[];
/**
* Идущий в беседе звонок (AGENT.md 7.8, Фаза 7): приходит в READY и в
* REST-списке бесед, дальше обновляется событиями `DM_CALL_*`. Список бесед
* рисует по нему маркер «идёт звонок», шапка — кнопку «Присоединиться».
*
* `| undefined` явно: разбор ответа всегда проставляет поле, чтобы слияние в
* сторе не сохраняло завершённый звонок после перечитывания бесед.
*/
active_call?: DmCall | undefined;
} }
/** Участник групповой беседы. */ /** Участник групповой беседы. */
+32 -3
View File
@@ -114,9 +114,9 @@
"template": { "template": {
"limit_reached": "You have reached the personal template limit — delete one of them." "limit_reached": "You have reached the personal template limit — delete one of them."
}, },
"voice.disabled": "Voice is not configured on this instance.", "voice.disabled": "Voice is disabled on this instance.",
"voice.channel_full": "No free slots in this room.", "voice.channel_full": "No free slots in this room.",
"voice.token_refresh_failed": "Could not renew the voice session. Please join again.", "voice.token_refresh_failed": "Could not refresh the call connection.",
"sound": { "sound": {
"invalid_type": "MP3, OGG, WAV, WebM and M4A are supported.", "invalid_type": "MP3, OGG, WAV, WebM and M4A are supported.",
"invalid_name": "Sound name: 2–32 characters, latin letters, digits and «_».", "invalid_name": "Sound name: 2–32 characters, latin letters, digits and «_».",
@@ -132,7 +132,11 @@
"push.invalid_endpoint": "The browser sent an invalid push service endpoint.", "push.invalid_endpoint": "The browser sent an invalid push service endpoint.",
"push.invalid_keys": "The browser sent invalid subscription keys.", "push.invalid_keys": "The browser sent invalid subscription keys.",
"push.too_many_subscriptions": "Too many devices with push notifications: disable some of them.", "push.too_many_subscriptions": "Too many devices with push notifications: disable some of them.",
"unfurl.disabled": "Link previews are disabled on this instance." "unfurl.disabled": "Link previews are disabled on this instance.",
"dm.call_limit": "The call already has ten participants.",
"dm.call_in_progress": "A call is already running in this conversation.",
"dm.call_not_found": "The call has already ended.",
"dm.blocked": "Direct messages with this user are blocked."
}, },
"theme": { "theme": {
"switchToLight": "Light theme", "switchToLight": "Light theme",
@@ -498,6 +502,31 @@
"body": "This direct conversation was not found: it may be deleted or closed to you." "body": "This direct conversation was not found: it may be deleted or closed to you."
} }
}, },
"call": {
"menu": "Call",
"audio": "Voice call",
"video": "Video call",
"join": "Join the call",
"leave": "Leave",
"accept": "Accept",
"decline": "Decline",
"incoming": "Incoming call",
"incomingVideo": "Incoming video call",
"inProgress": "Call in progress",
"connected": "You are in the call",
"empty": "Waiting for others…",
"pending": "Invited: {{names}}",
"history": "Call history",
"historyEmpty": "No calls yet.",
"entryAudio": "— voice call",
"entryVideo": "— video call",
"entryMissed": "— missed call",
"entryNoAnswer": "— no answer",
"entryDeclined": "— call declined",
"entryCanceled": "— call canceled",
"duration": "{{minutes}} min {{seconds}} s",
"durationSeconds": "{{count}} s"
},
"blocked": { "blocked": {
"section": "Blocked ({{count}})", "section": "Blocked ({{count}})",
"unblock": "Unblock", "unblock": "Unblock",
+32 -3
View File
@@ -114,9 +114,9 @@
"template": { "template": {
"limit_reached": "Достигнут лимит своих шаблонов — удалите один из них." "limit_reached": "Достигнут лимит своих шаблонов — удалите один из них."
}, },
"voice.disabled": "Голос не настроен на инстансе.", "voice.disabled": "Голос на этом инстансе выключен.",
"voice.channel_full": "В комнате нет свободных мест.", "voice.channel_full": "В комнате нет свободных мест.",
"voice.token_refresh_failed": "Не удалось продлить сессию голоса. Подключитесь заново.", "voice.token_refresh_failed": "Не удалось продлить подключение к звонку.",
"sound": { "sound": {
"invalid_type": "Поддерживаются MP3, OGG, WAV, WebM и M4A.", "invalid_type": "Поддерживаются MP3, OGG, WAV, WebM и M4A.",
"invalid_name": "Имя звука: 2–32 символа, латиница, цифры и «_».", "invalid_name": "Имя звука: 2–32 символа, латиница, цифры и «_».",
@@ -132,7 +132,11 @@
"push.invalid_endpoint": "Браузер прислал некорректный адрес push-сервиса.", "push.invalid_endpoint": "Браузер прислал некорректный адрес push-сервиса.",
"push.invalid_keys": "Браузер прислал некорректные ключи подписки.", "push.invalid_keys": "Браузер прислал некорректные ключи подписки.",
"push.too_many_subscriptions": "Слишком много устройств с push-уведомлениями: отключите лишние.", "push.too_many_subscriptions": "Слишком много устройств с push-уведомлениями: отключите лишние.",
"unfurl.disabled": "Превью ссылок на инстансе выключены." "unfurl.disabled": "Превью ссылок на инстансе выключены.",
"dm.call_limit": "В звонке уже десять участников — больше не помещается.",
"dm.call_in_progress": "В этой беседе уже идёт звонок.",
"dm.call_not_found": "Звонок уже завершён.",
"dm.blocked": "Личные сообщения с этим пользователем заблокированы."
}, },
"theme": { "theme": {
"switchToLight": "Светлая тема", "switchToLight": "Светлая тема",
@@ -498,6 +502,31 @@
"body": "Личная беседа не найдена: возможно, она удалена или доступ к ней закрыт." "body": "Личная беседа не найдена: возможно, она удалена или доступ к ней закрыт."
} }
}, },
"call": {
"menu": "Звонок",
"audio": "Аудиозвонок",
"video": "Видеозвонок",
"join": "Присоединиться к звонку",
"leave": "Завершить",
"accept": "Принять",
"decline": "Отклонить",
"incoming": "Входящий звонок",
"incomingVideo": "Входящий видеозвонок",
"inProgress": "Идёт звонок",
"connected": "Вы в звонке",
"empty": "Ждём остальных участников…",
"pending": "Приглашены: {{names}}",
"history": "История звонков",
"historyEmpty": "Звонков ещё не было.",
"entryAudio": "— аудиозвонок",
"entryVideo": "— видеозвонок",
"entryMissed": "— пропущенный звонок",
"entryNoAnswer": "— не ответили",
"entryDeclined": "— звонок отклонён",
"entryCanceled": "— звонок отменён",
"duration": "{{minutes}} мин {{seconds}} с",
"durationSeconds": "{{count}} с"
},
"blocked": { "blocked": {
"section": "Заблокированные ({{count}})", "section": "Заблокированные ({{count}})",
"unblock": "Разблокировать", "unblock": "Разблокировать",
+144
View File
@@ -0,0 +1,144 @@
import { useVoiceStore } from '@/stores/voice';
/**
* Рингтон звонка в беседе. Встроенных аудиофайлов в проекте нет (все сигналы
* синтезируются), поэтому рингтон — тот же приём, что у звуков интерфейса:
* осцилляторы Web Audio. Цикл держит таймер, а не `Audio.loop`: файлов нет, а
* так слышно и «входящий» (два тона), и «гудки» у звонящего (один тон).
*/
export type RingtoneKind = 'incoming' | 'outgoing';
/** Период повтора и длительность сигнала, мс. */
export const RINGTONE_PERIOD_MS = 3000;
export const RINGTONE_TONE_MS = 1200;
/** Частоты сигналов: входящий — две ноты, исходящий — короткие гудки. */
const PATTERNS: Record<RingtoneKind, number[]> = {
incoming: [660, 880],
outgoing: [440],
};
type AudioContextFactory = () => AudioContext;
let contextFactory: AudioContextFactory = () => new AudioContext();
let context: AudioContext | null = null;
let timer: ReturnType<typeof setInterval> | null = null;
let playing: RingtoneKind | null = null;
let onVisibilityStop: (() => void) | null = null;
/** Подменяет фабрику AudioContext (тесты). */
export function setRingtoneContextFactory(factory: AudioContextFactory): void {
contextFactory = factory;
context = null;
}
/** Текущий сигнал: null, если рингтон не играет. */
export function currentRingtone(): RingtoneKind | null {
return playing;
}
/** Играет ли рингтон прямо сейчас. */
export function isRingtonePlaying(): boolean {
return playing !== null;
}
function audioContext(): AudioContext {
context ??= contextFactory();
return context;
}
/** Громкость рингтона: общая громкость вывода и «наушники» из настроек голоса. */
function volume(): number {
const state = useVoiceStore.getState();
const base = state.headphonesEnabled ? state.outputVolume / 100 : 1;
return Math.min(1, Math.max(0, base * 0.35));
}
function playPattern(kind: RingtoneKind): void {
const ctx = audioContext();
const gainValue = volume();
if (gainValue <= 0) {
return;
}
const frequencies = PATTERNS[kind];
frequencies.forEach((frequency, index) => {
const start = ctx.currentTime + index * (RINGTONE_TONE_MS / frequencies.length);
const duration = RINGTONE_TONE_MS / frequencies.length;
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = frequency;
// Плавные фронты: резкий старт даёт щелчок.
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(gainValue, start + 0.05);
gain.gain.setValueAtTime(gainValue, start + duration - 0.05);
gain.gain.linearRampToValueAtTime(0, start + duration);
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.start(start);
oscillator.stop(start + duration);
});
}
/** Запускает рингтон; повторный вызов с тем же сигналом ничего не меняет. */
export function startRingtone(kind: RingtoneKind): void {
if (playing === kind) {
return;
}
stopRingtone();
playing = kind;
playPattern(kind);
timer = setInterval(() => playPattern(kind), RINGTONE_PERIOD_MS);
// Свёрнутая вкладка не должна звенеть бесконечно: возвращаемся — зазвонит снова.
if (typeof document !== 'undefined' && onVisibilityStop === null) {
onVisibilityStop = () => {
if (document.hidden) {
stopRingtone();
}
};
document.addEventListener('visibilitychange', onVisibilityStop);
}
}
/** Останавливает рингтон. */
export function stopRingtone(): void {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
playing = null;
}
/** Короткий сигнал завершения звонка: два нисходящих тона. */
export function playCallEndedTone(): void {
const ctx = audioContext();
const gainValue = volume();
if (gainValue <= 0) {
return;
}
[520, 360].forEach((frequency, index) => {
const start = ctx.currentTime + index * 0.16;
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = frequency;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(gainValue, start + 0.02);
gain.gain.linearRampToValueAtTime(0, start + 0.14);
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.start(start);
oscillator.stop(start + 0.16);
});
}
/** Полный сброс движка: нужен тестам и выходу из аккаунта. */
export function resetRingtone(): void {
stopRingtone();
if (onVisibilityStop !== null && typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibilityStop);
}
onVisibilityStop = null;
context = null;
}
+474
View File
@@ -0,0 +1,474 @@
import { create } from 'zustand';
import {
acceptDmCall,
endDmCall,
startDmCall,
updateDmCallState,
type DmCall,
type DmCallMedia,
type DmCallSelfPatch,
} from '@/api/calls';
import {
createVoiceRoom,
tokenExpiresAt,
type VoiceParticipant,
type VoiceRoomHandle,
type VoiceStats,
} from '@/lib/livekit';
import { useSessionStore } from '@/stores/session';
import { useVoiceStore, type VoiceDeviceKind, type VoicePublishQuality } from '@/stores/voice';
/**
* Медиа-сессия звонка в личной беседе (AGENT.md §7.8, §7.14). Отдельный стор, а
* не `voiceConnection`: там состояние привязано к серверу и его ролям
* (`voice_states`, server mute), а в беседе сервера нет — позвонить может
* участник. Медиа-слой общий: та же обёртка `lib/livekit`, те же плитки.
*/
export type DmCallSessionStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error';
/** Код ошибки, когда не удалось продлить токен комнаты. */
export const DM_CALL_TOKEN_EXPIRED_CODE = 'voice.token_refresh_failed';
export const DM_CALL_REFRESH_LEAD_MS = 60_000;
export const DM_CALL_REFRESH_MIN_DELAY_MS = 1_000;
export const DM_CALL_REFRESH_ATTEMPTS = 3;
export interface DmCallState {
/** Беседа, в звонке которой мы находимся; null — не в звонке. */
channelId: string | null;
callId: string | null;
status: DmCallSessionStatus;
participants: VoiceParticipant[];
latencies: Record<string, number>;
micEnabled: boolean;
deafened: boolean;
cameraEnabled: boolean;
screenEnabled: boolean;
refreshing: boolean;
error: unknown;
/** Позвонить: создать звонок и подключиться (инициатор). */
start: (channelId: string, media: DmCallMedia) => Promise<void>;
/** Принять входящий звонок и подключиться. */
accept: (channelId: string) => Promise<void>;
/** Выйти из звонка (в беседе вдвоём — завершить разговор). */
leave: () => Promise<void>;
setMicEnabled: (enabled: boolean) => Promise<void>;
setDeafened: (enabled: boolean) => void;
setCameraEnabled: (enabled: boolean) => Promise<void>;
setScreenShareEnabled: (enabled: boolean) => Promise<void>;
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
fetchStats: () => Promise<VoiceStats>;
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
/** Сброс без запросов к серверу: выход из аккаунта, тесты. */
reset: () => void;
}
const defaults = {
channelId: null,
callId: null,
status: 'idle' as DmCallSessionStatus,
participants: [] as VoiceParticipant[],
latencies: {} as Record<string, number>,
micEnabled: true,
deafened: false,
cameraEnabled: false,
screenEnabled: false,
refreshing: false,
error: null as unknown,
};
// Вне стора: объект комнаты LiveKit и служебные таймеры не сериализуются.
let room: VoiceRoomHandle | null = null;
let stopLatencyReporting: (() => void) | null = null;
let joinedChannelId: string | null = null;
let generation = 0;
let tokenTimer: ReturnType<typeof setTimeout> | null = null;
let plannedDisconnect = false;
function describeUnknown(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** Публикует состояние звонка в стор бесед: маркер «идёт звонок» в списке и шапке. */
function publishCall(call: DmCall | null): void {
if (call === null) {
return;
}
useSessionStore.getState().setDmChannelCall(call.channel_id, call);
}
export const useDmCallStore = create<DmCallState>((set, get) => {
const clearTokenTimer = (): void => {
if (tokenTimer !== null) {
clearTimeout(tokenTimer);
tokenTimer = null;
}
};
/** Флаги уходят на сервер: остальные участники видят их в составе звонка. */
const patchSelf = async (patch: DmCallSelfPatch): Promise<void> => {
const channelId = get().channelId;
if (channelId === null) {
return;
}
try {
publishCall(await updateDmCallState(channelId, patch));
} catch (error) {
// Флаг не доехал: разговор продолжается, состояние поправит следующий вызов.
console.warn(`dm call: не удалось обновить состояние (${describeUnknown(error)})`);
}
};
const closeRoom = async (): Promise<void> => {
stopLatencyReporting?.();
stopLatencyReporting = null;
set({ latencies: {} });
const handle = room;
room = null;
if (handle === null) {
return;
}
plannedDisconnect = true;
try {
await handle.disconnect();
} catch {
// Комната могла уже отключиться: для выхода это не ошибка.
} finally {
plannedDisconnect = false;
handle.dispose();
}
};
const scheduleTokenRefresh = (token: string, current: number): void => {
clearTokenTimer();
const now = Date.now();
const expiresAt = tokenExpiresAt(token, now);
const delay = Math.max(DM_CALL_REFRESH_MIN_DELAY_MS, expiresAt - now - DM_CALL_REFRESH_LEAD_MS);
tokenTimer = setTimeout(() => {
tokenTimer = null;
void refreshToken(current);
}, delay);
};
/**
* Переиздание токена: сервер выдаёт его на 10 минут (AGENT.md 7.14).
* Повторный accept для уже подключённого участника идемпотентен и отдаёт
* свежий токен, поэтому отдельной ручки продления не нужно.
*/
const refreshToken = async (current: number): Promise<void> => {
const channelId = get().channelId;
if (channelId === null || current !== generation) {
return;
}
set({ refreshing: true });
for (let attempt = 1; attempt <= DM_CALL_REFRESH_ATTEMPTS; attempt += 1) {
try {
const result = await acceptDmCall(channelId);
if (current !== generation) {
return;
}
publishCall(result.call);
const handle = room;
if (handle === null) {
set({ refreshing: false });
return;
}
plannedDisconnect = true;
try {
await handle.disconnect();
} finally {
plannedDisconnect = false;
}
await handle.connect(result.url, result.token);
if (current !== generation) {
return;
}
const { micEnabled, cameraEnabled, screenEnabled, deafened } = get();
await handle.setMicrophoneEnabled(micEnabled);
await handle.setCameraEnabled(cameraEnabled);
await handle.setScreenShareEnabled(screenEnabled);
set({ status: 'connected', refreshing: false, participants: handle.participants() });
scheduleTokenRefresh(result.token, current);
await patchSelf({
self_mute: !micEnabled,
self_deaf: deafened,
camera: cameraEnabled,
screen: screenEnabled,
});
return;
} catch (error) {
if (attempt === DM_CALL_REFRESH_ATTEMPTS) {
await closeRoom();
joinedChannelId = null;
set({ ...defaults, error: { code: DM_CALL_TOKEN_EXPIRED_CODE } });
if (channelId !== null) {
try {
publishCall(await endDmCall(channelId));
} catch {
// Звонок мог уже закончиться: для клиента это выход.
}
}
void error;
return;
}
}
}
};
/** Подключение к комнате звонка: общий путь для «позвонить» и «принять». */
const connect = async (
channelId: string,
result: { call: DmCall; token: string; url: string },
mine: number,
): Promise<void> => {
joinedChannelId = channelId;
publishCall(result.call);
scheduleTokenRefresh(result.token, mine);
const preferences = useVoiceStore.getState();
const handle = await createVoiceRoom({
onParticipants: (participants) => {
if (mine === generation) {
set({ participants });
}
},
onStatus: (status) => {
if (mine !== generation || plannedDisconnect) {
return;
}
if (status === 'reconnecting') {
set({ status: 'reconnecting' });
return;
}
if (status === 'reconnected') {
set({ status: 'connected' });
return;
}
// SDK исчерпал попытки: комната недоступна, выходим из звонка.
void get().leave();
},
onSpeakingWhileMuted: () => undefined,
onLatency: (identity, latency) => {
if (mine !== generation) {
return;
}
set((state) => {
const latencies = { ...state.latencies };
if (latency === null) {
delete latencies[identity];
} else {
latencies[identity] = latency;
}
return { latencies };
});
},
});
if (mine !== generation) {
handle.dispose();
return;
}
room = handle;
await handle.connect(result.url, result.token);
if (mine !== generation) {
handle.dispose();
if (room === handle) {
room = null;
}
return;
}
stopLatencyReporting = handle.startLatencyReporting(
() => useVoiceStore.getState().latencySharingEnabled,
);
await handle.setMicrophoneEnabled(preferences.micEnabled);
await handle.setCameraEnabled(preferences.cameraEnabled);
await handle.setVideoQuality(preferences.videoQuality);
set({ status: 'connected', participants: handle.participants() });
};
return {
...defaults,
start: async (channelId, media) => {
const current = get();
if (current.channelId === channelId && current.status !== 'error') {
return;
}
if (current.channelId !== null) {
await get().leave();
}
const preferences = useVoiceStore.getState();
const mine = (generation += 1);
set({
...defaults,
status: 'connecting',
channelId,
// Видеозвонок сразу включает камеру: так звонок и задуман.
cameraEnabled: media === 'video' && preferences.cameraEnabled,
micEnabled: preferences.micEnabled,
deafened: !preferences.headphonesEnabled,
});
try {
const result = await startDmCall(channelId, media);
if (mine !== generation) {
return;
}
set({ callId: result.call.id });
await connect(channelId, result, mine);
} catch (error) {
if (mine !== generation) {
return;
}
set({ ...defaults, status: 'error', channelId, error });
}
},
accept: async (channelId) => {
const current = get();
if (
current.channelId === channelId &&
(current.status === 'connected' || current.status === 'connecting')
) {
return;
}
if (current.channelId !== null) {
await get().leave();
}
const preferences = useVoiceStore.getState();
const mine = (generation += 1);
set({
...defaults,
status: 'connecting',
channelId,
micEnabled: preferences.micEnabled,
deafened: !preferences.headphonesEnabled,
});
try {
const result = await acceptDmCall(channelId);
if (mine !== generation) {
return;
}
set({ callId: result.call.id, cameraEnabled: result.call.media === 'video' });
await connect(channelId, result, mine);
} catch (error) {
if (mine !== generation) {
return;
}
set({ ...defaults, status: 'error', channelId, error });
}
},
leave: async () => {
generation += 1;
clearTokenTimer();
await closeRoom();
const channelId = joinedChannelId ?? get().channelId;
joinedChannelId = null;
set({ ...defaults });
if (channelId === null) {
return;
}
try {
publishCall(await endDmCall(channelId));
} catch (error) {
console.warn(`dm call: не удалось выйти из звонка (${describeUnknown(error)})`);
}
},
setMicEnabled: async (enabled) => {
useVoiceStore.getState().setMicEnabled(enabled);
set({ micEnabled: enabled });
const handle = room;
if (handle !== null) {
try {
await handle.setMicrophoneEnabled(enabled);
set({ participants: handle.participants() });
} catch (error) {
set({ error });
}
}
await patchSelf({ self_mute: !enabled });
},
setDeafened: (enabled) => {
useVoiceStore.getState().setHeadphonesEnabled(!enabled);
set({ deafened: enabled });
void patchSelf({ self_deaf: enabled });
},
setCameraEnabled: async (enabled) => {
useVoiceStore.getState().setCameraEnabled(enabled);
set({ cameraEnabled: enabled });
const handle = room;
if (handle !== null) {
try {
await handle.setCameraEnabled(enabled);
set({ participants: handle.participants() });
} catch (error) {
set({ error });
}
}
await patchSelf({ camera: enabled });
},
setScreenShareEnabled: async (enabled) => {
set({ screenEnabled: enabled });
const handle = room;
if (handle !== null) {
const preset = useVoiceStore.getState().screenSharePreset;
try {
await handle.setScreenShareEnabled(enabled, preset);
set({ participants: handle.participants() });
} catch (error) {
set({ error });
}
}
await patchSelf({ screen: enabled });
},
setVideoQuality: async (quality) => {
useVoiceStore.getState().setVideoQuality(quality);
const handle = room;
if (handle !== null) {
await handle.setVideoQuality(quality);
}
},
fetchStats: async () => {
const handle = room;
if (handle === null) {
throw new Error('dm call: no active call');
}
return handle.fetchStats();
},
switchActiveDevice: async (kind, deviceId) => {
const handle = room;
if (handle === null) {
return;
}
await handle.switchActiveDevice(kind, deviceId);
},
reset: () => {
generation += 1;
clearTokenTimer();
stopLatencyReporting?.();
stopLatencyReporting = null;
const handle = room;
room = null;
handle?.dispose();
joinedChannelId = null;
set({ ...defaults });
},
};
});
/** Завершение звонка по решению другого участника: гасим локальную сессию. */
export function dropDmCallSession(channelId: string): void {
if (useDmCallStore.getState().channelId !== channelId) {
return;
}
useDmCallStore.getState().reset();
}
+28
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { callParticipant, parseDmCall } from '@/api/calls';
import { guildEmojisQueryKey, type GuildEmoji } from '@/api/emojis'; import { guildEmojisQueryKey, type GuildEmoji } from '@/api/emojis';
import { import {
connectGateway, connectGateway,
@@ -31,6 +32,8 @@ import {
parseUserUpdate, parseUserUpdate,
} from '@/api/gatewayEvents'; } from '@/api/gatewayEvents';
import { guildSoundsQueryKey, type GuildSound } from '@/api/sounds'; import { guildSoundsQueryKey, type GuildSound } from '@/api/sounds';
import { playCallEndedTone } from '@/lib/ringtone';
import { dropDmCallSession } from '@/stores/dmCall';
import { useFriendsStore } from '@/stores/friends'; import { useFriendsStore } from '@/stores/friends';
import { useMessagesStore } from '@/stores/messages'; import { useMessagesStore } from '@/stores/messages';
import { useSessionStore, type SessionGuild } from '@/stores/session'; import { useSessionStore, type SessionGuild } from '@/stores/session';
@@ -396,6 +399,31 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
} }
return; return;
} }
case 'DM_CALL_RING':
case 'DM_CALL_UPDATE':
case 'DM_CALL_END': {
// Звонок в беседе (AGENT.md 7.8, Фаза 7): приглашение приходит событием,
// состояние звонка обновляется целиком, а завершение гасит сессию у тех,
// кто в нём был. Посторонним эти события не приходят: сервер рассылает
// их только участникам звонка.
const call = parseDmCall(event.d);
if (call === null) {
return;
}
const me = session.user?.id ?? null;
const mine = me === null ? undefined : callParticipant(call, me);
if (event.t === 'DM_CALL_END') {
useSessionStore.getState().setDmChannelCall(call.channel_id, null);
dropDmCallSession(call.channel_id);
// Сигнал завершения слышит только тот, кто действительно был в звонке.
if (mine !== undefined && mine.state !== 'invited' && mine.state !== 'missed') {
playCallEndedTone();
}
return;
}
useSessionStore.getState().setDmChannelCall(call.channel_id, call);
return;
}
case 'VOICE_STATE_UPDATE': { case 'VOICE_STATE_UPDATE': {
// Лёг из комнаты или сменил флаги: единственный источник «кто в голосе». // Лёг из комнаты или сменил флаги: единственный источник «кто в голосе».
const voice = parseVoiceStateEvent(event.d); const voice = parseVoiceStateEvent(event.d);
+18
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { GuildEmoji } from '@/api/emojis'; import type { GuildEmoji } from '@/api/emojis';
import type { DmCall } from '@/api/calls';
import { fetchChannels } from '@/api/guilds'; import { fetchChannels } from '@/api/guilds';
import { fetchDirectChannels } from '@/api/friends'; import { fetchDirectChannels } from '@/api/friends';
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway'; import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
@@ -110,6 +111,12 @@ interface SessionState {
patchDmPresence: (userId: string, status: UserStatus | undefined) => void; patchDmPresence: (userId: string, status: UserStatus | undefined) => void;
/** MESSAGE_CREATE в беседе: сдвигаем «последнее сообщение» (бейджи и порядок). */ /** MESSAGE_CREATE в беседе: сдвигаем «последнее сообщение» (бейджи и порядок). */
touchDmChannel: (channelId: string, messageId: string, at: string) => void; touchDmChannel: (channelId: string, messageId: string, at: string) => void;
/**
* Состояние звонка в беседе (AGENT.md 7.8, Фаза 7): `active_call` в карточке
* беседы — из READY, REST-списка и событий `DM_CALL_*`. `null` убирает
* маркер «идёт звонок» после завершения.
*/
setDmChannelCall: (channelId: string, call: DmCall | null) => void;
/** DM_CHANNEL_CREATE: перечитываем беседы по REST. */ /** DM_CHANNEL_CREATE: перечитываем беседы по REST. */
reloadDmChannels: () => Promise<void>; reloadDmChannels: () => Promise<void>;
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */ /** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
@@ -563,6 +570,17 @@ export const useSessionStore = create<SessionState>((set, get) => ({
}); });
}, },
setDmChannelCall: (channelId, call) => {
if (!get().dmChannels.some((channel) => channel.id === channelId)) {
return;
}
set({
dmChannels: get().dmChannels.map((channel) =>
channel.id === channelId ? { ...channel, active_call: call ?? undefined } : channel,
),
});
},
reloadDmChannels: async () => { reloadDmChannels: async () => {
try { try {
set({ dmChannels: await fetchDirectChannels() }); set({ dmChannels: await fetchDirectChannels() });