feat(web): интерфейс звонков в беседах (Фаза 7)
В шапке беседы — меню звонка: аудио, видео, «Присоединиться» к идущему и история. Когда голос на инстансе выключен, меню нет вовсе: обещать звонок, который не поднимется, нельзя. Входящий звонок показывается карточкой поверх любого экрана с принять и отклонить и рингтоном; идущий звонок — маркером в списке бесед и в шапке, а в области переписки вместо ленты открывается панель с плитками участников и кнопками микрофона, звука, камеры, шаринга и выхода. История звонков — кто звонил, когда, сколько длился разговор и кто не ответил. Тесты (vitest): начало звонка из шапки с подключением к комнате LiveKit, входящий звонок и его принятие, отклонение, маркер «идёт звонок» по событию и его снятие после завершения, флаги микрофона, камеры и экрана, история с пропущенными и длительностью, выключенный голос и маркер после перезагрузки (active_call в READY).
This commit is contained in:
@@ -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 }) {
|
||||
) : (
|
||||
<span className="sr-only">{t(`user.status.${status}`)}</span>
|
||||
)}
|
||||
{channel.active_call === undefined ? null : (
|
||||
<span
|
||||
role="status"
|
||||
data-testid="dm-call-header-status"
|
||||
data-status={channel.active_call.status}
|
||||
className="shrink-0 text-xs text-success"
|
||||
>
|
||||
{t('friends.call.inProgress')}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<DmCallMenu channel={channel} onOpenHistory={() => setCallHistoryOpen((open) => !open)} />
|
||||
{isOwner ? (
|
||||
<>
|
||||
<button
|
||||
@@ -257,13 +279,18 @@ export function DirectMessageView({ channelId }: { channelId: string }) {
|
||||
authors={authors}
|
||||
onJump={goToMessage}
|
||||
/>
|
||||
<ChatView
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
guildId={null}
|
||||
jumpTo={jump}
|
||||
onJumpHandled={onJumpHandled}
|
||||
/>
|
||||
{callHistoryOpen ? <DmCallHistoryPanel channel={channel} /> : null}
|
||||
{inThisCall ? (
|
||||
<DmCallPanel channel={channel} />
|
||||
) : (
|
||||
<ChatView
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
guildId={null}
|
||||
jumpTo={jump}
|
||||
onJumpHandled={onJumpHandled}
|
||||
/>
|
||||
)}
|
||||
<PinsPanel
|
||||
channelId={channel.id}
|
||||
channelName={name}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
callParticipant,
|
||||
dmCallHistoryQueryKey,
|
||||
fetchDmCallHistory,
|
||||
type DmCall,
|
||||
} from '@/api/calls';
|
||||
import type { Channel } from '@/api/types';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* История звонков беседы (AGENT.md 7.8, Фаза 7): кто звонил, когда, сколько
|
||||
* длился разговор и кто не ответил. Данные приходят ручкой `GET /calls` —
|
||||
* отдельной от переписки, чтобы звонки не мешали непрочитанным сообщениям.
|
||||
*/
|
||||
export function DmCallHistoryPanel({ channel }: { channel: Channel }) {
|
||||
const { t } = useTranslation();
|
||||
const currentUserId = useSessionStore((state) => state.user?.id ?? null);
|
||||
const query = useQuery({
|
||||
queryKey: dmCallHistoryQueryKey(channel.id),
|
||||
queryFn: ({ signal }) => fetchDmCallHistory(channel.id, undefined, signal),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const calls = query.data ?? [];
|
||||
const userIds = calls.flatMap((call) => call.participants.map((item) => item.user_id));
|
||||
const authors = useAuthorDirectory(null, userIds);
|
||||
|
||||
/** Длительность разговора: минуты и секунды, у гудков — прочерк. */
|
||||
const formatDuration = (call: DmCall): string => {
|
||||
if (call.duration_seconds <= 0) {
|
||||
return '—';
|
||||
}
|
||||
const minutes = Math.floor(call.duration_seconds / 60);
|
||||
const seconds = call.duration_seconds % 60;
|
||||
if (minutes === 0) {
|
||||
return t('friends.call.durationSeconds', { count: seconds });
|
||||
}
|
||||
return t('friends.call.duration', { minutes, seconds });
|
||||
};
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<div className="border-b border-border/40 px-4 py-3" data-testid="dm-call-history">
|
||||
<SkeletonLines rows={2} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (calls.length === 0) {
|
||||
return (
|
||||
<div className="border-b border-border/40 px-4 py-3" data-testid="dm-call-history">
|
||||
<p className="text-xs text-fg-muted">{t('friends.call.historyEmpty')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-h-56 overflow-y-auto border-b border-border/40 px-4 py-3"
|
||||
data-testid="dm-call-history"
|
||||
>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{t('friends.call.history')}
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{calls.map((call) => {
|
||||
const mine = currentUserId === null ? undefined : callParticipant(call, currentUserId);
|
||||
const initiator = authors.resolve(call.initiator_id);
|
||||
const missed = call.end_reason === 'missed' || mine?.state === 'missed';
|
||||
return (
|
||||
<li
|
||||
key={call.id}
|
||||
data-testid={`dm-call-history-${call.id}`}
|
||||
data-missed={missed}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<Avatar name={initiator.name} seed={call.initiator_id} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<span className="font-medium">{initiator.name}</span>{' '}
|
||||
<span className="text-fg-muted">{t(callLabelKey(call, mine?.state))}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted" data-testid={`dm-call-duration-${call.id}`}>
|
||||
{formatDuration(call)}
|
||||
</span>
|
||||
<time className="shrink-0 text-fg-muted" dateTime={call.created_at}>
|
||||
{formatTime(call.created_at)}
|
||||
</time>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ключ подписи строки истории: зависит от того, чем звонок закончился. */
|
||||
function callLabelKey(call: DmCall, myState: string | undefined): string {
|
||||
if (myState === 'missed') {
|
||||
return 'friends.call.entryMissed';
|
||||
}
|
||||
if (call.end_reason === 'missed') {
|
||||
return 'friends.call.entryNoAnswer';
|
||||
}
|
||||
if (call.end_reason === 'declined') {
|
||||
return 'friends.call.entryDeclined';
|
||||
}
|
||||
if (call.end_reason === 'canceled') {
|
||||
return 'friends.call.entryCanceled';
|
||||
}
|
||||
if (call.media === 'video') {
|
||||
return 'friends.call.entryVideo';
|
||||
}
|
||||
return 'friends.call.entryAudio';
|
||||
}
|
||||
|
||||
/** Время звонка в локальном формате: дата и часы. */
|
||||
function formatTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return date.toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { callParticipant, isCallLive, type DmCallMedia } from '@/api/calls';
|
||||
import type { Channel } from '@/api/types';
|
||||
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
|
||||
import { Menu, MenuItem } from '@/components/ui/Menu';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useDmCallStore } from '@/stores/dmCall';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Кнопка звонка в шапке беседы (AGENT.md 7.8, Фаза 7): начать аудио- или
|
||||
* видеозвонок, присоединиться к идущему и открыть историю. Если голос на
|
||||
* инстансе выключен, кнопки не показываем вовсе — обещать звонок нельзя.
|
||||
*/
|
||||
export function DmCallMenu({
|
||||
channel,
|
||||
onOpenHistory,
|
||||
}: {
|
||||
channel: Channel;
|
||||
onOpenHistory: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const describeError = useApiErrorMessage();
|
||||
const voiceEnabled = useVoiceEnabled();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const myChannelId = useDmCallStore((state) => state.channelId);
|
||||
const status = useDmCallStore((state) => state.status);
|
||||
const start = useDmCallStore((state) => state.start);
|
||||
const accept = useDmCallStore((state) => state.accept);
|
||||
const currentUserId = useSessionStore((state) => state.user?.id ?? null);
|
||||
|
||||
const call = channel.active_call;
|
||||
const live = isCallLive(call);
|
||||
const inThisCall = myChannelId === channel.id && status !== 'error';
|
||||
const connecting = inThisCall && status === 'connecting';
|
||||
const mine =
|
||||
call !== undefined && currentUserId !== null ? callParticipant(call, currentUserId) : undefined;
|
||||
// Присоединиться можно и после перезагрузки: сервер уже пометил нас
|
||||
// вышедшим, а ручка принятия для участника звонка идемпотентна. Отказ и
|
||||
// пропуск — не приглашение: такой участник в комнату не вернётся.
|
||||
const canJoin =
|
||||
live && mine !== undefined && mine.state !== 'declined' && mine.state !== 'missed';
|
||||
|
||||
const run = useCallback((action: () => Promise<void>): void => {
|
||||
setError(null);
|
||||
setOpen(false);
|
||||
void action().catch((cause: unknown) => setError(cause));
|
||||
}, []);
|
||||
|
||||
if (!voiceEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t('friends.call.menu');
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-testid="dm-call-menu"
|
||||
data-live={live}
|
||||
className={`rounded-[var(--radius-sm)] px-1.5 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 ${
|
||||
live ? 'text-success' : 'text-fg-muted hover:text-fg'
|
||||
}`}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span aria-hidden="true">📞</span>
|
||||
</button>
|
||||
<Menu
|
||||
open={open}
|
||||
label={label}
|
||||
anchorRef={anchorRef}
|
||||
placement="bottom"
|
||||
align="right"
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
{canJoin ? (
|
||||
<MenuItem
|
||||
icon="📞"
|
||||
data-testid="dm-call-join"
|
||||
disabled={connecting}
|
||||
onClick={() => run(() => accept(channel.id))}
|
||||
>
|
||||
{t('friends.call.join')}
|
||||
</MenuItem>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
icon="📞"
|
||||
data-testid="dm-call-audio"
|
||||
disabled={live || connecting}
|
||||
onClick={() => run(() => start(channel.id, 'audio' satisfies DmCallMedia))}
|
||||
>
|
||||
{t('friends.call.audio')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="🎥"
|
||||
data-testid="dm-call-video"
|
||||
disabled={live || connecting}
|
||||
onClick={() => run(() => start(channel.id, 'video' satisfies DmCallMedia))}
|
||||
>
|
||||
{t('friends.call.video')}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuItem
|
||||
icon="🕘"
|
||||
data-testid="dm-call-history"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onOpenHistory();
|
||||
}}
|
||||
>
|
||||
{t('friends.call.history')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
{error === null ? null : (
|
||||
<p role="alert" className="sr-only" data-testid="dm-call-menu-error">
|
||||
{describeError(error)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"
|
||||
data-testid="dm-call-panel"
|
||||
data-status={status}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p role="status" className="text-xs text-fg-muted">
|
||||
{t(refreshing ? 'voice.refreshing' : 'friends.call.connected')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status === 'error' ? (
|
||||
<p role="alert" className="text-sm text-danger" data-testid="dm-call-error">
|
||||
{describeError(error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{participants.length === 0 ? (
|
||||
<p className="text-sm text-fg-muted" data-testid="dm-call-waiting">
|
||||
{connecting ? t('voice.joining') : t('friends.call.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
|
||||
{participants.map((participant) => (
|
||||
<DmCallTile
|
||||
key={participant.userId}
|
||||
participant={participant}
|
||||
name={authors.resolve(participant.userId).name}
|
||||
avatarFileId={authors.resolve(participant.userId).avatarFileId}
|
||||
latencyMs={latencies[participant.userId]}
|
||||
volume={((volumes[participant.userId] ?? 100) * outputVolume) / 100}
|
||||
deafened={deafened}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{participants.length === 0 && pending.length > 0 ? (
|
||||
<p className="text-xs text-fg-muted" data-testid="dm-call-pending">
|
||||
{t('friends.call.pending', { names: pending.join(', ') })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DmCallControl
|
||||
testId="dm-call-mic"
|
||||
active={micEnabled}
|
||||
glyph={micEnabled ? '🎤' : '🔇'}
|
||||
label={t(micEnabled ? 'voice.micOn' : 'voice.micOff')}
|
||||
onClick={onMic}
|
||||
/>
|
||||
<DmCallControl
|
||||
testId="dm-call-sound"
|
||||
active={!deafened}
|
||||
glyph={deafened ? '🔈' : '🎧'}
|
||||
label={t(deafened ? 'voice.undeafen' : 'voice.deafen')}
|
||||
onClick={onSound}
|
||||
/>
|
||||
<DmCallControl
|
||||
testId="dm-call-camera"
|
||||
active={cameraEnabled}
|
||||
glyph="🎥"
|
||||
label={t(cameraEnabled ? 'voice.cameraOff' : 'voice.cameraOn')}
|
||||
onClick={onCamera}
|
||||
/>
|
||||
<DmCallControl
|
||||
testId="dm-call-screen"
|
||||
active={screenEnabled}
|
||||
glyph="🖥"
|
||||
label={t(screenEnabled ? 'voice.screenStop' : 'voice.screenStart')}
|
||||
onClick={onScreen}
|
||||
/>
|
||||
<Button variant="ghost" data-testid="dm-call-leave" onClick={() => void leave()}>
|
||||
{t('friends.call.leave')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{connecting ? <p className="text-xs text-fg-muted">{t('voice.joining')}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Плитка участника звонка: видео или аватар, имя, флаги и качество связи. */
|
||||
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 (
|
||||
<div
|
||||
data-testid={`dm-call-tile-${participant.userId}`}
|
||||
data-speaking={participant.isSpeaking}
|
||||
className={`flex flex-col gap-2 rounded-[var(--radius-lg)] border bg-surface-1 p-3 ${
|
||||
participant.isSpeaking ? 'border-success/70' : 'border-border/60'
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex min-h-28 items-center justify-center overflow-hidden rounded-[var(--radius-md)] bg-surface-2">
|
||||
{video === undefined ? (
|
||||
<span className={`rounded-full ${speakingRingClass(participant.isSpeaking)}`}>
|
||||
<Avatar name={name} seed={participant.userId} fileId={avatarFileId} size="lg" />
|
||||
</span>
|
||||
) : (
|
||||
<RemoteVideo track={video} muted={participant.isLocal} />
|
||||
)}
|
||||
{participant.audio === undefined ? null : (
|
||||
<RemoteAudio
|
||||
track={participant.audio}
|
||||
volume={Math.round(volume)}
|
||||
muted={deafened && !participant.isLocal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<VoiceQualityDot quality={participant.quality} latencyMs={latencyMs} />
|
||||
{participant.isLocal ? (
|
||||
<span className="text-xs text-fg-muted">{t('voice.you')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<VoiceFlags
|
||||
selfMute={!participant.micEnabled}
|
||||
selfDeaf={deafened && participant.isLocal}
|
||||
serverMute={false}
|
||||
serverDeaf={false}
|
||||
camera={participant.cameraEnabled}
|
||||
screen={participant.screenEnabled}
|
||||
/>
|
||||
{participant.isSpeaking ? <p className="sr-only">{t('voice.speaking')}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Кнопка управления своей сессией. */
|
||||
function DmCallControl({
|
||||
testId,
|
||||
glyph,
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
testId: string;
|
||||
glyph: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
aria-pressed={active}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={`rounded-[var(--radius-md)] border px-2.5 py-1.5 text-base leading-none transition-colors duration-[var(--duration-fast)] ${
|
||||
active
|
||||
? 'border-border/60 bg-surface-2 text-fg'
|
||||
: 'border-danger/60 bg-surface-2 text-danger'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">{glyph}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Имена приглашённых, которые ещё не подключились: подсказка под плитками. */
|
||||
function pendingNames(
|
||||
channel: Channel,
|
||||
call: DmCall | undefined,
|
||||
currentUserId: string | null,
|
||||
): string[] {
|
||||
if (call === undefined) {
|
||||
return [];
|
||||
}
|
||||
const names = new Map<string, string>();
|
||||
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);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-30 flex items-start justify-center p-4"
|
||||
data-testid="dm-incoming-call"
|
||||
>
|
||||
<Card className="pointer-events-auto w-full max-w-sm" data-call-id={call.id}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar
|
||||
name={name}
|
||||
seed={channel.recipient_id ?? channel.id}
|
||||
fileId={channel.icon_file_id}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{name}</p>
|
||||
<p className="text-xs text-fg-muted" data-testid="dm-incoming-call-kind">
|
||||
{t(call.media === 'video' ? 'friends.call.incomingVideo' : 'friends.call.incoming')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error === null ? null : (
|
||||
<p role="alert" className="mt-2 text-xs text-danger" data-testid="dm-incoming-call-error">
|
||||
{describeError(error)}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button data-testid="dm-incoming-call-accept" disabled={connecting} onClick={onAccept}>
|
||||
{t('friends.call.accept')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="dm-incoming-call-decline"
|
||||
onClick={() => decline(channel.id)}
|
||||
>
|
||||
{t('friends.call.decline')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(), []);
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
<CreateGuildModal />
|
||||
{/* Входящий звонок в беседе: карточка поверх экрана и рингтон (7.8). */}
|
||||
<DmIncomingCall />
|
||||
{/* Подсказка «вы говорите с выключенным микрофоном» — поверх любого экрана. */}
|
||||
<VoiceSpeakingNotice />
|
||||
{/* Подпись «{имя} включил звук {name}» из события SOUNDBOARD_PLAY (7.13). */}
|
||||
|
||||
@@ -68,6 +68,18 @@ function DirectChannelLink({ channel, selected }: { channel: Channel; selected:
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{name}</span>
|
||||
{channel.active_call === undefined ? null : (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={t('friends.call.inProgress')}
|
||||
title={t('friends.call.inProgress')}
|
||||
data-testid={`dm-call-${channel.id}`}
|
||||
data-status={channel.active_call.status}
|
||||
className="shrink-0 text-xs text-success"
|
||||
>
|
||||
<span aria-hidden="true">📞</span>
|
||||
</span>
|
||||
)}
|
||||
{channel.is_group === true ? (
|
||||
<span
|
||||
className="shrink-0 text-[10px] text-fg-muted"
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user