diff --git a/web/src/components/friends/DirectMessageView.tsx b/web/src/components/friends/DirectMessageView.tsx index e7be389..1bbbc89 100644 --- a/web/src/components/friends/DirectMessageView.tsx +++ b/web/src/components/friends/DirectMessageView.tsx @@ -9,6 +9,9 @@ import { ChatView } from '@/components/chat/ChatView'; import { PinsPanel } from '@/components/chat/PinsPanel'; import { SearchPanel } from '@/components/chat/SearchPanel'; import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory'; +import { DmCallHistoryPanel } from '@/components/friends/DmCallHistoryPanel'; +import { DmCallMenu } from '@/components/friends/DmCallMenu'; +import { DmCallPanel } from '@/components/friends/DmCallPanel'; import { Avatar } from '@/components/ui/Avatar'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Menu, MenuItem } from '@/components/ui/Menu'; @@ -16,6 +19,7 @@ import { Card } from '@/components/ui/primitives'; import { SkeletonLines } from '@/components/ui/Skeleton'; import { useCurrentUser } from '@/lib/hooks'; import { presenceDotClass } from '@/lib/presence'; +import { useDmCallStore } from '@/stores/dmCall'; import { useMessagesStore } from '@/stores/messages'; import { useSessionStore } from '@/stores/session'; @@ -76,8 +80,15 @@ export function DirectMessageView({ channelId }: { channelId: string }) { const [searchOpen, setSearchOpen] = useState(false); const [pinsOpen, setPinsOpen] = useState(false); + const [callHistoryOpen, setCallHistoryOpen] = useState(false); const [jump, setJump] = useState<{ id: string; token: number } | null>(null); + // В звонке этой беседы лента уступает место панели звонка: плитки участников + // и управление своей сессией (AGENT.md 7.8). Выход возвращает переписку. + const callChannelId = useDmCallStore((state) => state.channelId); + const callStatus = useDmCallStore((state) => state.status); + const inThisCall = callChannelId === channelId && callStatus !== 'idle'; + const messages = useMessagesStore((state) => state.channels[channelId]?.messages); const authorIds = useMemo( () => @@ -152,7 +163,18 @@ export function DirectMessageView({ channelId }: { channelId: string }) { ) : ( {t(`user.status.${status}`)} )} + {channel.active_call === undefined ? null : ( + + {t('friends.call.inProgress')} + + )}
+ setCallHistoryOpen((open) => !open)} /> {isOwner ? ( <> + setOpen(false)} + > + {canJoin ? ( + run(() => accept(channel.id))} + > + {t('friends.call.join')} + + ) : ( + <> + run(() => start(channel.id, 'audio' satisfies DmCallMedia))} + > + {t('friends.call.audio')} + + run(() => start(channel.id, 'video' satisfies DmCallMedia))} + > + {t('friends.call.video')} + + + )} + { + setOpen(false); + onOpenHistory(); + }} + > + {t('friends.call.history')} + + + {error === null ? null : ( +

+ {describeError(error)} +

+ )} + + ); +} diff --git a/web/src/components/friends/DmCallPanel.tsx b/web/src/components/friends/DmCallPanel.tsx new file mode 100644 index 0000000..59f8a3a --- /dev/null +++ b/web/src/components/friends/DmCallPanel.tsx @@ -0,0 +1,270 @@ +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { DmCall } from '@/api/calls'; +import type { Channel } from '@/api/types'; +import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory'; +import { Avatar } from '@/components/ui/Avatar'; +import { Button } from '@/components/ui/primitives'; +import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio'; +import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators'; +import { speakingRingClass } from '@/components/voice/voiceStyles'; +import { useApiErrorMessage } from '@/lib/useApiErrorMessage'; +import type { VoiceParticipant } from '@/lib/livekit'; +import { useDmCallStore } from '@/stores/dmCall'; +import { useSessionStore } from '@/stores/session'; +import { useVoiceStore } from '@/stores/voice'; + +/** + * Панель звонка в беседе (AGENT.md 7.8, 7.14): плитки участников и те же + * кнопки, что в серверной голосовой комнате, — микрофон, звук, камера, + * демонстрация экрана и выход. Серверных ролей в беседе нет, поэтому + * модерации здесь тоже нет: только своя сессия. + */ +export function DmCallPanel({ channel }: { channel: Channel }) { + const { t } = useTranslation(); + const describeError = useApiErrorMessage(); + const status = useDmCallStore((state) => state.status); + const error = useDmCallStore((state) => state.error); + const participants = useDmCallStore((state) => state.participants); + const latencies = useDmCallStore((state) => state.latencies); + const micEnabled = useDmCallStore((state) => state.micEnabled); + const deafened = useDmCallStore((state) => state.deafened); + const cameraEnabled = useDmCallStore((state) => state.cameraEnabled); + const screenEnabled = useDmCallStore((state) => state.screenEnabled); + const refreshing = useDmCallStore((state) => state.refreshing); + const setMicEnabled = useDmCallStore((state) => state.setMicEnabled); + const setDeafened = useDmCallStore((state) => state.setDeafened); + const setCameraEnabled = useDmCallStore((state) => state.setCameraEnabled); + const setScreenShareEnabled = useDmCallStore((state) => state.setScreenShareEnabled); + const leave = useDmCallStore((state) => state.leave); + + const outputVolume = useVoiceStore((state) => state.outputVolume); + const volumes = useVoiceStore((state) => state.participantVolumes); + + const userIds = useMemo(() => participants.map((item) => item.userId), [participants]); + const authors = useAuthorDirectory(null, userIds); + const currentUserId = useSessionStore((state) => state.user?.id ?? null); + + // Пока комната только подключается, состав пуст: показываем тех, кого позвали. + const live = channel.active_call; + const pending = useMemo( + () => pendingNames(channel, live, currentUserId), + [channel, live, currentUserId], + ); + + const onMic = useCallback(() => void setMicEnabled(!micEnabled), [micEnabled, setMicEnabled]); + const onSound = useCallback(() => setDeafened(!deafened), [deafened, setDeafened]); + const onCamera = useCallback( + () => void setCameraEnabled(!cameraEnabled), + [cameraEnabled, setCameraEnabled], + ); + const onScreen = useCallback( + () => void setScreenShareEnabled(!screenEnabled), + [screenEnabled, setScreenShareEnabled], + ); + + const connecting = status === 'connecting'; + + return ( +
+
+

