128 lines
4.7 KiB
TypeScript
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();
|
||
|
|
}
|