- 通话结果"已拒绝"文案按视角区分(拒绝方看到"已拒绝"而非"对方已拒绝") - 通话记录页补充移动端返回头部与加载失败重试态 - 挂断时按是否已接通传递正确的挂断原因(cancelled) - 系统消息"全部标为已读"翻页加循环护栏,防止游标卡死;按钮可见性改用真实未读总数 - 收藏批量删除/转发在部分失败时保留失败项以便重试,而非无条件退出多选模式 - 收藏卡片边框/分类文字/侧栏图标颜色token修正
131 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|