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

233 lines
20 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);
export const MESSAGE_COMPOSER_VIEWS = [
'compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message',
'emoji-panel', 'emoji-message', 'voice-ready', 'voice-hold', 'voice-recording',
'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message',
'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple',
'forward-individual', 'forward-bundle'
];
const MEMBERS = [
{id: 'all', name: '所有人', note: '提醒群内所有成员', avatar: 'campaign'},
{id: 'annie', name: '安妮', note: '产品设计组', image: 'img/bkg/p09.jpg'},
{id: 'lizi', name: '栗子来了', note: '前端开发组', image: 'img/bkg/p15.jpg'},
{id: 'wang', name: '王珊珊', note: '市场运营组', image: 'img/bkg/p14.jpg'},
{id: 'chen', name: '陈晨', note: '项目管理组', image: 'img/bkg/p05.jpg'}
];
const TARGETS = [
{id: 'file', name: '文件助手', note: '仅自己可见', icon: 'folder_special'},
{id: 'team', name: '产品研发群', note: '18人', image: 'img/bkg/p22.jpg'},
{id: 'annie', name: '安妮', note: '在线', image: 'img/bkg/p09.jpg'},
{id: 'lizi', name: '栗子来了', note: '刚刚活跃', image: 'img/bkg/p15.jpg'},
{id: 'wang', name: '王珊珊', note: '市场运营组', image: 'img/bkg/p14.jpg'}
];
const EMOJIS = ['😀', '😄', '😂', '🥰', '😎', '🤔', '👍', '👏', '🎉', '❤️', '🔥', '💡', '✅', '🙏', '💪', '🌟', '📌', '🚀'];
function Icon({children}) {
return <i className="material-icons" aria-hidden="true">{children}</i>;
}
function Avatar({item}) {
return <span className="compose-avatar">
{item.image ? <img src={item.image} alt="" /> : <Icon>{item.icon || item.avatar || 'person'}</Icon>}
</span>;
}
function Header({title = '产品研发群', subtitle = '18人', onMore}) {
return <header className="chat-product-header">
<button type="button" onClick={() => navigate('messages-home')} aria-label="返回消息"><Icon>arrow_back_ios_new</Icon></button>
<div><h1>{title}</h1>{subtitle ? <span>{subtitle}</span> : null}</div>
<button type="button" onClick={onMore} aria-label="会话设置"><Icon>more_vert</Icon></button>
</header>;
}
function Toast({message, onDone}) {
React.useEffect(() => {
if (!message) return undefined;
const timer = window.setTimeout(onDone, 2000);
return () => window.clearTimeout(timer);
}, [message, onDone]);
return message ? <div className="chat-toast" role="status">{message}</div> : null;
}
function VoiceBubble({mine = false, duration = 8, unread = false}) {
return <div className={'compose-message-row' + (mine ? ' mine' : '')}>
{!mine ? <Avatar item={MEMBERS[1]} /> : null}
<button type="button" className={'compose-voice-bubble' + (mine ? ' mine' : '')} aria-label={`播放${duration}秒语音`}>
<Icon>graphic_eq</Icon><span className="compose-wave" /><strong>{duration}″</strong>{unread ? <i /> : null}
</button>
{mine ? <Avatar item={{image: 'img/bkg/p05.jpg'}} /> : null}
</div>;
}
function TextBubble({mine = false, children, selected, onSelect}) {
return <div className={'compose-select-row' + (selected !== undefined ? ' selectable' : '')}>
{selected !== undefined ? <button type="button" className={'compose-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label={selected ? '取消选择' : '选择消息'}><Icon>{selected ? 'check' : ''}</Icon></button> : null}
<div className={'compose-message-row' + (mine ? ' mine' : '')}>
{!mine ? <Avatar item={MEMBERS[2]} /> : null}
<div className={'compose-text-bubble' + (mine ? ' mine' : '')}>{children}</div>
{mine ? <Avatar item={{image: 'img/bkg/p05.jpg'}} /> : null}
</div>
</div>;
}
function ImageBubble({progress, failed = false, selected, onSelect}) {
return <div className={'compose-select-row' + (selected !== undefined ? ' selectable' : '')}>
{selected !== undefined ? <button type="button" className={'compose-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择图片"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
<div className="compose-message-row mine">
<div className="compose-media-card"><img src="img/bkg/p22.jpg" alt="会议白板照片" />
{progress < 100 || failed ? <div className="compose-media-state"><Icon>{failed ? 'refresh' : 'cloud_upload'}</Icon><strong>{failed ? '上传失败,点击重试' : `${progress}%`}</strong>{!failed ? <progress value={progress} max="100" /> : null}</div> : null}
</div>
<Avatar item={{image: 'img/bkg/p05.jpg'}} />
</div>
</div>;
}
function VideoBubble() {
return <div className="compose-message-row"><Avatar item={MEMBERS[3]} /><button type="button" className="compose-video-card" aria-label="播放视频"><img src="img/bkg/p23.jpg" alt="项目讨论视频封面" /><Icon>play_circle</Icon><small>00:24</small></button></div>;
}
function FileBubble({progress = 100, name = '产品需求说明书.pdf', selected, onSelect}) {
return <div className={'compose-select-row' + (selected !== undefined ? ' selectable' : '')}>
{selected !== undefined ? <button type="button" className={'compose-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择文件"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
<div className="compose-message-row mine"><div className="compose-file-card"><span className="compose-file-type">PDF</span><div><strong>{name}</strong><small>2.4 MB · {progress < 100 ? `上传中 ${progress}%` : '已发送'}</small>{progress < 100 ? <progress value={progress} max="100" /> : null}</div><Icon>{progress < 100 ? 'close' : 'download'}</Icon></div><Avatar item={{image: 'img/bkg/p05.jpg'}} /></div>
</div>;
}
function BaseTimeline({mode, uploadProgress, selected, toggleSelected}) {
const selectable = mode === 'message-multiselect' || mode === 'favorite-success';
return <main className="chat-product-timeline compose-timeline">
<p className="chat-time">今天 10:24</p>
<TextBubble>上午的评审结论已经整理好了,大家有空看一下。</TextBubble>
<TextBubble mine selected={selectable ? selected.includes('text') : undefined} onSelect={() => toggleSelected('text')}>好的,我补充一下交互细节。</TextBubble>
{mode === 'mentions-message' ? <TextBubble><strong className="compose-mention">@安妮 @栗子来了</strong> 下午三点一起确认新版流程。</TextBubble> : null}
{['media-uploading', 'media-gallery', 'media-retry'].includes(mode) ? <>
<VoiceBubble duration={6} />
<ImageBubble progress={mode === 'media-retry' ? 48 : uploadProgress} failed={mode === 'media-retry'} />
{mode === 'media-gallery' ? <><VoiceBubble mine duration={12} unread /><VideoBubble /></> : null}
</> : null}
{['file-message', 'message-multiselect', 'favorite-success'].includes(mode) ? <FileBubble selected={selectable ? selected.includes('file') : undefined} onSelect={() => toggleSelected('file')} /> : null}
{selectable ? <ImageBubble progress={100} selected={selected.includes('image')} onSelect={() => toggleSelected('image')} /> : null}
</main>;
}
function EmojiPanel({onEmoji, onDelete}) {
return <section className="compose-emoji-panel" aria-label="表情面板">
<nav><button type="button" className="active" aria-label="最近使用"><Icon>schedule</Icon></button><button type="button" aria-label="笑脸"><Icon>sentiment_satisfied_alt</Icon></button><button type="button" aria-label="收藏表情"><Icon>favorite_border</Icon></button></nav>
<div>{EMOJIS.map(emoji => <button type="button" key={emoji} onClick={() => onEmoji(emoji)}>{emoji}</button>)}</div>
<button type="button" className="compose-emoji-delete" onClick={onDelete} aria-label="删除字符"><Icon>backspace</Icon></button>
</section>;
}
function Composer({mode, draft, setDraft, onSend, setMode}) {
const voice = ['voice-ready', 'voice-hold', 'voice-recording'].includes(mode);
const emoji = ['emoji-panel', 'emoji-message'].includes(mode);
const keyDown = event => {
if (event.key === '@' && mode !== 'mentions-message') window.setTimeout(() => navigate('mentions-select'), 0);
};
return <>
<form className="compose-toolbar" onSubmit={event => {event.preventDefault(); onSend();}}>
<button type="button" onClick={() => setMode(voice ? 'compose-keyboard' : 'voice-ready')} aria-label={voice ? '切换键盘' : '切换语音'}><Icon>{voice ? 'keyboard' : 'record_voice_over'}</Icon></button>
{voice ? <button type="button" className="compose-hold-button" onPointerDown={() => setMode('voice-hold')} onPointerUp={() => setMode('media-uploading')}>按住说话</button> : <input autoFocus={mode === 'compose-keyboard'} value={draft} onChange={event => setDraft(event.target.value)} onKeyDown={keyDown} placeholder="输入消息" />}
<button type="button" onClick={() => setMode(emoji ? 'compose-keyboard' : 'emoji-panel')} aria-label="表情"><Icon>sentiment_satisfied_alt</Icon></button>
{draft.trim() && !voice ? <button type="submit" className="compose-send">发送</button> : <button type="button" onClick={() => navigate('upload-file')} aria-label="更多"><Icon>add_circle_outline</Icon></button>}
</form>
{emoji ? <EmojiPanel onEmoji={value => setDraft(draft + value)} onDelete={() => setDraft(draft.slice(0, -1))} /> : null}
</>;
}
function VoiceOverlay({cancel = false}) {
return <div className={'compose-record-overlay' + (cancel ? ' cancel' : '')} role="status">
<Icon>{cancel ? 'delete_outline' : 'mic'}</Icon>
<div className="compose-meter">{[2, 5, 8, 12, 9, 6, 11, 4].map((height, index) => <i key={index} style={{height: `${height * 2}px`}} />)}</div>
<strong>{cancel ? '松开手指,取消发送' : '手指上滑,取消发送'}</strong><small>00:08</small>
</div>;
}
function MemberPicker({complete}) {
const [query, setQuery] = React.useState('');
const [selected, setSelected] = React.useState(complete ? ['annie', 'lizi'] : []);
const visible = MEMBERS.filter(item => item.name.includes(query) || item.note.includes(query));
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id));
return <div className="compose-picker-page">
<header className="compose-picker-header"><button type="button" onClick={() => navigate('compose-keyboard')}><Icon>arrow_back_ios_new</Icon></button><h1>选择提醒人</h1><button type="button" className="text" disabled={!selected.length} onClick={() => {sessionStorage.setItem('im-mentions', JSON.stringify(selected)); navigate('mentions-message');}}>完成{selected.length ? `(${selected.length})` : ''}</button></header>
<label className="compose-search"><Icon>search</Icon><input value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索群成员" /></label>
<p className="compose-section-title">群成员</p>
<div className="compose-picker-list">{visible.map(item => <button type="button" key={item.id} onClick={() => toggle(item.id)}><Avatar item={item} /><span><strong>{item.name}</strong><small>{item.note}</small></span><i className={'compose-checkbox' + (selected.includes(item.id) ? ' checked' : '')}>{selected.includes(item.id) ? <Icon>check</Icon> : null}</i></button>)}</div>
</div>;
}
function UploadFilePage({onToast}) {
const [files, setFiles] = React.useState([{name: '产品需求说明书.pdf', size: '2.4 MB', progress: 64}]);
React.useEffect(() => {
if (files.every(file => file.progress >= 100)) return undefined;
const timer = window.setTimeout(() => setFiles(items => items.map(file => ({...file, progress: Math.min(100, file.progress + 6)}))), 300);
return () => window.clearTimeout(timer);
}, [files]);
return <div className="compose-upload-page"><Header title="上传文件" subtitle="最多选择 9 个文件" onMore={() => onToast('单个文件最大 100 MB')} />
<main><label className="compose-upload-drop"><input type="file" multiple onChange={event => setFiles(Array.from(event.target.files).map(file => ({name: file.name, size: `${Math.max(1, file.size / 1024 / 1024).toFixed(1)} MB`, progress: 1})))} /><Icon>cloud_upload</Icon><strong>选择文件</strong><small>支持文档、压缩包及常用媒体格式</small></label>
<section className="compose-upload-list"><h2>待发送文件</h2>{files.map(file => <div key={file.name}><span className="compose-file-type">{file.name.split('.').pop().slice(0, 4).toUpperCase()}</span><div><strong>{file.name}</strong><small>{file.size} · {file.progress < 100 ? `上传中 ${file.progress}%` : '上传完成'}</small><progress value={file.progress} max="100" /></div><button type="button" aria-label="移除文件" onClick={() => setFiles(items => items.filter(item => item.name !== file.name))}><Icon>close</Icon></button></div>)}</section>
</main><footer><button type="button" disabled={!files.length} onClick={() => navigate('file-message')}>发送{files.length ? ` (${files.length})` : ''}</button></footer>
</div>;
}
function ForwardPage({mode, onToast}) {
const multi = mode !== 'forward-select';
const confirm = mode === 'forward-individual' || mode === 'forward-bundle';
const [selected, setSelected] = React.useState(() => {
if (!confirm) return [];
try {
return JSON.parse(sessionStorage.getItem('im-forward-targets')) || ['team', 'annie'];
} catch (_) {
return ['team', 'annie'];
}
});
const [query, setQuery] = React.useState('');
const [comment, setComment] = React.useState('');
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : multi ? values.concat(id).slice(0, 9) : [id]);
const visible = TARGETS.filter(item => item.name.includes(query) || item.note.includes(query));
return <div className="compose-forward-page">
<header className="compose-picker-header"><button type="button" onClick={() => navigate('message-multiselect')}><Icon>close</Icon></button><h1>{confirm ? (mode === 'forward-bundle' ? '合并转发' : '逐条转发') : '选择转发对象'}</h1>{!confirm ? <button type="button" className="text" onClick={() => navigate(multi ? 'forward-select' : 'forward-multiple')}>{multi ? '单选' : '多选'}</button> : <span />}</header>
{!confirm ? <><label className="compose-search"><Icon>search</Icon><input value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索会话或联系人" /></label><p className="compose-section-title">最近会话</p><div className="compose-picker-list">{visible.map(item => <button type="button" key={item.id} onClick={() => {toggle(item.id); if (!multi) {sessionStorage.setItem('im-forward-targets', JSON.stringify([item.id])); window.setTimeout(() => navigate('forward-individual'), 0);}}}><Avatar item={item} /><span><strong>{item.name}</strong><small>{item.note}</small></span><i className={'compose-checkbox' + (selected.includes(item.id) ? ' checked' : '')}>{selected.includes(item.id) ? <Icon>check</Icon> : null}</i></button>)}</div>{multi ? <footer className="compose-target-footer"><span>已选择 {selected.length}/9 个会话</span><button type="button" disabled={!selected.length} onClick={() => {sessionStorage.setItem('im-forward-targets', JSON.stringify(selected)); navigate('forward-individual');}}>下一步</button></footer> : null}</> : <main className="compose-forward-confirm"><section><h2>发送给</h2><div className="compose-target-summary">{TARGETS.filter(item => selected.includes(item.id)).map(item => <span key={item.id}><Avatar item={item} /><small>{item.name}</small></span>)}</div></section><section className="compose-forward-preview"><h2>{mode === 'forward-bundle' ? '产品研发群的聊天记录' : '将逐条发送 3 条消息'}</h2><p>栗子来了:上午的评审结论已经整理好了</p><p>我:好的,我补充一下交互细节。</p><p>我:[图片]</p></section><textarea value={comment} onChange={event => setComment(event.target.value)} placeholder="给朋友留言" /><div className="compose-forward-mode"><button type="button" className={mode === 'forward-individual' ? 'active' : ''} onClick={() => navigate('forward-individual')}>逐条转发</button><button type="button" className={mode === 'forward-bundle' ? 'active' : ''} onClick={() => navigate('forward-bundle')}>合并转发</button></div><button type="button" className="compose-confirm-send" onClick={() => {onToast(`已发送给 ${selected.length} 个会话`); window.setTimeout(() => navigate('chat-standard'), 900);}}>发送</button></main>}
</div>;
}
export default function MessageComposerView({view}) {
const [mode, setMode] = React.useState(view);
const [draft, setDraftState] = React.useState(() => sessionStorage.getItem('im-message-composer-draft') || (view === 'emoji-message' ? '收到啦 ' : ''));
const [uploadProgress, setUploadProgress] = React.useState(view === 'media-uploading' ? 22 : 100);
const [selected, setSelected] = React.useState(view === 'favorite-success' ? ['text', 'file', 'image'] : ['text', 'image']);
const [toast, setToast] = React.useState(view === 'favorite-success' ? '已收藏 3 条消息' : '');
const setDraft = value => {setDraftState(value); sessionStorage.setItem('im-message-composer-draft', value);};
React.useEffect(() => {
if (mode !== 'media-uploading' || uploadProgress >= 100) return undefined;
const timer = window.setTimeout(() => setUploadProgress(value => Math.min(100, value + 5)), 350);
return () => window.clearTimeout(timer);
}, [mode, uploadProgress]);
if (view === 'mentions-select' || view === 'mentions-complete') return <MemberPicker complete={view === 'mentions-complete'} />;
if (view === 'upload-file') return <><UploadFilePage onToast={setToast} /><Toast message={toast} onDone={() => setToast('')} /></>;
if (['forward-select', 'forward-multiple', 'forward-individual', 'forward-bundle'].includes(view)) return <><ForwardPage mode={view} onToast={setToast} /><Toast message={toast} onDone={() => setToast('')} /></>;
const toggleSelected = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id));
const recording = mode === 'voice-hold' || mode === 'voice-recording';
const multiselect = mode === 'message-multiselect' || mode === 'favorite-success';
return <div className="chat-product-page message-composer-page">
<Header onMore={() => setToast('会话设置')} />
{multiselect ? <div className="compose-selection-bar"><button type="button" onClick={() => navigate('chat-standard')}><Icon>close</Icon></button><strong>已选择 {selected.length} 条</strong><button type="button" onClick={() => setSelected(selected.length ? [] : ['text', 'file', 'image'])}>{selected.length ? '取消全选' : '全选'}</button></div> : null}
<BaseTimeline mode={mode} uploadProgress={uploadProgress} selected={selected} toggleSelected={toggleSelected} />
{multiselect ? <nav className="compose-batch-actions"><button type="button" disabled={!selected.length} onClick={() => navigate('forward-select')}><Icon>forward</Icon><span>转发</span></button><button type="button" disabled={!selected.length} onClick={() => {setMode('favorite-success'); setToast(`已收藏 ${selected.length} 条消息`);}}><Icon>bookmark_border</Icon><span>收藏</span></button><button type="button" disabled={!selected.length} onClick={() => {setSelected([]); setToast('已删除所选消息');}}><Icon>delete_outline</Icon><span>删除</span></button></nav> : <Composer mode={mode} draft={draft} setDraft={setDraft} setMode={setMode} onSend={() => {if (!draft.trim()) return; setToast('消息已发送'); setDraft('');}} />}
{recording ? <VoiceOverlay cancel={false} /> : null}
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}