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>
233 lines
20 KiB
React
233 lines
20 KiB
React
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>;
|
||
}
|