Files
im_webapp/src/views/call-experience-view.jsx
T
Your NameandClaude Sonnet 5 4e8f45d7d8 路由从 hash 换成 History API(pushState/replaceState/popstate),不再依赖 location.hash
HashNavigation 改名 Navigation(src/lib/navigation.js),接口不变,内部改用真实路径:
parseUrlHash/composeUrlHash 改名 parseUrl/composeUrl;navigateTo/navigateReplace 改调
pushState/replaceState 后手动派发 popstate(pushState/replaceState 不会像赋值 location.hash
那样自动触发事件,这里复用 navigateReplace 早就在用的同一个手动 dispatch 手法);goBack()
不用改,history.back() 本来就是真实浏览器历史机制。新增 Navigation.currentUrl() 替代调用方
原本直接读的 window.location.hash(这个类从来没包装过这个读取)。

一处需要专门处理的边界情况:当 path[0] 是空字符串("不选中侧栏视图、只开一个会话"这个仓库里
的合法状态)时,拼出的真实路径开头会变成双斜杠,被浏览器当协议相对地址解析、试图跳到一个叫
会话id的host,pushState 直接抛 SecurityError。composeUrl/parseUrl 内部用占位 token "_" 处理
这种情况,对外的调用方(19个文件)完全无感。

tinode-web.jsx 的 handleHashRoute 改名 handleRouteChange,监听从 hashchange 换成 popstate;
service-worker.js 的推送通知点击跳转手动改写成同样的路径格式(这个文件在打包之外单独执行,
没法 import Navigation)。index.html/index-dev.html 加了 <base href="/">——所有静态资源引用
都是相对路径,hash 路由下页面路径永远是 /,换真实路径后深层地址刷新会按当前目录解析出错误
资源地址导致白屏,这一行是标准修法。nginx.conf 的 try_files 兜底规则本来就适用两种路由,只
更新了注释。

实机验证:地址栏不再有 #;点击会话/设置等各类视图正常跳转;浏览器前进后退和应用内"返回"
按钮都正常;收藏页内部的类型 tab 切换(navigateReplace)确认不产生新的历史记录;在会话页/
个人中心页刷新、直接用完整深层路径打开新标签页都能正确恢复到位,资源正常加载不白屏。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 21:00:39 +08:00

138 lines
11 KiB
React

