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:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Участник групповой беседы. */
|
/** Участник групповой беседы. */
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Разблокировать",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|||||||
Reference in New Issue
Block a user