Files
im_webapp/web/src/features/calls/CallHistoryView.tsx
T
Your Name 29a1f1c2fd 修复收藏/系统消息/通话记录批次的代码审查问题
- 通话结果"已拒绝"文案按视角区分(拒绝方看到"已拒绝"而非"对方已拒绝")
- 通话记录页补充移动端返回头部与加载失败重试态
- 挂断时按是否已接通传递正确的挂断原因(cancelled)
- 系统消息"全部标为已读"翻页加循环护栏,防止游标卡死;按钮可见性改用真实未读总数
- 收藏批量删除/转发在部分失败时保留失败项以便重试,而非无条件退出多选模式
- 收藏卡片边框/分类文字/侧栏图标颜色token修正
2026-09-16 12:54:40 +08:00

131 lines
6.8 KiB
TypeScript

import { useInfiniteQuery } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { getCallHistory, type CallHistoryItem } from '@/lib/api/calls';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useChatList } from '@/lib/tinode/hooks';
import { useCallStore } from '@/stores/calls';
function formatDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
/** Direction-dependent, same reasoning as dispatch.ts's callResultDisplay: a `missed` result reads
* as "未接听" from the callee's own history and "已取消" from the caller's. */
function statusLabel(item: CallHistoryItem): string {
const result = item.result || item.status;
const incoming = item.direction === 'incoming';
if (result === 'finished') return formatDuration(item.duration_ms);
if (result === 'missed') return incoming ? '未接听' : '已取消';
if (result === 'declined') return '已拒绝';
if (result === 'disconnected') return '异常结束';
return result;
}
/** README §7 doesn't spec a dedicated call-history screen beyond the "最近通话" idea implicit in
* NavRail's own 通话记录 entry -- this is a plain list off the confirmed real `GET /v1/im/calls`
* endpoint, whose response shape (see calls.ts's own comment) turned out completely different
* from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id, unlike
* `topic`, which comes back `p2p...`-hashed and unusable for navigation/re-dialing) is resolved to
* a display name via the chat list, same lookup ChatListColumn itself uses.
*/
export function CallHistoryView() {
const navigate = useNavigate();
const contacts = useChatList();
const query = useInfiniteQuery({
queryKey: ['call-history'],
queryFn: ({ pageParam }) => getCallHistory(pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
{/* `/calls`, like `/favorites`, is only reachable on a narrow viewport via 设置(我的)>通话记录
(SettingsLayout.tsx's MOBILE_SHORTCUTS) -- same "back returns there" convention
FavoritesLayout's own mobile header already uses, and the same gap this file didn't have
until now (there was no way back from here at all short of a system back gesture). */}
<header className="grid h-15 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-panel bg-white px-1 md:hidden">
<Link to="/settings" className="grid h-9 w-9 place-items-center text-text-heading">
<Icon name="arrow_back_ios_new" className="text-[18px]" />
</Link>
<strong className="truncate text-center text-base font-semibold text-text-heading">通话记录</strong>
<span />
</header>
<header className="hidden h-15 flex-none items-center border-b border-border-panel bg-white px-6 md:flex">
<strong className="text-base font-semibold text-text-heading">通话记录</strong>
</header>
<div className="flex flex-col gap-2 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : query.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无通话记录</div>
) : (
items.map((item) => {
const result = item.result || item.status;
const incoming = item.direction === 'incoming';
// Mock only reds out a genuine "未接听" (a call that rang and was never answered) --
// "已取消"(I cancelled before pickup)/"对方忙线中"/"异常结束" are neutral outcomes, not
// something the viewer missed and should feel warned about. This used to redden EVERY
// non-'finished' result alike.
const isMissed = result === 'missed' && incoming;
const name = contacts.find((c) => c.topic === item.peer_user_id)?.public?.fn || item.peer_user_id;
const ts = item.started_at || item.created_at;
return (
<div key={`${item.call_id}`} className="flex items-center gap-3 rounded-xl bg-white px-4 py-3">
<button
type="button"
onClick={() => navigate(`/messages/${item.peer_user_id}`)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(item.peer_user_id)}`}>
{avatarLetter(name)}
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<strong className="truncate text-[15px] font-medium text-text-body">{name}</strong>
<span className={`flex items-center gap-1 truncate text-xs ${isMissed ? 'text-danger-text' : 'text-text-tertiary-2'}`}>
<Icon name={incoming ? 'call_received' : 'call_made'} className="text-sm" />
{statusLabel(item)}
{ts ? ` · ${formatEventTimestamp(new Date(ts))}` : ''}
</span>
</span>
</button>
<button
type="button"
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(item.peer_user_id, name, item.call_type);
}}
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent"
>
<Icon name={item.call_type === 'video' ? 'videocam' : 'call'} className="text-lg" />
</button>
</div>
);
})
)}
{query.hasNextPage ? (
<button
type="button"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
className="py-2 text-center text-sm text-accent disabled:opacity-60"
>
{query.isFetchingNextPage ? '加载中…' : '加载更多'}
</button>
) : null}
</div>
</div>
);
}