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
+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;
},
};
}