diff --git a/web/src/api/calls.ts b/web/src/api/calls.ts new file mode 100644 index 0000000..c48305b --- /dev/null +++ b/web/src/api/calls.ts @@ -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 | null { + return typeof value === 'object' && value !== null ? (value as Record) : 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 { + return joinRequest(`/channels/${channelId}/call`, { method: 'POST', body: { media } }); +} + +/** `POST /channels/{id}/call/accept` — принять звонок. */ +export function acceptDmCall(channelId: string): Promise { + return joinRequest(`/channels/${channelId}/call/accept`, { method: 'POST' }); +} + +async function joinRequest(path: string, options: RequestOptions): Promise { + 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 { + return callResponse(`/channels/${channelId}/call/decline`, { method: 'POST' }); +} + +/** `POST /channels/{id}/call/end` — выйти из звонка или завершить его. */ +export async function endDmCall(channelId: string): Promise { + return callResponse(`/channels/${channelId}/call/end`, { method: 'POST' }); +} + +/** `GET /channels/{id}/call` — текущий звонок беседы (null, если никто не звонит). */ +export async function fetchDmCall(channelId: string, signal?: AbortSignal): Promise { + 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 { + 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 { + 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; +} diff --git a/web/src/api/friends.ts b/web/src/api/friends.ts index c8f323a..a40ec49 100644 --- a/web/src/api/friends.ts +++ b/web/src/api/friends.ts @@ -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; } diff --git a/web/src/api/gatewayEvents.ts b/web/src/api/gatewayEvents.ts index 3f80dee..234cc3f 100644 --- a/web/src/api/gatewayEvents.ts +++ b/web/src/api/gatewayEvents.ts @@ -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; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 9b4718f..9922f6c 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; } /** Участник групповой беседы. */ diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 20129af..97faa95 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 6939e8d..f744578 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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": "Разблокировать", diff --git a/web/src/lib/ringtone.ts b/web/src/lib/ringtone.ts new file mode 100644 index 0000000..ab068cd --- /dev/null +++ b/web/src/lib/ringtone.ts @@ -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 = { + incoming: [660, 880], + outgoing: [440], +}; + +type AudioContextFactory = () => AudioContext; + +let contextFactory: AudioContextFactory = () => new AudioContext(); +let context: AudioContext | null = null; +let timer: ReturnType | 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; +} diff --git a/web/src/stores/dmCall.ts b/web/src/stores/dmCall.ts new file mode 100644 index 0000000..048ff72 --- /dev/null +++ b/web/src/stores/dmCall.ts @@ -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; + micEnabled: boolean; + deafened: boolean; + cameraEnabled: boolean; + screenEnabled: boolean; + refreshing: boolean; + error: unknown; + + /** Позвонить: создать звонок и подключиться (инициатор). */ + start: (channelId: string, media: DmCallMedia) => Promise; + /** Принять входящий звонок и подключиться. */ + accept: (channelId: string) => Promise; + /** Выйти из звонка (в беседе вдвоём — завершить разговор). */ + leave: () => Promise; + setMicEnabled: (enabled: boolean) => Promise; + setDeafened: (enabled: boolean) => void; + setCameraEnabled: (enabled: boolean) => Promise; + setScreenShareEnabled: (enabled: boolean) => Promise; + setVideoQuality: (quality: VoicePublishQuality) => Promise; + fetchStats: () => Promise; + switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise; + /** Сброс без запросов к серверу: выход из аккаунта, тесты. */ + reset: () => void; +} + +const defaults = { + channelId: null, + callId: null, + status: 'idle' as DmCallSessionStatus, + participants: [] as VoiceParticipant[], + latencies: {} as Record, + 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 | 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((set, get) => { + const clearTokenTimer = (): void => { + if (tokenTimer !== null) { + clearTimeout(tokenTimer); + tokenTimer = null; + } + }; + + /** Флаги уходят на сервер: остальные участники видят их в составе звонка. */ + const patchSelf = async (patch: DmCallSelfPatch): Promise => { + 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 => { + 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 => { + 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 => { + 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(); +} diff --git a/web/src/stores/gateway.ts b/web/src/stores/gateway.ts index ba1b3b9..2b6b9c5 100644 --- a/web/src/stores/gateway.ts +++ b/web/src/stores/gateway.ts @@ -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); diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index 8fdf9b2..3233487 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -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; /** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */ @@ -563,6 +570,17 @@ export const useSessionStore = create((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() });