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>
279 lines
14 KiB
React
279 lines
14 KiB
React
import React from 'react';
|
||
|
||
import Navigation from '../lib/navigation.js';
|
||
|
||
const navigate = view => Navigation.navigateTo(view);
|
||
|
||
const VIEW_CONFIG = {
|
||
'chat-inbound-request': {relationship: 'pending_inbound', variant: 'inbound-request'},
|
||
'chat-inbound-message': {relationship: 'pending_inbound', variant: 'inbound-message'},
|
||
'chat-outbound-request': {relationship: 'pending_outbound', variant: 'outbound-request'},
|
||
'chat-status': {relationship: 'friend', variant: 'status'},
|
||
'chat-friend': {relationship: 'friend', variant: 'friend'},
|
||
'chat-media': {relationship: 'friend', variant: 'media'},
|
||
'chat-blocked': {relationship: 'blocked_by_peer', variant: 'blocked'},
|
||
'chat-deleted': {relationship: 'none', variant: 'deleted'},
|
||
'chat-standard': {relationship: 'friend', variant: 'standard'},
|
||
'chat-attachments': {relationship: 'friend', variant: 'attachments'},
|
||
'chat-mixed': {relationship: 'friend', variant: 'mixed'},
|
||
'chat-contact-card': {relationship: 'friend', variant: 'contact-card'}
|
||
};
|
||
|
||
const ATTACHMENTS = [
|
||
{id: 'photo', icon: 'image', label: '照片'},
|
||
{id: 'camera', icon: 'photo_camera', label: '拍摄'},
|
||
{id: 'call', icon: 'call', label: '视频语音'},
|
||
{id: 'card', icon: 'contact_page', label: '名片'},
|
||
{id: 'file', icon: 'folder', label: '文件'},
|
||
{id: 'favorite', icon: 'bookmark', label: '收藏'},
|
||
{id: 'video', icon: 'videocam', label: '视频通话'}
|
||
];
|
||
|
||
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, 2200);
|
||
return () => window.clearTimeout(timer);
|
||
}, [message, onDone]);
|
||
return message ? <div className="chat-toast" role="status">{message}</div> : null;
|
||
}
|
||
|
||
function ChatAvatar({mine = false, peer = false}) {
|
||
return <div className="chat-avatar"><img src={mine ? 'img/bkg/p05.jpg' : peer ? 'img/bkg/p09.jpg' : 'img/bkg/p14.jpg'} alt="" /></div>;
|
||
}
|
||
|
||
function Status({value, failedReason}) {
|
||
if (!value && !failedReason) return null;
|
||
return <div className={'chat-message-status' + (failedReason ? ' failed' : '')}>
|
||
{failedReason ? <Icon>error</Icon> : null}<span>{failedReason || value}</span>
|
||
</div>;
|
||
}
|
||
|
||
function FileCard() {
|
||
return <div className="chat-file-card">
|
||
<div><strong>365天艾宾浩斯记忆曲线复习计划.pdf</strong><small>115KB</small></div>
|
||
<span className="chat-file-icon">P</span>
|
||
</div>;
|
||
}
|
||
|
||
function ContactCard({onOpen}) {
|
||
return <button type="button" className="chat-contact-card" onClick={onOpen}>
|
||
<span><img src="img/bkg/p15.jpg" alt="" /><strong>栗子来了</strong></span>
|
||
<small>个人名片 · 账号 QL1024</small>
|
||
</button>;
|
||
}
|
||
|
||
function VoiceMessage({duration = 6, playing = false}) {
|
||
return <div className="chat-voice-message"><Icon>{playing ? 'graphic_eq' : 'volume_up'}</Icon><strong>{duration}″</strong><span /></div>;
|
||
}
|
||
|
||
function ImageMessage({progress}) {
|
||
return <div className="chat-image-message">
|
||
<img src="img/bkg/p22.jpg" alt="会话图片" />
|
||
{progress < 100 ? <div className="chat-upload-progress"><progress value={progress} max="100" /><strong>{progress}%</strong></div> : null}
|
||
</div>;
|
||
}
|
||
|
||
function Bubble({mine = false, children, status, failedReason, type = 'text', onOpenCard}) {
|
||
return <div className={'chat-message-row' + (mine ? ' mine' : '')}>
|
||
{!mine ? <ChatAvatar peer /> : null}
|
||
<div className="chat-message-stack">
|
||
{type === 'file' ? <FileCard /> : type === 'card' ? <ContactCard onOpen={onOpenCard} /> :
|
||
<div className={'chat-bubble ' + type + (mine ? ' mine' : '')}>{children}</div>}
|
||
<Status value={status} failedReason={failedReason} />
|
||
</div>
|
||
{mine ? <ChatAvatar mine /> : null}
|
||
</div>;
|
||
}
|
||
|
||
function RelationNotice({children, action, onAction}) {
|
||
return <div className="chat-relation-notice"><span>{children}</span>{action ? <button type="button" onClick={onAction}>{action}</button> : null}</div>;
|
||
}
|
||
|
||
function ChatTimeline({variant, uploadProgress, sendStatus, onRetry, onOpenCard, onFriendAction}) {
|
||
if (variant === 'inbound-request' || variant === 'inbound-message') {
|
||
return <>
|
||
<p className="chat-time">7月30日 11:40</p>
|
||
<Bubble>{variant === 'inbound-request' ? '请求添加你为好友' : '我是祈万事安好'}</Bubble>
|
||
<RelationNotice>以上是打招呼的消息</RelationNotice>
|
||
<div className="chat-request-actions"><button type="button" onClick={() => onFriendAction('declined')}>拒绝</button><button type="button" className="primary" onClick={() => onFriendAction('accepted')}>通过申请</button></div>
|
||
</>;
|
||
}
|
||
|
||
if (variant === 'outbound-request') {
|
||
return <>
|
||
<p className="chat-time">7月30日 11:40</p>
|
||
<Bubble mine status="已发送">请求添加你为好友</Bubble>
|
||
<RelationNotice>好友申请已发送,等待对方处理</RelationNotice>
|
||
</>;
|
||
}
|
||
|
||
if (variant === 'status') {
|
||
return <>
|
||
<p className="chat-time">7月30日 11:40</p>
|
||
<Bubble mine status={sendStatus}>我是祈万事安好</Bubble>
|
||
<RelationNotice>你已添加安妮,现在可以开始聊天。</RelationNotice>
|
||
</>;
|
||
}
|
||
|
||
if (variant === 'friend') {
|
||
return <>
|
||
<p className="chat-time">8月27日 11:11</p>
|
||
<RelationNotice>你们已成为好友,现在可以开始聊天</RelationNotice>
|
||
<Bubble>我们已成为好友,现在我们可以开始聊天</Bubble>
|
||
<Bubble mine status="已读">你还好吗?</Bubble>
|
||
</>;
|
||
}
|
||
|
||
if (variant === 'media') {
|
||
return <>
|
||
<p className="chat-time">8月27日 11:11</p>
|
||
<RelationNotice>接雨彤创建群聊</RelationNotice>
|
||
<Bubble mine status="已读">你还好吗?</Bubble>
|
||
<Bubble mine type="image"><ImageMessage progress={uploadProgress} /></Bubble>
|
||
<Bubble mine type="voice"><VoiceMessage duration={6} playing /></Bubble>
|
||
<Bubble type="voice"><VoiceMessage duration={6} /></Bubble>
|
||
</>;
|
||
}
|
||
|
||
if (variant === 'blocked') {
|
||
return <>
|
||
<p className="chat-time">7月30日 11:40</p>
|
||
<Bubble mine status="已读">请求添加你为好友</Bubble>
|
||
<RelationNotice>你已添加了安妮,现在可以开始聊天。</RelationNotice>
|
||
<p className="chat-time">7月30日 13:00</p>
|
||
<Bubble mine status="已读">这边在网上看见你的留言,你想了解什么课程了</Bubble>
|
||
<Bubble>有课程辅导的老师?对新手友好吗?</Bubble>
|
||
<Bubble mine failedReason="消息已发送,但被对方拒收了">这个不用担心特别好上手,学起来特别简单</Bubble>
|
||
<Bubble mine failedReason="消息已发送,但被对方拒收了">拉黑我这么速度,真的牛</Bubble>
|
||
</>;
|
||
}
|
||
|
||
if (variant === 'deleted') {
|
||
return <>
|
||
<p className="chat-time">7月30日 13:00</p>
|
||
<Bubble mine status="已读">你好我是XXX推荐过来的,这边想了解课程是吗</Bubble>
|
||
<Bubble>这边目前找到更好的课程,不考虑你们家,谢谢</Bubble>
|
||
<Bubble mine status="已读">这是我们导师名片,你可以加一下,对比一下价格</Bubble>
|
||
<RelationNotice action="发送朋友验证" onAction={() => onFriendAction('resent')}>你还不是他(她)朋友。</RelationNotice>
|
||
<Bubble mine failedReason="发送失败,需重新添加好友">这么突然把我删了,真心累了</Bubble>
|
||
<RelationNotice action="发送朋友验证" onAction={() => onFriendAction('resent')}>你还不是他(她)朋友。</RelationNotice>
|
||
</>;
|
||
}
|
||
|
||
const base = <>
|
||
<p className="chat-time">7月30日 11:40</p>
|
||
<Bubble mine status="已读">请求添加你为好友</Bubble>
|
||
<RelationNotice>你已添加了安妮,现在可以开始聊天。</RelationNotice>
|
||
<p className="chat-time">7月30日 13:00</p>
|
||
<Bubble mine status="已读">你好,我是王珊珊,很高兴认识你,你怎么称呼</Bubble>
|
||
<Bubble>你好,我是安妮,想跟你咨询一下设计课程,方便?</Bubble>
|
||
</>;
|
||
|
||
if (variant === 'standard') {
|
||
return <>{base}<Bubble mine status="未读">非常方便,这是我们的课程目录,你可以看看了解一下</Bubble><Bubble mine type="file" status="未读" /></>;
|
||
}
|
||
if (variant === 'mixed') {
|
||
return <>{base}<Bubble mine type="file" status="已送达" /><Bubble type="card" onOpenCard={onOpenCard} /></>;
|
||
}
|
||
if (variant === 'contact-card') {
|
||
return <>{base}<Bubble mine status="已读">这是我们导师名片,你可以加一下,有问题随时联系他</Bubble><Bubble mine type="card" status="已送达" onOpenCard={onOpenCard} /><Bubble>好的,非常感谢,那我这边就直接联系他吧</Bubble><Bubble mine status="已读">没问题的,有疑问随时沟通</Bubble></>;
|
||
}
|
||
return base;
|
||
}
|
||
|
||
function AttachmentPanel({onChoose}) {
|
||
return <div className="chat-attachment-panel">
|
||
{ATTACHMENTS.map(item => <button type="button" key={item.id} onClick={() => onChoose(item)}><span><Icon>{item.icon}</Icon></span><small>{item.label}</small></button>)}
|
||
</div>;
|
||
}
|
||
|
||
function Composer({relationship, attachmentOpen, setAttachmentOpen, onSend}) {
|
||
const [draft, setDraft] = React.useState(sessionStorage.getItem('im-chat-draft') || '');
|
||
const canSend = relationship === 'friend';
|
||
const updateDraft = value => {
|
||
setDraft(value);
|
||
sessionStorage.setItem('im-chat-draft', value);
|
||
};
|
||
const submit = e => {
|
||
e.preventDefault();
|
||
if (!canSend || !draft.trim()) return;
|
||
onSend(draft.trim());
|
||
updateDraft('');
|
||
};
|
||
return <form className={'chat-composer' + (!canSend ? ' locked' : '')} onSubmit={submit}>
|
||
<button type="button" aria-label="语音" disabled={!canSend} onClick={() => navigate('voice-ready')}><Icon>record_voice_over</Icon></button>
|
||
<input value={draft} onChange={e => updateDraft(e.target.value)} placeholder={canSend ? '输入消息' : relationship === 'pending_inbound' ? '请先处理好友申请' : relationship === 'pending_outbound' ? '等待对方通过好友申请' : '当前无法发送消息'} disabled={!canSend} />
|
||
<button type="button" aria-label="表情" disabled={!canSend} onClick={() => navigate('emoji-panel')}><Icon>sentiment_satisfied_alt</Icon></button>
|
||
{draft.trim() ? <button type="submit" className="chat-send-button">发送</button> : <button type="button" aria-label="更多" disabled={!canSend} onClick={() => setAttachmentOpen(!attachmentOpen)}><Icon>{attachmentOpen ? 'keyboard' : 'add_circle_outline'}</Icon></button>}
|
||
</form>;
|
||
}
|
||
|
||
export default function DirectMessageView({view}) {
|
||
const config = VIEW_CONFIG[view] || VIEW_CONFIG['chat-standard'];
|
||
const [relationship, setRelationship] = React.useState(config.relationship);
|
||
const [variant, setVariant] = React.useState(config.variant);
|
||
const [attachmentOpen, setAttachmentOpen] = React.useState(config.variant === 'attachments');
|
||
const [uploadProgress, setUploadProgress] = React.useState(config.variant === 'media' ? 76 : 100);
|
||
const [sendStatus, setSendStatus] = React.useState('发送中');
|
||
const [extraMessages, setExtraMessages] = React.useState([]);
|
||
const [toast, setToast] = React.useState('');
|
||
|
||
React.useEffect(() => {
|
||
if (variant !== 'status') return undefined;
|
||
const stages = ['已发送', '已送达', '已读'];
|
||
const timers = stages.map((status, index) => window.setTimeout(() => setSendStatus(status), 600 * (index + 1)));
|
||
return () => timers.forEach(timer => window.clearTimeout(timer));
|
||
}, [variant]);
|
||
|
||
React.useEffect(() => {
|
||
if (variant !== 'media' || uploadProgress >= 100) return undefined;
|
||
const timer = window.setTimeout(() => setUploadProgress(value => Math.min(value + 4, 100)), 350);
|
||
return () => window.clearTimeout(timer);
|
||
}, [variant, uploadProgress]);
|
||
|
||
const friendAction = action => {
|
||
if (action === 'declined') {
|
||
setRelationship('none');
|
||
setToast('已拒绝好友申请');
|
||
} else {
|
||
setRelationship(action === 'accepted' ? 'friend' : 'pending_outbound');
|
||
if (action === 'accepted') setVariant('friend');
|
||
setToast(action === 'accepted' ? '好友申请已通过' : '好友验证已发送');
|
||
}
|
||
};
|
||
|
||
return <div className="chat-product-page">
|
||
<header className="chat-product-header">
|
||
<button type="button" onClick={() => navigate('messages-home')} aria-label="返回消息"><Icon>arrow_back_ios_new</Icon></button>
|
||
<div><h1>接语彤</h1><span><i />在线</span></div>
|
||
<button type="button" onClick={() => navigate('friend-settings')} aria-label="会话设置"><Icon>more_vert</Icon></button>
|
||
</header>
|
||
<main className="chat-product-timeline">
|
||
<ChatTimeline variant={variant} uploadProgress={uploadProgress} sendStatus={sendStatus}
|
||
onRetry={() => setToast('正在重试发送')} onOpenCard={() => navigate('friend-profile')} onFriendAction={friendAction} />
|
||
{extraMessages.map(item => <Bubble mine status={item.status} key={item.id}>{item.text}</Bubble>)}
|
||
</main>
|
||
{relationship === 'friend' ? null : <div className="chat-permission-banner"><Icon>info_outline</Icon>{relationship === 'pending_inbound' ? '通过好友申请后即可发送消息' : relationship === 'pending_outbound' ? '好友申请正在等待处理' : relationship === 'none' ? '对方已不是你的好友,发送消息前需重新验证' : '当前关系状态下无法发送消息'}</div>}
|
||
<Composer relationship={relationship} attachmentOpen={attachmentOpen} setAttachmentOpen={setAttachmentOpen}
|
||
onSend={text => {
|
||
const id = Date.now();
|
||
setExtraMessages(items => items.concat({id, text, status: '发送中'}));
|
||
window.setTimeout(() => setExtraMessages(items => items.map(item => item.id === id ? {...item, status: '已发送'} : item)), 600);
|
||
}} />
|
||
{attachmentOpen && relationship === 'friend' ? <AttachmentPanel onChoose={item => {
|
||
setToast(`已选择${item.label},上传接口将在联调阶段接入`);
|
||
if (item.id === 'card') navigate('chat-contact-card');
|
||
if (item.id === 'photo' || item.id === 'camera') navigate('media-uploading');
|
||
if (item.id === 'call' || item.id === 'video') navigate('call-history');
|
||
if (item.id === 'file') navigate('upload-file');
|
||
if (item.id === 'favorite') navigate('favorites');
|
||
}} /> : null}
|
||
<Toast message={toast} onDone={() => setToast('')} />
|
||
</div>;
|
||
}
|