Files
glchat/web/src/components/friends/DmCallHistoryPanel.tsx
T
grendervill 2b9bf55f16 feat(web): интерфейс звонков в беседах (Фаза 7)
В шапке беседы — меню звонка: аудио, видео, «Присоединиться» к идущему и
история. Когда голос на инстансе выключен, меню нет вовсе: обещать звонок,
который не поднимется, нельзя.

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

Тесты (vitest): начало звонка из шапки с подключением к комнате LiveKit,
входящий звонок и его принятие, отклонение, маркер «идёт звонок» по событию
и его снятие после завершения, флаги микрофона, камеры и экрана, история с
пропущенными и длительностью, выключенный голос и маркер после
перезагрузки (active_call в READY).
2026-09-26 17:12:56 +03:00

128 lines
4.7 KiB
TypeScript

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();
}