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 type { DmRecipient, MemberCosmetics } from './types';
|
||||
import { userStatuses, type Channel, type UserStatus } from './types';
|
||||
@@ -252,6 +253,9 @@ export function parseDirectChannel(value: unknown): Channel | null {
|
||||
if (recipientId !== undefined) {
|
||||
channel.recipient_id = recipientId;
|
||||
}
|
||||
// Поле ставится всегда, в том числе пустым: слияние в сторе не должно
|
||||
// сохранять завершённый звонок после перечитывания списка бесед.
|
||||
channel.active_call = parseDmCall(record['active_call']) ?? undefined;
|
||||
if (avatar !== undefined) {
|
||||
channel.icon_file_id = avatar;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseDmCall } from './calls';
|
||||
import { parseGuildEmojiList } from './emojis';
|
||||
import { parseGuildSoundList } from './sounds';
|
||||
import { userStatuses, type Channel, type ChannelOverwrite, type UserStatus } from './types';
|
||||
@@ -401,6 +402,9 @@ export function parseChannelPayload(value: unknown): Channel | null {
|
||||
if (lastMessageAt !== undefined) {
|
||||
channel.last_message_at = lastMessageAt;
|
||||
}
|
||||
// Звонок в беседе (AGENT.md 7.8, Фаза 7): READY уже знает про идущий звонок,
|
||||
// поэтому маркер «идёт звонок» не теряется после перезагрузки страницы.
|
||||
channel.active_call = parseDmCall(record['active_call']) ?? undefined;
|
||||
// Групповая личная беседа (AGENT.md 7.8, Фаза 7): имя, состав и владелец.
|
||||
if (record['is_group'] === true) {
|
||||
channel.is_group = true;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* в зависимости от прав и состояния сервера).
|
||||
*/
|
||||
|
||||
import type { DmCall } from './calls';
|
||||
|
||||
/** Виды присутствия, которые сервер принимает в PATCH /users/@me. */
|
||||
export const userStatuses = ['online', 'idle', 'dnd', 'invisible', 'offline'] as const;
|
||||
export type UserStatus = (typeof userStatuses)[number];
|
||||
@@ -121,6 +123,15 @@ export interface Channel {
|
||||
member_ids?: string[];
|
||||
owner_id?: string;
|
||||
recipients?: DmRecipient[];
|
||||
/**
|
||||
* Идущий в беседе звонок (AGENT.md 7.8, Фаза 7): приходит в READY и в
|
||||
* REST-списке бесед, дальше обновляется событиями `DM_CALL_*`. Список бесед
|
||||
* рисует по нему маркер «идёт звонок», шапка — кнопку «Присоединиться».
|
||||
*
|
||||
* `| undefined` явно: разбор ответа всегда проставляет поле, чтобы слияние в
|
||||
* сторе не сохраняло завершённый звонок после перечитывания бесед.
|
||||
*/
|
||||
active_call?: DmCall | undefined;
|
||||
}
|
||||
|
||||
/** Участник групповой беседы. */
|
||||
|
||||
@@ -114,9 +114,9 @@
|
||||
"template": {
|
||||
"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.token_refresh_failed": "Could not renew the voice session. Please join again.",
|
||||
"voice.token_refresh_failed": "Could not refresh the call connection.",
|
||||
"sound": {
|
||||
"invalid_type": "MP3, OGG, WAV, WebM and M4A are supported.",
|
||||
"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_keys": "The browser sent invalid subscription keys.",
|
||||
"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": {
|
||||
"switchToLight": "Light theme",
|
||||
@@ -498,6 +502,31 @@
|
||||
"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": {
|
||||
"section": "Blocked ({{count}})",
|
||||
"unblock": "Unblock",
|
||||
|
||||
@@ -114,9 +114,9 @@
|
||||
"template": {
|
||||
"limit_reached": "Достигнут лимит своих шаблонов — удалите один из них."
|
||||
},
|
||||
"voice.disabled": "Голос не настроен на инстансе.",
|
||||
"voice.disabled": "Голос на этом инстансе выключен.",
|
||||
"voice.channel_full": "В комнате нет свободных мест.",
|
||||
"voice.token_refresh_failed": "Не удалось продлить сессию голоса. Подключитесь заново.",
|
||||
"voice.token_refresh_failed": "Не удалось продлить подключение к звонку.",
|
||||
"sound": {
|
||||
"invalid_type": "Поддерживаются MP3, OGG, WAV, WebM и M4A.",
|
||||
"invalid_name": "Имя звука: 2–32 символа, латиница, цифры и «_».",
|
||||
@@ -132,7 +132,11 @@
|
||||
"push.invalid_endpoint": "Браузер прислал некорректный адрес push-сервиса.",
|
||||
"push.invalid_keys": "Браузер прислал некорректные ключи подписки.",
|
||||
"push.too_many_subscriptions": "Слишком много устройств с push-уведомлениями: отключите лишние.",
|
||||
"unfurl.disabled": "Превью ссылок на инстансе выключены."
|
||||
"unfurl.disabled": "Превью ссылок на инстансе выключены.",
|
||||
"dm.call_limit": "В звонке уже десять участников — больше не помещается.",
|
||||
"dm.call_in_progress": "В этой беседе уже идёт звонок.",
|
||||
"dm.call_not_found": "Звонок уже завершён.",
|
||||
"dm.blocked": "Личные сообщения с этим пользователем заблокированы."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Светлая тема",
|
||||
@@ -498,6 +502,31 @@
|
||||
"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": {
|
||||
"section": "Заблокированные ({{count}})",
|
||||
"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 { callParticipant, parseDmCall } from '@/api/calls';
|
||||
import { guildEmojisQueryKey, type GuildEmoji } from '@/api/emojis';
|
||||
import {
|
||||
connectGateway,
|
||||
@@ -31,6 +32,8 @@ import {
|
||||
parseUserUpdate,
|
||||
} from '@/api/gatewayEvents';
|
||||
import { guildSoundsQueryKey, type GuildSound } from '@/api/sounds';
|
||||
import { playCallEndedTone } from '@/lib/ringtone';
|
||||
import { dropDmCallSession } from '@/stores/dmCall';
|
||||
import { useFriendsStore } from '@/stores/friends';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
import { useSessionStore, type SessionGuild } from '@/stores/session';
|
||||
@@ -396,6 +399,31 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
||||
}
|
||||
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': {
|
||||
// Лёг из комнаты или сменил флаги: единственный источник «кто в голосе».
|
||||
const voice = parseVoiceStateEvent(event.d);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { GuildEmoji } from '@/api/emojis';
|
||||
import type { DmCall } from '@/api/calls';
|
||||
import { fetchChannels } from '@/api/guilds';
|
||||
import { fetchDirectChannels } from '@/api/friends';
|
||||
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
|
||||
@@ -110,6 +111,12 @@ interface SessionState {
|
||||
patchDmPresence: (userId: string, status: UserStatus | undefined) => void;
|
||||
/** MESSAGE_CREATE в беседе: сдвигаем «последнее сообщение» (бейджи и порядок). */
|
||||
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. */
|
||||
reloadDmChannels: () => Promise<void>;
|
||||
/** 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 () => {
|
||||
try {
|
||||
set({ dmChannels: await fetchDirectChannels() });
|
||||
|
||||
Reference in New Issue
Block a user