feat(web): интерфейс звонков в беседах (Фаза 7)
В шапке беседы — меню звонка: аудио, видео, «Присоединиться» к идущему и история. Когда голос на инстансе выключен, меню нет вовсе: обещать звонок, который не поднимется, нельзя. Входящий звонок показывается карточкой поверх любого экрана с принять и отклонить и рингтоном; идущий звонок — маркером в списке бесед и в шапке, а в области переписки вместо ленты открывается панель с плитками участников и кнопками микрофона, звука, камеры, шаринга и выхода. История звонков — кто звонил, когда, сколько длился разговор и кто не ответил. Тесты (vitest): начало звонка из шапки с подключением к комнате LiveKit, входящий звонок и его принятие, отклонение, маркер «идёт звонок» по событию и его снятие после завершения, флаги микрофона, камеры и экрана, история с пропущенными и длительностью, выключенный голос и маркер после перезагрузки (active_call в READY).
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user