+ {t(refreshing ? 'voice.refreshing' : 'friends.call.connected')} +

+
+ + {status === 'error' ? ( +

+ {describeError(error)} +

+ ) : null} + + {participants.length === 0 ? ( +

+ {connecting ? t('voice.joining') : t('friends.call.empty')} +

+ ) : ( +
+ {participants.map((participant) => ( + + ))} +
+ )} + + {participants.length === 0 && pending.length > 0 ? ( +

+ {t('friends.call.pending', { names: pending.join(', ') })} +

+ ) : null} + +
+ + + + + +
+ + {connecting ?

{t('voice.joining')}

: null} +
+ ); +} + +/** Плитка участника звонка: видео или аватар, имя, флаги и качество связи. */ +function DmCallTile({ + participant, + name, + avatarFileId, + latencyMs, + volume, + deafened, +}: { + participant: VoiceParticipant; + name: string; + avatarFileId: string | undefined; + latencyMs: number | undefined; + volume: number; + deafened: boolean; +}) { + const { t } = useTranslation(); + const video = participant.screen ?? participant.video; + return ( +
+
+ {video === undefined ? ( + + + + ) : ( + + )} + {participant.audio === undefined ? null : ( + + )} +
+
+ + {name} + + + {participant.isLocal ? ( + {t('voice.you')} + ) : null} +
+ + {participant.isSpeaking ?

{t('voice.speaking')}

: null} +
+ ); +} + +/** Кнопка управления своей сессией. */ +function DmCallControl({ + testId, + glyph, + label, + active, + onClick, +}: { + testId: string; + glyph: string; + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +/** Имена приглашённых, которые ещё не подключились: подсказка под плитками. */ +function pendingNames( + channel: Channel, + call: DmCall | undefined, + currentUserId: string | null, +): string[] { + if (call === undefined) { + return []; + } + const names = new Map(); + for (const recipient of channel.recipients ?? []) { + names.set(recipient.user_id, recipient.display_name || recipient.username); + } + if (channel.recipient_id !== undefined) { + names.set(channel.recipient_id, channel.name); + } + return call.participants + .filter( + (participant) => participant.state === 'invited' && participant.user_id !== currentUserId, + ) + .map((participant) => names.get(participant.user_id) ?? participant.user_id); +} diff --git a/web/src/components/friends/DmIncomingCall.tsx b/web/src/components/friends/DmIncomingCall.tsx new file mode 100644 index 0000000..03ee961 --- /dev/null +++ b/web/src/components/friends/DmIncomingCall.tsx @@ -0,0 +1,85 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; + +import { declineDmCall } from '@/api/calls'; +import { useIncomingDmCall } from '@/components/friends/useDmCallRingtone'; +import { Avatar } from '@/components/ui/Avatar'; +import { Button, Card } from '@/components/ui/primitives'; +import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled'; +import { useApiErrorMessage } from '@/lib/useApiErrorMessage'; +import { useDmCallStore } from '@/stores/dmCall'; + +/** + * Карточка входящего звонка поверх любого экрана (AGENT.md 7.8): принять или + * отклонить. Пока она висит, играет рингтон (см. `useDmCallRingtone`), а + * принятие уводит в беседу и подключает к комнате LiveKit. + */ +export function DmIncomingCall() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const describeError = useApiErrorMessage(); + const voiceEnabled = useVoiceEnabled(); + const incoming = useIncomingDmCall(); + const accept = useDmCallStore((state) => state.accept); + const status = useDmCallStore((state) => state.status); + const error = useDmCallStore((state) => state.error); + const myChannelId = useDmCallStore((state) => state.channelId); + + // Отклонение — ручка звонка: состояние придёт событием DM_CALL_UPDATE. + const decline = useCallback((channelId: string): void => { + void declineDmCall(channelId).catch(() => undefined); + }, []); + + if (incoming === null || !voiceEnabled) { + return null; + } + const { channel, call } = incoming; + const name = channel.name === '' ? t('friends.dm.unknown') : channel.name; + const connecting = status === 'connecting' && myChannelId === channel.id; + const onAccept = (): void => { + void accept(channel.id); + void navigate(`/app/friends/${channel.id}`); + }; + + return ( +
+ +
+ +
+

{name}

+

+ {t(call.media === 'video' ? 'friends.call.incomingVideo' : 'friends.call.incoming')} +

+
+
+ {error === null ? null : ( +

+ {describeError(error)} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/web/src/components/friends/useDmCallRingtone.ts b/web/src/components/friends/useDmCallRingtone.ts new file mode 100644 index 0000000..86122fa --- /dev/null +++ b/web/src/components/friends/useDmCallRingtone.ts @@ -0,0 +1,80 @@ +import { useEffect, useMemo } from 'react'; + +import { callParticipant, type DmCall } from '@/api/calls'; +import type { Channel } from '@/api/types'; +import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled'; +import { startRingtone, stopRingtone } from '@/lib/ringtone'; +import { useDmCallStore } from '@/stores/dmCall'; +import { useSessionStore } from '@/stores/session'; + +/** Входящий звонок, который ждёт нашего ответа. */ +export interface IncomingDmCall { + channel: Channel; + call: DmCall; +} + +/** + * Звонок, который звонит именно нам: гудки в беседе, где мы приглашены и ещё + * не ответили. Свои исходящие сюда не попадают — там мы инициатор. + */ +export function useIncomingDmCall(): IncomingDmCall | null { + const channels = useSessionStore((state) => state.dmChannels); + const currentUserId = useSessionStore((state) => state.user?.id ?? null); + if (currentUserId === null) { + return null; + } + for (const channel of channels) { + const call = channel.active_call; + if (call === undefined || call.status !== 'ringing' || call.initiator_id === currentUserId) { + continue; + } + if (callParticipant(call, currentUserId)?.state !== 'invited') { + continue; + } + return { channel, call }; + } + return null; +} + +/** + * Рингтон (AGENT.md 7.8): входящий — пока звонят нам, исходящий — пока мы ждём + * ответа в своём звонке. Сигнал синтезируется, файлов в проекте нет; свёрнутая + * вкладка замолкает (см. `lib/ringtone`). + */ +export function useDmCallRingtone(): void { + const voiceEnabled = useVoiceEnabled(); + const incoming = useIncomingDmCall(); + const myChannelId = useDmCallStore((state) => state.channelId); + const myCallId = useDmCallStore((state) => state.callId); + const status = useDmCallStore((state) => state.status); + const channels = useSessionStore((state) => state.dmChannels); + + const outgoing = useMemo(() => { + if (myChannelId === null || myCallId === null || status !== 'connected') { + return false; + } + const channel = channels.find((item) => item.id === myChannelId); + // Свои гудки слышны, пока никто не ответил: звонок ещё ringing. + return channel?.active_call?.status === 'ringing'; + }, [channels, myCallId, myChannelId, status]); + + useEffect(() => { + // Выключенный на инстансе голос не должен звонить: звонок всё равно не примут. + if (!voiceEnabled) { + stopRingtone(); + return; + } + if (incoming !== null) { + startRingtone('incoming'); + return; + } + if (outgoing) { + startRingtone('outgoing'); + return; + } + stopRingtone(); + }, [incoming, outgoing, voiceEnabled]); + + // Уход с экрана (выход из аккаунта, размонтирование) не должен оставлять звук. + useEffect(() => () => stopRingtone(), []); +} diff --git a/web/src/pages/app/AppLayout.tsx b/web/src/pages/app/AppLayout.tsx index 63ebfc7..887b96f 100644 --- a/web/src/pages/app/AppLayout.tsx +++ b/web/src/pages/app/AppLayout.tsx @@ -6,6 +6,8 @@ import { Outlet, useLocation, useNavigate } from 'react-router'; import { fetchGuild, guildQueryKey } from '@/api/guilds'; import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users'; import { GatewayBridge } from '@/components/GatewayBridge'; +import { DmIncomingCall } from '@/components/friends/DmIncomingCall'; +import { useDmCallRingtone } from '@/components/friends/useDmCallRingtone'; import { SoundboardNotice } from '@/components/voice/SoundboardNotice'; import { VoiceSpeakingNotice } from '@/components/voice/VoiceSpeakingNotice'; import { usePushToTalk } from '@/components/voice/usePushToTalk'; @@ -46,6 +48,10 @@ export default function AppLayout() { // чтобы работать на любом экране, пока открыта голосовая сессия. usePushToTalk(); + // Рингтон звонка в беседе: входящий — пока ждём ответа мы, исходящий — пока + // ждём ответа от собеседника (AGENT.md 7.8). + useDmCallRingtone(); + // Встроенные звуки (AGENT.md 7.13) разблокируются первым жестом: до него // браузер не даёт играть сигналам, пришедшим из шлюза. useEffect(() => { @@ -235,6 +241,8 @@ export default function AppLayout() {
+ {/* Входящий звонок в беседе: карточка поверх экрана и рингтон (7.8). */} + {/* Подсказка «вы говорите с выключенным микрофоном» — поверх любого экрана. */} {/* Подпись «{имя} включил звук {name}» из события SOUNDBOARD_PLAY (7.13). */} diff --git a/web/src/pages/app/FriendsSidebar.tsx b/web/src/pages/app/FriendsSidebar.tsx index 07ec29d..a424595 100644 --- a/web/src/pages/app/FriendsSidebar.tsx +++ b/web/src/pages/app/FriendsSidebar.tsx @@ -68,6 +68,18 @@ function DirectChannelLink({ channel, selected }: { channel: Channel; selected: )} {name} + {channel.active_call === undefined ? null : ( + + + + )} {channel.is_group === true ? ( & Pick): 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] { + 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; + 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; + }, + }; +} diff --git a/web/tests/dmCalls.test.tsx b/web/tests/dmCalls.test.tsx new file mode 100644 index 0000000..4c5f83f --- /dev/null +++ b/web/tests/dmCalls.test.tsx @@ -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[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'); + }); +}); diff --git a/web/tests/fakeAudio.ts b/web/tests/fakeAudio.ts index a65a934..926477d 100644 --- a/web/tests/fakeAudio.ts +++ b/web/tests/fakeAudio.ts @@ -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 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; + }, + }; +} diff --git a/web/tests/helpers.tsx b/web/tests/helpers.tsx index aec4fb9..d599f9d 100644 --- a/web/tests/helpers.tsx +++ b/web/tests/helpers.tsx @@ -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: [],