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:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { playLocalVideo, playRemoteVideo } from '@/lib/calls/agora-client';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatCallDuration } from '@/lib/messages/dispatch';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
|
||||
function useElapsedSeconds(connectedAt: number | null): number {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!connectedAt) {
|
||||
setElapsed(0);
|
||||
return undefined;
|
||||
}
|
||||
const tick = () => setElapsed(Math.floor((Date.now() - connectedAt) / 1000));
|
||||
tick();
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [connectedAt]);
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
function formatElapsed(totalSeconds: number): string {
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function CircleButton({
|
||||
icon,
|
||||
active,
|
||||
danger,
|
||||
label,
|
||||
onClick,
|
||||
size = 'h-13 w-13',
|
||||
iconSize = 'text-2xl',
|
||||
}: {
|
||||
icon: string;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
size?: string;
|
||||
iconSize?: string;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} aria-label={label} className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className={`grid ${size} place-items-center rounded-full ${iconSize} text-white ${
|
||||
danger ? 'bg-[#f34b46]' : active ? 'bg-white !text-[#22262d]' : 'bg-white/[0.16]'
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} />
|
||||
</span>
|
||||
<span className="text-xs text-white">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** README §7's incoming-call card (top-right floating, 360px). `w-[360px]` alone overflows a phone-
|
||||
* width viewport (confirmed live: 360px + the 24px right offset already exceeds a 375px screen with
|
||||
* no room for a left margin at all) -- `max-w-[calc(100vw-32px)]` caps it to the viewport with a
|
||||
* 16px gutter on each side below the point where the fixed width alone would fit. */
|
||||
function IncomingCallCard() {
|
||||
const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore();
|
||||
return (
|
||||
<div className="fixed right-4 top-4 z-[60] w-[360px] max-w-[calc(100vw-32px)] rounded-[14px] bg-white p-5 shadow-[0_16px_48px_rgba(0,0,0,0.2)] sm:right-6 sm:top-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`grid h-13 w-13 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(topic || peerName)}`}>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-[15px] font-semibold text-text-heading">{peerName}</strong>
|
||||
<span className="text-xs text-text-tertiary-2">邀请你{callType === 'video' ? '视频' : '语音'}通话</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-3">
|
||||
<button type="button" onClick={rejectCall} aria-label="拒绝" className="grid h-[42px] w-[42px] place-items-center rounded-full bg-[#f34b46] text-xl text-white">
|
||||
<Icon name="call_end" />
|
||||
</button>
|
||||
<button type="button" onClick={acceptCall} aria-label="接听" className="grid h-[42px] w-[42px] place-items-center rounded-full bg-[#1fb36a] text-xl text-white">
|
||||
<Icon name={callType === 'video' ? 'videocam' : 'call'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mobile-only full-screen ringing view (06-calls.dc.html `#1g`, "移动·语音来电" -- the same layout
|
||||
* serves a video invite too, just swapping the subtitle/accept icon). Desktop keeps the small
|
||||
* top-right floating `IncomingCallCard` above, unchanged; `CallRoot` renders exactly one of the two
|
||||
* per breakpoint. Built as its own component rather than a responsive fork of `IncomingCallCard`
|
||||
* because the two share no DOM structure at all (a corner card vs. a full-bleed ringing screen). */
|
||||
function MobileIncomingCall() {
|
||||
const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore();
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col items-center bg-gradient-to-b from-[#1f2a3d] to-[#111826] text-white">
|
||||
<span
|
||||
className={`mt-32 grid h-[120px] w-[120px] place-items-center rounded-[28px] text-[46px] font-medium text-white ${avatarBgClass(topic || peerName)}`}
|
||||
style={{ boxShadow: '0 0 0 14px rgba(43,182,115,0.18), 0 0 0 32px rgba(43,182,115,0.08)' }}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="mt-8 text-2xl font-semibold">{peerName}</strong>
|
||||
<span className="mt-2 text-[15px] text-white/70">邀请你{callType === 'video' ? '视频' : '语音'}通话…</span>
|
||||
<div className="mb-16 mt-auto flex gap-24">
|
||||
<button type="button" onClick={rejectCall} aria-label="拒绝" className="flex flex-col items-center gap-2.5 text-[13px]">
|
||||
<span className="grid h-18 w-18 place-items-center rounded-full bg-[#f34b46] text-[32px]">
|
||||
<Icon name="call_end" />
|
||||
</span>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" onClick={acceptCall} aria-label="接听" className="flex flex-col items-center gap-2.5 text-[13px]">
|
||||
<span className="grid h-18 w-18 place-items-center rounded-full bg-[#1fb36a] text-[32px]">
|
||||
<Icon name={callType === 'video' ? 'videocam' : 'call'} />
|
||||
</span>
|
||||
接听
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** README §7's minimized floating bar (280px). Call stays live (Agora channel untouched) --
|
||||
* minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never
|
||||
* unmounted between full-screen and minimized). */
|
||||
function MinimizedCallBar() {
|
||||
const { topic, peerName, connectedAt, muted, toggleMinimize, toggleMute, hangUp } = useCallStore();
|
||||
const elapsed = useElapsedSeconds(connectedAt);
|
||||
return (
|
||||
// `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx
|
||||
// and FriendProfileView's mobile action bar -- below 640px this would otherwise sit on top of
|
||||
// AppShell's persistent 82px BottomTabBar (a minimized call can be reached at any viewport width,
|
||||
// there's no `sm:hidden` gate on this component to make the overlap moot).
|
||||
<div className="fixed bottom-[106px] right-6 z-[60] flex w-[280px] items-center gap-3 rounded-xl bg-[#1b2231] px-4 py-3 text-white sm:bottom-6">
|
||||
<button type="button" onClick={toggleMinimize} aria-label="展开通话窗口" className="flex flex-1 items-center gap-3 text-left">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topic || peerName)}`}>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm font-medium">{peerName}</strong>
|
||||
<span className="text-xs text-success">{connectedAt ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'} className="grid h-8 w-8 flex-none place-items-center rounded-full bg-white/10">
|
||||
<Icon name={muted ? 'mic_off' : 'mic'} className="text-base" />
|
||||
</button>
|
||||
<button type="button" onClick={hangUp} aria-label="挂断" className="grid h-8 w-8 flex-none place-items-center rounded-full bg-[#f34b46]">
|
||||
<Icon name="call_end" className="text-base" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** README §7's in-call window. Deliberately an in-app overlay, not a true separate OS window (the
|
||||
* design calls for one) -- the old app's own real CallPanel is also just an overlay, never a
|
||||
* separate window/component instance, and keeping the Agora client/tracks in one JS realm avoids a
|
||||
* cross-window signaling bus this rewrite has no other need for. */
|
||||
function CallWindow() {
|
||||
const {
|
||||
topic,
|
||||
callType,
|
||||
phase,
|
||||
peerName,
|
||||
connectedAt,
|
||||
muted,
|
||||
cameraOff,
|
||||
hasRemoteVideo,
|
||||
hasLocalVideo,
|
||||
minimized,
|
||||
mediaError,
|
||||
toggleMinimize,
|
||||
toggleMute,
|
||||
toggleCamera,
|
||||
hangUp,
|
||||
} = useCallStore();
|
||||
const remoteRef = useRef<HTMLDivElement>(null);
|
||||
const localRef = useRef<HTMLDivElement>(null);
|
||||
const elapsed = useElapsedSeconds(connectedAt);
|
||||
|
||||
useEffect(() => {
|
||||
if (remoteRef.current) playRemoteVideo(remoteRef.current);
|
||||
}, [hasRemoteVideo]);
|
||||
useEffect(() => {
|
||||
if (localRef.current && hasLocalVideo && !cameraOff) playLocalVideo(localRef.current);
|
||||
}, [hasLocalVideo, cameraOff]);
|
||||
|
||||
if (minimized) return <MinimizedCallBar />;
|
||||
|
||||
const statusLabel = phase === 'connected' ? `${formatElapsed(elapsed)} · 网络良好` : '正在等待对方接听…';
|
||||
// 06-calls.dc.html #1b (呼出等待中) labels this same button "取消", matching the protocol doc's own
|
||||
// "发起方取消发送 hang-up:cancelled" wording for the pre-connect case -- it was hardcoded to "挂断"
|
||||
// at all 3 call sites below regardless of phase, which is only actually correct once connected.
|
||||
const hangUpLabel = phase === 'connected' ? '挂断' : '取消';
|
||||
|
||||
if (callType === 'audio') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col items-center justify-between bg-gradient-to-b from-[#1f2a3d] to-[#111826] py-16 text-white">
|
||||
<button type="button" onClick={toggleMinimize} aria-label="最小化" className="absolute left-5 top-5 text-white/70">
|
||||
<Icon name="expand_more" className="text-2xl" />
|
||||
</button>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span
|
||||
// Mock `06-calls.dc.html #1b`: `border-radius:24px` (a squircle), not a perfect circle --
|
||||
// the mobile equivalent of this same avatar (MobileIncomingCall) already gets this right.
|
||||
className={`grid h-[100px] w-[100px] place-items-center rounded-[24px] text-3xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
|
||||
style={{ boxShadow: '0 0 0 12px rgba(43,182,115,0.18), 0 0 0 28px rgba(43,182,115,0.08)' }}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="text-center text-xl font-semibold">{peerName}</strong>
|
||||
<span className="text-sm text-white/60">{statusLabel}</span>
|
||||
</div>
|
||||
<div className="flex gap-8">
|
||||
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} />
|
||||
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} />
|
||||
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} />
|
||||
</div>
|
||||
<Toast message={mediaError || ''} variant="error" onDone={() => useCallStore.setState({ mediaError: null })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] text-white"
|
||||
style={{ background: 'radial-gradient(ellipse at 50% 40%, #3b4a63, #1b2231)' }}
|
||||
>
|
||||
{/* Desktop header (06-calls.dc.html #1a): name + live duration/network status inline. `z-10`
|
||||
(here and on every other overlay piece below) is deliberate, not decorative -- without it,
|
||||
this and the real `remoteRef` video div right below are BOTH `position:absolute` with no
|
||||
z-index, so plain DOM order would decide stacking and the video (which comes second) would
|
||||
paint over this header the moment real remote video is actually playing. That case never
|
||||
got exercised live (this sandbox has no real camera, see the calls verification note in the
|
||||
plan file), so it's a latent bug worth closing here rather than leaving for whoever first
|
||||
tests a real 2-camera call to rediscover. */}
|
||||
<div className="absolute inset-x-0 top-0 z-10 hidden items-center justify-between bg-gradient-to-b from-black/40 to-transparent px-6 py-4 md:flex">
|
||||
<div className="flex flex-col">
|
||||
<strong className="text-base font-semibold">{peerName}</strong>
|
||||
<span className="flex items-center gap-1 text-xs text-white/70">
|
||||
{phase === 'connected' ? (
|
||||
<>
|
||||
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
|
||||
</>
|
||||
) : (
|
||||
'正在等待对方接听…'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-white/80">
|
||||
<button type="button" onClick={toggleMinimize} aria-label="最小化">
|
||||
<Icon name="picture_in_picture_alt" className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile header (06-calls.dc.html #1f): icon-only row, no room for the name/duration text --
|
||||
those move into the centered overlay below (no remote video yet) or the small pill just
|
||||
under this row (once real video is playing). 翻转摄像头 lives here instead of the bottom
|
||||
control grid, matching the mock exactly. */}
|
||||
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-between px-4 py-1.5 md:hidden">
|
||||
<button type="button" onClick={toggleMinimize} aria-label="最小化" className="grid h-10 w-10 place-items-center">
|
||||
<Icon name="picture_in_picture_alt" className="text-xl" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void useCallStore.getState().switchCamera()}
|
||||
aria-label="翻转摄像头"
|
||||
className="grid h-10 w-10 place-items-center"
|
||||
>
|
||||
<Icon name="flip_camera_ios" className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
{hasRemoteVideo ? (
|
||||
<div className="absolute left-1/2 top-16 z-10 -translate-x-1/2 whitespace-nowrap rounded-full bg-black/40 px-3 py-1 text-xs md:hidden">
|
||||
{peerName}
|
||||
{phase === 'connected' ? ` · ${formatElapsed(elapsed)}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute inset-0">
|
||||
{hasRemoteVideo ? (
|
||||
<div ref={remoteRef} className="h-full w-full [&>video]:h-full [&>video]:w-full [&>video]:object-cover" />
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop fallback (unchanged): dead-centered avatar, name/status stay in the header above. */}
|
||||
<div className="hidden h-full w-full place-items-center md:grid">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{/* Mock `06-calls.dc.html #1a`: `border-radius:28px`, not a perfect circle. */}
|
||||
<span
|
||||
className={`grid h-[120px] w-[120px] place-items-center rounded-[28px] text-4xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<span className="text-sm text-white/70">{phase === 'connected' ? '对方已关闭摄像头' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile
|
||||
has no header info bar to carry the name instead. */}
|
||||
<div className="flex h-full w-full flex-col items-center pt-[150px] md:hidden">
|
||||
<span
|
||||
className={`grid h-[110px] w-[110px] place-items-center rounded-[26px] text-4xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="mt-2 text-xl font-semibold">{peerName}</strong>
|
||||
<span className="mt-1 text-sm text-white/70">
|
||||
{phase === 'connected' ? formatElapsed(elapsed) : '正在等待对方接听…'}
|
||||
</span>
|
||||
{/* Desktop's version of this same fallback state shows this explanation (line 285
|
||||
above); mobile silently dropped it, showing only the elapsed-time line with no
|
||||
indication of WHY there's no video. */}
|
||||
{phase === 'connected' ? <span className="text-sm text-white/70">对方已关闭摄像头</span> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Local self-preview: one shared element (not a desktop/mobile fork) so the live Agora track
|
||||
stays attached to a single `ref` -- only its position/size differs by breakpoint, via plain
|
||||
responsive classes, restoring the desktop values under `md:`. */}
|
||||
{!cameraOff ? (
|
||||
<div
|
||||
ref={localRef}
|
||||
className="absolute right-4 top-[110px] z-10 h-40 w-27 overflow-hidden rounded-[14px] border-2 border-white/30 bg-black/40 [&>video]:h-full [&>video]:w-full [&>video]:object-cover md:right-5 md:top-[72px] md:h-[132px] md:w-[200px] md:rounded-xl"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Desktop bottom bar (unchanged): 5 controls incl. 共享屏幕. */}
|
||||
<div className="absolute inset-x-0 bottom-8 z-10 hidden justify-center gap-6 md:flex">
|
||||
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} />
|
||||
<CircleButton icon={cameraOff ? 'videocam_off' : 'videocam'} active={cameraOff} label="摄像头" onClick={toggleCamera} />
|
||||
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} />
|
||||
<CircleButton icon="screen_share" label="共享屏幕" onClick={() => {}} />
|
||||
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} />
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom bar (06-calls.dc.html #1f): 3-over-1 grid, no 共享屏幕 (not in this mock) and
|
||||
bigger touch targets (60px / 68px for hang-up) matching the mock's exact button sizes. */}
|
||||
<div className="absolute inset-x-0 bottom-12 z-10 grid grid-cols-3 gap-y-6 px-8 md:hidden">
|
||||
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} size="h-15 w-15" iconSize="text-[26px]" />
|
||||
<CircleButton
|
||||
icon={cameraOff ? 'videocam_off' : 'videocam'}
|
||||
active={cameraOff}
|
||||
label="摄像头"
|
||||
onClick={toggleCamera}
|
||||
size="h-15 w-15"
|
||||
iconSize="text-[26px]"
|
||||
/>
|
||||
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} size="h-15 w-15" iconSize="text-[26px]" />
|
||||
<span />
|
||||
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} size="h-17 w-17" iconSize="text-[30px]" />
|
||||
<span />
|
||||
</div>
|
||||
<Toast message={mediaError || ''} variant="error" onDone={() => useCallStore.setState({ mediaError: null })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Brief (3s, see stores/calls.ts's `_endWithReason`) full-screen result card shown right after any
|
||||
* call ends -- same icon/text vocabulary as the permanent chat-bubble record (dispatch.ts's
|
||||
* callResultDisplay) so the live moment and the message history agree on how a call is described. */
|
||||
function EndedCallOverlay() {
|
||||
const { topic, peerName, callType, endReason, connectedAt, wasCaller } = useCallStore();
|
||||
// Computed once (not on every re-render) -- this overlay is only ever on screen for a fixed 3s
|
||||
// window, so a live-ticking duration isn't needed and re-deriving it from Date.now() on each
|
||||
// render would make the displayed value drift depending on when React happens to re-render.
|
||||
const [durationMs] = useState(() => (connectedAt ? Date.now() - connectedAt : 0));
|
||||
const icon = callType === 'video' ? 'videocam' : 'call';
|
||||
const { label, danger } = (() => {
|
||||
switch (endReason) {
|
||||
case 'finished':
|
||||
return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false };
|
||||
case 'missed':
|
||||
return { label: wasCaller ? '对方未接听,已取消' : '未接听', danger: true };
|
||||
case 'declined':
|
||||
return { label: '对方已拒绝', danger: true };
|
||||
case 'disconnected':
|
||||
return { label: '通话连接异常,已结束', danger: true };
|
||||
case 'busy':
|
||||
return { label: '对方忙线中', danger: true };
|
||||
default:
|
||||
return { label: '通话已结束', danger: false };
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-[#1f2a3d] to-[#111826] text-white">
|
||||
{/* Same 24px squircle as the audio in-call avatar (same 100x100 size, same visual family --
|
||||
no mock draws this exact result-overlay state, so this follows the nearest one that does). */}
|
||||
<span className={`grid h-[100px] w-[100px] place-items-center rounded-[24px] text-3xl font-medium text-white ${avatarBgClass(topic || peerName)}`}>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="text-xl font-semibold">{peerName}</strong>
|
||||
<span className={`flex items-center gap-1.5 text-sm ${danger ? 'text-[#f34b46]' : 'text-white/70'}`}>
|
||||
<Icon name={danger ? 'phone_missed' : icon} className="text-base" />
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mounted once in AppShell. */
|
||||
export function CallRoot() {
|
||||
const { phase, minimized } = useCallStore();
|
||||
|
||||
if (phase === 'incoming')
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:contents">
|
||||
<IncomingCallCard />
|
||||
</div>
|
||||
<div className="contents md:hidden">
|
||||
<MobileIncomingCall />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (phase === 'outgoing' || phase === 'connected') return minimized ? <MinimizedCallBar /> : <CallWindow />;
|
||||
if (phase === 'ended') return <EndedCallOverlay />;
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user