Files
im_webapp/src/views/direct-message-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

279 lines
14 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}