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
@@ -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();
}
+134
View File
@@ -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>
)}
</>
);
}
+270
View File
@@ -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(), []);
}
+8
View File
@@ -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). */}
+12
View File
@@ -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"