feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes

Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
+112
View File
@@ -0,0 +1,112 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { 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">
<header className="flex h-15 flex-none items-center border-b border-border-panel bg-white px-6">
<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>
) : 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>
);
}