feat(web): интерфейс звонков в беседах (Фаза 7)

В шапке беседы — меню звонка: аудио, видео, «Присоединиться» к идущему и
история. Когда голос на инстансе выключен, меню нет вовсе: обещать звонок,
который не поднимется, нельзя.

Входящий звонок показывается карточкой поверх любого экрана с принять и
отклонить и рингтоном; идущий звонок — маркером в списке бесед и в шапке, а
в области переписки вместо ленты открывается панель с плитками участников и
кнопками микрофона, звука, камеры, шаринга и выхода. История звонков — кто
звонил, когда, сколько длился разговор и кто не ответил.

Тесты (vitest): начало звонка из шапки с подключением к комнате LiveKit,
входящий звонок и его принятие, отклонение, маркер «идёт звонок» по событию
и его снятие после завершения, флаги микрофона, камеры и экрана, история с
пропущенными и длительностью, выключенный голос и маркер после
перезагрузки (active_call в READY).
This commit is contained in:
2026-09-26 17:12:56 +03:00
parent 431013c3e3
commit 2b9bf55f16
12 changed files with 1384 additions and 7 deletions
+193
View File
@@ -0,0 +1,193 @@
import { apiError, json, type FetchRoute } from './helpers';
import { fakeVoiceToken } from './voiceRoutes';
import type { DmCall, DmCallMedia, DmCallParticipantState } from '@/api/calls';
/**
* Маршруты звонков в беседах для тестов: состояние живёт в замыкании, поэтому
* ответы согласованы между запросами — как у настоящего сервера (AGENT.md 7.8).
*/
export function makeDmCall(overrides: Partial<DmCall> & Pick<DmCall, 'id' | 'channel_id'>): DmCall {
return {
initiator_id: 'user-1',
status: 'ringing',
media: 'audio',
created_at: '2026-09-26T10:00:00Z',
duration_seconds: 0,
participants: [],
...overrides,
};
}
/** Участник звонка в моке. */
export function makeCallParticipant(
user_id: string,
state: DmCallParticipantState,
overrides: Partial<DmCall['participants'][number]> = {},
): DmCall['participants'][number] {
return {
user_id,
state,
self_mute: false,
self_deaf: false,
camera: false,
screen: false,
...overrides,
};
}
export interface DmCallRoutes {
routes: FetchRoute[];
/** Текущий звонок «сервера»: тесты читают его после запросов. */
call: () => DmCall | null;
/** Что вернул сервер на последний запрос; для проверки тела PATCH. */
setCall: (call: DmCall | null) => void;
}
export interface DmCallRoutesOptions {
channelId?: string;
/** Стартовый звонок: null — никто не звонит. */
call?: DmCall | null;
/** История звонков беседы. */
history?: DmCall[];
/** Код ошибки на создание звонка (`voice.disabled`, `dm.call_limit`, ...). */
startError?: { code: string; status: number };
token?: string;
url?: string;
}
export function dmCallRoutes(options: DmCallRoutesOptions = {}): DmCallRoutes {
const channelId = options.channelId ?? 'c-dm';
let call: DmCall | null = options.call ?? null;
const history = options.history ?? [];
const join = (): Response => {
if (call === null) {
return apiError('dm.call_not_found', 404);
}
return json({
call,
token: options.token ?? fakeVoiceToken(),
url: options.url ?? 'wss://gl.localhost/rtc',
room: `dm_call_${call.id}`,
});
};
const routes: FetchRoute[] = [
{
match: `/channels/${channelId}/call/accept`,
method: 'POST',
response: () => {
if (call !== null) {
call = {
...call,
status: 'active',
started_at: '2026-09-26T10:00:05Z',
participants: call.participants.map((participant) =>
participant.state === 'invited' ? { ...participant, state: 'joined' } : participant,
),
};
}
return join();
},
},
{
match: `/channels/${channelId}/call/decline`,
method: 'POST',
response: () => {
if (call !== null) {
call = {
...call,
status: 'ended',
end_reason: 'declined',
ended_at: '2026-09-26T10:00:06Z',
participants: call.participants.map((participant) =>
participant.state === 'invited' ? { ...participant, state: 'declined' } : participant,
),
};
}
return json({ call });
},
},
{
match: `/channels/${channelId}/call/end`,
method: 'POST',
response: () => {
if (call !== null) {
call = {
...call,
status: 'ended',
end_reason: 'completed',
ended_at: '2026-09-26T10:01:00Z',
participants: call.participants.map((participant) =>
participant.state === 'joined' ? { ...participant, state: 'left' } : participant,
),
};
}
return json({ call });
},
},
{
match: `/channels/${channelId}/call/@me`,
method: 'PATCH',
response: ({ body }) => {
const patch = (body ?? {}) as Record<string, boolean>;
if (call !== null) {
call = {
...call,
participants: call.participants.map((participant) =>
participant.user_id === 'user-1'
? {
...participant,
self_mute: patch['self_mute'] ?? participant.self_mute,
self_deaf: patch['self_deaf'] ?? participant.self_deaf,
camera: patch['camera'] ?? participant.camera,
screen: patch['screen'] ?? participant.screen,
}
: participant,
),
};
}
return json({ call });
},
},
{
match: `/channels/${channelId}/calls`,
method: 'GET',
response: () => json({ calls: history }),
},
{
match: `/channels/${channelId}/call`,
method: 'GET',
response: () => json({ call }),
},
{
match: `/channels/${channelId}/call`,
method: 'POST',
response: ({ body }) => {
if (options.startError !== undefined) {
return apiError(options.startError.code, options.startError.status);
}
const media = ((body ?? {}) as { media?: DmCallMedia }).media ?? 'audio';
call = makeDmCall({
id: 'call-1',
channel_id: channelId,
media,
participants: [
makeCallParticipant('user-1', 'joined'),
makeCallParticipant('user-2', 'invited'),
],
});
return join();
},
},
];
return {
routes,
call: () => call,
setCall: (next) => {
call = next;
},
};
}
+371
View File
@@ -0,0 +1,371 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { currentRingtone } from '@/lib/ringtone';
import { useDmCallStore } from '@/stores/dmCall';
import { useSessionStore } from '@/stores/session';
import { dmCallRoutes, makeCallParticipant, makeDmCall } from './dmCallRoutes';
import { installFakeAudioContext } from './fakeAudio';
import { FakeRoom } from './fakeLiveKit';
import {
findRequest,
installFetch,
installGatewaySocket,
json,
makeReadySnapshot,
makeUser,
recordedRequests,
renderApp,
} from './helpers';
vi.mock('livekit-client', async () => {
const fake = await import('./fakeLiveKit');
return {
Room: fake.FakeRoom,
RoomEvent: fake.RoomEvent,
ConnectionQuality: fake.ConnectionQuality,
Track: fake.Track,
VideoQuality: fake.VideoQuality,
};
});
/**
* Ответы оболочки: мета с включённым голосом (без неё кнопок звонка нет),
* профиль, инстанс, серверы, связи и список бесед.
*/
const baseRoutes = [
{ match: '/api/v1/meta', response: () => json({ features: { voice_enabled: true } }) },
{
match: '/api/v1/users/@me/relationships',
response: () => json({ friends: [], incoming: [], outgoing: [], blocked: [] }),
},
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{
match: '/api/v1/users/@me/channels',
response: () => json({ channels: [] }),
},
{ match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) },
{
match: '/api/v1/instance',
response: () => json({ instance: { name: 'glchat-test', registration_enabled: true } }),
},
];
/** Беседа 1:1 с Бобом в снапшоте READY. */
const dmChannel = {
id: 'c-dm',
name: 'Bob',
recipient_id: 'user-2',
status: 'online',
};
/** Снапшот со звонком в беседе: Алиса приглашена, Боб уже «в комнате». */
function ringingCallSnapshot() {
return makeReadySnapshot(
[],
[
{
...dmChannel,
active_call: makeDmCall({
id: 'call-7',
channel_id: 'c-dm',
initiator_id: 'user-2',
media: 'video',
participants: [
makeCallParticipant('user-2', 'joined'),
makeCallParticipant('user-1', 'invited'),
],
}),
},
],
);
}
/** Поднимает приложение на беседе с подключённым шлюзом. */
async function bootDm(options: {
snapshot?: unknown;
routes: Parameters<typeof installFetch>[0];
path?: string;
}) {
const fetchMock = installFetch([...baseRoutes, ...options.routes]);
const gateway = installGatewaySocket(options.snapshot ?? makeReadySnapshot([], [dmChannel]));
renderApp(options.path ?? '/app/friends/c-dm');
await gateway.greet();
await screen.findByTestId('dm-header');
return { fetchMock, gateway };
}
describe('звонки в беседах', () => {
beforeEach(() => {
FakeRoom.reset();
installFakeAudioContext();
});
it('начинает аудиозвонок из шапки и подключается к комнате LiveKit', async () => {
const calls = dmCallRoutes();
const { fetchMock } = await bootDm({ routes: calls.routes });
fireEvent.click(screen.getByTestId('dm-call-menu'));
fireEvent.click(await screen.findByTestId('dm-call-audio'));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/channels/c-dm/call', method: 'POST' })).toBeDefined();
});
const start = recordedRequests(fetchMock).find(
(request) => request.method === 'POST' && request.url.includes('/channels/c-dm/call'),
);
expect(start?.body).toEqual({ media: 'audio' });
// Комната поднялась по адресу из ответа сервера, панель звонка на месте.
await waitFor(() => {
expect(FakeRoom.latest().connectCalls).toHaveLength(1);
});
expect(FakeRoom.latest().connectCalls[0]?.url).toBe('wss://gl.localhost/rtc');
expect(await screen.findByTestId('dm-call-panel')).toBeInTheDocument();
expect(await screen.findByTestId('dm-call-tile-user-1')).toBeInTheDocument();
// Свои гудки: звонок ещё ringing, играет исходящий сигнал.
expect(currentRingtone()).toBe('outgoing');
expect(useDmCallStore.getState().channelId).toBe('c-dm');
});
it('показывает входящий звонок, играет рингтон и принимает его', async () => {
const calls = dmCallRoutes({
call: makeDmCall({
id: 'call-7',
channel_id: 'c-dm',
initiator_id: 'user-2',
media: 'video',
participants: [
makeCallParticipant('user-2', 'joined'),
makeCallParticipant('user-1', 'invited'),
],
}),
});
const { fetchMock } = await bootDm({ snapshot: ringingCallSnapshot(), routes: calls.routes });
const card = await screen.findByTestId('dm-incoming-call');
expect(card).toBeInTheDocument();
expect(screen.getByTestId('dm-incoming-call-kind')).toHaveTextContent('Входящий видеозвонок');
// Рингтон входящего играет, пока мы не ответили.
await waitFor(() => {
expect(currentRingtone()).toBe('incoming');
});
fireEvent.click(screen.getByTestId('dm-incoming-call-accept'));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/channels/c-dm/call/accept', method: 'POST' }),
).toBeDefined();
});
expect(await screen.findByTestId('dm-call-panel')).toBeInTheDocument();
await waitFor(() => {
expect(currentRingtone()).toBeNull();
});
});
it('отклоняет входящий звонок и убирает карточку', async () => {
const calls = dmCallRoutes({
call: makeDmCall({
id: 'call-7',
channel_id: 'c-dm',
initiator_id: 'user-2',
participants: [makeCallParticipant('user-1', 'invited')],
}),
});
const { fetchMock } = await bootDm({ snapshot: ringingCallSnapshot(), routes: calls.routes });
fireEvent.click(await screen.findByTestId('dm-incoming-call-decline'));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/channels/c-dm/call/decline', method: 'POST' }),
).toBeDefined();
});
// Локальная сессия не поднималась: комнаты LiveKit нет.
expect(FakeRoom.instances).toHaveLength(0);
});
it('показывает «идёт звонок» в списке и шапке по событию и убирает после завершения', async () => {
const calls = dmCallRoutes();
const { gateway } = await bootDm({ routes: calls.routes });
expect(screen.queryByTestId('dm-call-c-dm')).not.toBeInTheDocument();
expect(screen.queryByTestId('dm-call-header-status')).not.toBeInTheDocument();
gateway.dispatch(
'DM_CALL_RING',
makeDmCall({
id: 'call-9',
channel_id: 'c-dm',
initiator_id: 'user-2',
participants: [
makeCallParticipant('user-2', 'joined'),
makeCallParticipant('user-1', 'invited'),
],
}),
);
expect(await screen.findByTestId('dm-call-c-dm')).toBeInTheDocument();
expect(screen.getByTestId('dm-call-header-status')).toHaveTextContent('Идёт звонок');
expect(screen.getByTestId('dm-call-c-dm')).toHaveAttribute('data-status', 'ringing');
expect(await screen.findByTestId('dm-incoming-call')).toBeInTheDocument();
gateway.dispatch(
'DM_CALL_END',
makeDmCall({
id: 'call-9',
channel_id: 'c-dm',
initiator_id: 'user-2',
status: 'ended',
end_reason: 'canceled',
participants: [
makeCallParticipant('user-2', 'left'),
makeCallParticipant('user-1', 'missed'),
],
}),
);
await waitFor(() => {
expect(screen.queryByTestId('dm-call-c-dm')).not.toBeInTheDocument();
});
expect(screen.queryByTestId('dm-call-header-status')).not.toBeInTheDocument();
expect(screen.queryByTestId('dm-incoming-call')).not.toBeInTheDocument();
expect(currentRingtone()).toBeNull();
});
it('обновляет флаги звонка на сервере и в комнате', async () => {
const activeCall = makeDmCall({
id: 'call-7',
channel_id: 'c-dm',
initiator_id: 'user-2',
participants: [
makeCallParticipant('user-2', 'joined'),
makeCallParticipant('user-1', 'invited'),
],
});
const calls = dmCallRoutes({ call: activeCall });
const { fetchMock } = await bootDm({
snapshot: makeReadySnapshot([], [{ ...dmChannel, active_call: activeCall }]),
routes: calls.routes,
});
fireEvent.click(await screen.findByTestId('dm-incoming-call-accept'));
await screen.findByTestId('dm-call-panel');
fireEvent.click(screen.getByTestId('dm-call-mic'));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/channels/c-dm/call/@me', method: 'PATCH' }),
).toBeDefined();
});
const patch = recordedRequests(fetchMock).find(
(request) => request.method === 'PATCH' && request.url.includes('/call/@me'),
);
expect(patch?.body).toEqual({ self_mute: true });
// Микрофон выключен и на стороне SDK.
const micCalls = FakeRoom.latest().localParticipant.calls.filter(
(call) => call.method === 'setMicrophoneEnabled',
);
expect(micCalls.at(-1)?.value).toBe(false);
fireEvent.click(screen.getByTestId('dm-call-camera'));
await waitFor(() => {
const cameraCalls = FakeRoom.latest().localParticipant.calls.filter(
(call) => call.method === 'setCameraEnabled',
);
expect(cameraCalls.at(-1)?.value).toBe(true);
});
fireEvent.click(screen.getByTestId('dm-call-screen'));
await waitFor(() => {
expect(FakeRoom.latest().localParticipant.screenOptions.at(-1)?.enabled).toBe(true);
});
// Выход завершает звонок и гасит сессию.
fireEvent.click(screen.getByTestId('dm-call-leave'));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/channels/c-dm/call/end', method: 'POST' }),
).toBeDefined();
});
await waitFor(() => {
expect(useDmCallStore.getState().channelId).toBeNull();
});
});
it('показывает историю звонков с пропущенными и длительностью', async () => {
const calls = dmCallRoutes({
history: [
makeDmCall({
id: 'call-2',
channel_id: 'c-dm',
initiator_id: 'user-2',
status: 'ended',
end_reason: 'missed',
started_at: '2026-09-26T09:00:00Z',
ended_at: '2026-09-26T09:00:00Z',
participants: [
makeCallParticipant('user-2', 'left'),
makeCallParticipant('user-1', 'missed'),
],
}),
makeDmCall({
id: 'call-1',
channel_id: 'c-dm',
initiator_id: 'user-1',
status: 'ended',
end_reason: 'completed',
duration_seconds: 125,
started_at: '2026-09-26T08:00:00Z',
ended_at: '2026-09-26T08:02:05Z',
participants: [
makeCallParticipant('user-1', 'left'),
makeCallParticipant('user-2', 'left'),
],
}),
],
});
await bootDm({ routes: calls.routes });
fireEvent.click(screen.getByTestId('dm-call-menu'));
fireEvent.click(await screen.findByTestId('dm-call-history'));
const panel = await screen.findByTestId('dm-call-history');
expect(panel).toBeInTheDocument();
expect(await screen.findByTestId('dm-call-history-call-2')).toHaveAttribute(
'data-missed',
'true',
);
expect(screen.getByTestId('dm-call-history-call-1')).toHaveAttribute('data-missed', 'false');
expect(screen.getByTestId('dm-call-duration-call-1')).toHaveTextContent('2 мин 5 с');
});
it('не предлагает звонок, когда голос на инстансе выключен', async () => {
const calls = dmCallRoutes();
const fetchMock = installFetch([
{ match: '/api/v1/meta', response: () => json({ features: { voice_enabled: false } }) },
...baseRoutes.slice(1),
...calls.routes,
]);
const gateway = installGatewaySocket(ringingCallSnapshot());
renderApp('/app/friends/c-dm');
await gateway.greet();
await screen.findByTestId('dm-header');
expect(screen.queryByTestId('dm-call-menu')).not.toBeInTheDocument();
expect(screen.queryByTestId('dm-incoming-call')).not.toBeInTheDocument();
expect(currentRingtone()).toBeNull();
expect(fetchMock).toBeDefined();
});
it('переживает перезагрузку: маркер звонка приходит в READY', async () => {
const calls = dmCallRoutes();
await bootDm({ snapshot: ringingCallSnapshot(), routes: calls.routes });
// Список бесед из снапшота уже знает про звонок — запросов за ним не нужно.
expect(await screen.findByTestId('dm-call-c-dm')).toBeInTheDocument();
expect(screen.getByTestId('dm-call-c-dm')).toHaveAttribute('data-status', 'ringing');
expect(useSessionStore.getState().dmChannels[0]?.active_call?.id).toBe('call-7');
});
});
+64
View File
@@ -61,3 +61,67 @@ export function installFakeAudio(): FakeAudio[] {
vi.stubGlobal('Audio', StubAudio);
return instances;
}
/**
* Подставной `AudioContext` для рингтона: jsdom не умеет Web Audio, поэтому
* осцилляторы записываются, а не звучат. По ним видно, что сигнал запустили,
* сколько раз и на каких частотах.
*/
export class FakeOscillator {
type = 'sine';
frequency = { value: 0 };
startCalls = 0;
stopCalls = 0;
connect(): void {
return undefined;
}
start(): void {
this.startCalls += 1;
}
stop(): void {
this.stopCalls += 1;
}
}
export class FakeAudioContext {
currentTime = 0;
destination = {};
readonly oscillators: FakeOscillator[] = [];
createOscillator(): FakeOscillator {
const oscillator = new FakeOscillator();
this.oscillators.push(oscillator);
return oscillator;
}
createGain(): { gain: Record<string, () => void>; connect: () => void } {
return {
gain: { setValueAtTime: () => undefined, linearRampToValueAtTime: () => undefined },
connect: () => undefined,
};
}
}
/** Ставит подставной `AudioContext` и возвращает последний созданный контекст. */
export function installFakeAudioContext(): { current: () => FakeAudioContext } {
const created: FakeAudioContext[] = [];
class StubAudioContext extends FakeAudioContext {
constructor() {
super();
created.push(this);
}
}
vi.stubGlobal('AudioContext', StubAudioContext);
return {
current: () => {
const last = created.at(-1);
if (last === undefined) {
throw new Error('AudioContext ещё не создавался');
}
return last;
},
};
}
+6
View File
@@ -13,6 +13,7 @@ import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
import { useSoundboardStore } from '@/stores/soundboard';
import { useUiStore } from '@/stores/ui';
import { useDmCallStore } from '@/stores/dmCall';
import { useVoiceStore } from '@/stores/voice';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
import type { User } from '@/api/types';
@@ -153,6 +154,8 @@ export function resetStores(): void {
useVoiceStore.getState().reset();
// Голосовая сессия могла остаться от прошлого теста: гасим её без запросов.
useVoiceConnectionStore.getState().reset();
// Звонок в беседе — тоже сессия: комната LiveKit не должна переживать тест.
useDmCallStore.getState().reset();
// Саундборд: подпись и очередь звуков не должны переживать тест.
useSoundboardStore.getState().reset();
useFriendsStore.getState().reset();
@@ -275,6 +278,8 @@ export interface SnapshotDmInput {
last_message_id?: string;
last_message_at?: string;
can_send?: boolean;
/** Идущий в беседе звонок (AGENT.md 7.8, Фаза 7): маркер «идёт звонок». */
active_call?: unknown;
}
/** Снапшот READY в формате шлюза. */
@@ -359,6 +364,7 @@ export function makeReadySnapshot(
...(channel.last_message_at === undefined
? {}
: { last_message_at: channel.last_message_at }),
...(channel.active_call === undefined ? {} : { active_call: channel.active_call }),
can_send: channel.can_send ?? true,
})),
read_states: [],