import React from 'react';
import Navigation from '../lib/navigation.js';
const navigate = view => Navigation.navigateTo(view);
export const CALL_EXPERIENCE_VIEWS = [
'call-history', 'call-incoming', 'audio-call-ringing', 'audio-call-active',
'audio-call-muted', 'audio-call-speaker', 'video-call-ringing',
'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off',
'call-minimized'
];
const PEER = {name: '安妮', avatar: 'img/bkg/p09.jpg'};
const CALL_RECORDS = [
{id: 1, type: 'video', direction: 'outgoing', state: 'cancelled', label: '已取消', time: '今天 10:24'},
{id: 2, type: 'audio', direction: 'incoming', state: 'declined', label: '已拒绝', time: '昨天 21:03'},
{id: 3, type: 'video', direction: 'incoming', state: 'missed', label: '未接听', time: '昨天 18:42'},
{id: 4, type: 'audio', direction: 'outgoing', state: 'busy', label: '对方忙线', time: '星期一 15:10'},
{id: 5, type: 'audio', direction: 'outgoing', state: 'finished', label: '通话时长 12:36', time: '8月20日'}
];
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Toast({message, onDone}) {
React.useEffect(() => {
if (!message) return undefined;
const timer = window.setTimeout(onDone, 1800);
return () => window.clearTimeout(timer);
}, [message, onDone]);
return message ? <div className="call-toast" role="status">{message}</div> : null;
}
function CallAvatar({small = false}) {
return <span className={'call-product-avatar' + (small ? ' small' : '')}><img src={PEER.avatar} alt="" /></span>;
}
function CallHeader({onMinimize, light = false}) {
return <header className={'call-product-header' + (light ? ' light' : '')}>
<button type="button" onClick={onMinimize} aria-label="最小化通话"><Icon>keyboard_arrow_down</Icon></button>
<span>端到端加密通话</span>
<button type="button" aria-label="更多通话设置"><Icon>more_vert</Icon></button>
</header>;
}
function Control({icon, label, active, danger, onClick}) {
return <button type="button" className={'call-control' + (active ? ' active' : '') + (danger ? ' danger' : '')} onClick={onClick}><span><Icon>{icon}</Icon></span><small>{label}</small></button>;
}
function CallHistory() {
return <div className="call-history-page">
<header><button type="button" onClick={() => navigate('chat-standard')} aria-label="返回会话"><Icon>arrow_back_ios_new</Icon></button><h1>安妮</h1><button type="button" onClick={() => navigate('friend-profile')} aria-label="个人资料"><Icon>person_outline</Icon></button></header>
<main><p className="chat-time">最近通话</p>{CALL_RECORDS.map(item => <button type="button" className={'call-record ' + item.state} key={item.id} onClick={() => navigate(item.type === 'audio' ? 'audio-call-ringing' : 'video-call-ringing')}><Icon>{item.type === 'audio' ? 'call' : 'videocam'}</Icon><span><strong>{item.type === 'audio' ? '语音通话' : '视频通话'}</strong><small>{item.label}</small></span><time>{item.time}</time><Icon>{item.direction === 'incoming' ? 'call_received' : 'call_made'}</Icon></button>)}</main>
<footer><button type="button" onClick={() => navigate('audio-call-ringing')}><Icon>call</Icon><span>语音通话</span></button><button type="button" onClick={() => navigate('video-call-ringing')}><Icon>videocam</Icon><span>视频通话</span></button></footer>
</div>;
}
function IncomingCall({onToast}) {
return <div className="call-fullscreen call-audio-surface incoming">
<div className="call-ambient" />
<CallHeader onMinimize={() => navigate('call-minimized')} />
<main className="call-peer-card"><CallAvatar /><h1>{PEER.name}</h1><p>邀请你进行视频通话</p><span className="call-ringing-dots"><i /><i /><i /></span></main>
<footer className="call-incoming-actions"><Control icon="call_end" label="拒绝" danger onClick={() => {onToast('已拒绝视频通话'); window.setTimeout(() => navigate('call-history'), 600);}} /><Control icon="videocam" label="接听" active onClick={() => navigate('video-call-active')} /></footer>
</div>;
}
function formatDuration(seconds) {
const mins = Math.floor(seconds / 60).toString().padStart(2, '0');
return `${mins}:${(seconds % 60).toString().padStart(2, '0')}`;
}
function AudioCall({view, onToast}) {
const ringing = view === 'audio-call-ringing';
const [seconds, setSeconds] = React.useState(ringing ? 0 : 68);
const [mic, setMic] = React.useState(view !== 'audio-call-muted');
const [speaker, setSpeaker] = React.useState(view === 'audio-call-speaker');
React.useEffect(() => {
if (ringing) return undefined;
const timer = window.setInterval(() => setSeconds(value => value + 1), 1000);
return () => window.clearInterval(timer);
}, [ringing]);
const hangup = () => {onToast(ringing ? '已取消呼叫' : `通话结束 ${formatDuration(seconds)}`); window.setTimeout(() => navigate('call-history'), 650);};
return <div className="call-fullscreen call-audio-surface">
<div className="call-ambient" />
<CallHeader onMinimize={() => navigate('call-minimized')} />
<main className="call-peer-card"><CallAvatar /><h1>{PEER.name}</h1><p>{ringing ? '正在等待对方接听…' : formatDuration(seconds)}</p>{!ringing ? <small className="call-network"><i />网络良好</small> : <span className="call-ringing-dots"><i /><i /><i /></span>}</main>
{ringing ? <footer className="call-single-action"><Control icon="call_end" label="取消" danger onClick={hangup} /></footer> : <footer className="call-active-controls"><Control icon={mic ? 'mic' : 'mic_off'} label={mic ? '麦克风' : '麦克风已关'} active={mic} onClick={() => {setMic(!mic); onToast(mic ? '麦克风已关闭' : '麦克风已开启');}} /><Control icon={speaker ? 'volume_up' : 'hearing'} label={speaker ? '扬声器' : '听筒'} active={speaker} onClick={() => {setSpeaker(!speaker); onToast(speaker ? '已切换到听筒' : '扬声器已开启');}} /><Control icon="call_end" label="挂断" danger onClick={hangup} /></footer>}
</div>;
}
function VideoBackdrop({cameraOff, peerOff}) {
if (peerOff) return <div className="call-video-off"><CallAvatar /><strong>对方摄像头已关闭</strong><small>语音通话仍在继续</small></div>;
if (cameraOff) return <div className="call-video-off local"><CallAvatar /><strong>摄像头已关闭</strong></div>;
return <img className="call-video-backdrop" src="img/bkg/w06.jpg" alt="视频通话画面" />;
}
function VideoCall({view, onToast}) {
const ringing = view === 'video-call-ringing' || view === 'video-call-camera-off';
const [camera, setCamera] = React.useState(view !== 'video-call-camera-off');
const [mic, setMic] = React.useState(true);
const [seconds, setSeconds] = React.useState(ringing ? 0 : 128);
const peerOff = view === 'video-call-peer-camera-off';
React.useEffect(() => {
if (ringing) return undefined;
const timer = window.setInterval(() => setSeconds(value => value + 1), 1000);
return () => window.clearInterval(timer);
}, [ringing]);
const hangup = () => {onToast(ringing ? '已取消视频呼叫' : `视频通话结束 ${formatDuration(seconds)}`); window.setTimeout(() => navigate('call-history'), 650);};
return <div className="call-fullscreen call-video-surface">
<VideoBackdrop cameraOff={ringing && !camera} peerOff={peerOff} />
<div className="call-video-shade" />
<CallHeader light onMinimize={() => navigate('call-minimized')} />
<div className="call-video-status"><h1>{PEER.name}</h1><p>{ringing ? '正在等待对方接听…' : formatDuration(seconds)}</p></div>
{!ringing ? <button type="button" className="call-local-preview" aria-label="本地视频预览"><img src={camera ? 'img/bkg/p05.jpg' : PEER.avatar} alt="" />{!camera ? <Icon>videocam_off</Icon> : null}</button> : null}
<footer className="call-video-controls"><Control icon="flip_camera_android" label="翻转" onClick={() => onToast('已切换摄像头')} /><Control icon={camera ? 'videocam' : 'videocam_off'} label={camera ? '摄像头' : '摄像头已关'} active={camera} onClick={() => {setCamera(!camera); onToast(camera ? '摄像头已关闭' : '摄像头已开启');}} /><Control icon="call_end" label={ringing ? '取消' : '挂断'} danger onClick={hangup} />{!ringing ? <Control icon={mic ? 'mic' : 'mic_off'} label={mic ? '麦克风' : '麦克风已关'} active={mic} onClick={() => setMic(!mic)} /> : null}</footer>
</div>;
}
function MinimizedCall({onToast}) {
const [position, setPosition] = React.useState(() => ({x: Math.max(16, window.innerWidth - 134), y: 168}));
const drag = React.useRef(null);
const onPointerDown = event => {drag.current = {x: event.clientX - position.x, y: event.clientY - position.y}; event.currentTarget.setPointerCapture(event.pointerId);};
const onPointerMove = event => {if (!drag.current) return; setPosition({x: Math.max(8, Math.min(window.innerWidth - 132, event.clientX - drag.current.x)), y: Math.max(70, Math.min(window.innerHeight - 190, event.clientY - drag.current.y))});};
return <div className="call-minimized-page"><header className="call-chat-preview-header"><button type="button" onClick={() => navigate('messages-home')}><Icon>arrow_back_ios_new</Icon></button><div><strong>安妮</strong><small>在线</small></div><button type="button"><Icon>more_vert</Icon></button></header><main><p className="chat-time">今天 10:24</p><div className="call-chat-preview-message">下午的评审材料我已经发到群里了。</div></main><button type="button" className="call-floating-window" style={{transform: `translate(${position.x}px, ${position.y}px)`}} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={() => {drag.current = null;}} onClick={() => navigate('video-call-active')} aria-label="返回全屏通话"><img src="img/bkg/w06.jpg" alt="视频通话悬浮画面" /><span><i />02:18</span></button><footer className="call-chat-composer"><input placeholder="输入消息" /><button type="button"><Icon>add_circle_outline</Icon></button></footer><button type="button" className="call-mini-hangup" onClick={() => {onToast('通话已结束'); window.setTimeout(() => navigate('call-history'), 600);}} aria-label="挂断通话"><Icon>call_end</Icon></button></div>;
}
export default function CallExperienceView({view}) {
const [toast, setToast] = React.useState('');
let content;
if (view === 'call-history') content = <CallHistory />;
else if (view === 'call-incoming') content = <IncomingCall onToast={setToast} />;
else if (view.startsWith('audio-call')) content = <AudioCall view={view} onToast={setToast} />;
else if (view === 'call-minimized') content = <MinimizedCall onToast={setToast} />;
else content = <VideoCall view={view} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
}