Implements all 11 product chapters (auth, messaging, contacts, groups,
files/favorites, calls, workplace/reports, settings, security) against the
real /v1/im REST API and native Tinode WS protocol, replacing the hardcoded
mock data the ba8f48ee prototype commit shipped with. Adds src/lib/im-api.js
as the REST client, plus supporting fixes across messages-view.jsx and the
attachment/upload/download pipeline (wrong SDK auth headers, group topic
naming, seq/timezone edge cases) found via live end-to-end testing against
the real backend.
Backup checkpoint before reconciling with origin/dev's parallel rewrite.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
528 lines
34 KiB
React
528 lines
34 KiB
React
import React from 'react';
|
||
|
||
import { Drafty, Tinode } from 'tinode-sdk';
|
||
|
||
import AudioPlayer from '../widgets/audio-player.jsx';
|
||
|
||
import HashNavigation from '../lib/navigation.js';
|
||
import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
|
||
import {
|
||
getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader,
|
||
searchTopicMessages, getMessageContext, getTopicMedia, checkURL, getFileUrl, getGroup, getUserProfile
|
||
} from '../lib/im-api.js';
|
||
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
|
||
import { topicDisplayName } from '../lib/utils.js';
|
||
|
||
// favoritePayload's source.topic is the raw conversation topic id (grpXXX/usrXXX) -- resolve it to a
|
||
// display name the same way every other list in this app does, cached since many favorites usually
|
||
// share a handful of source conversations.
|
||
const sourceNameCache = new Map();
|
||
function resolveSourceName(topic, onDone) {
|
||
if (!topic) return;
|
||
if (sourceNameCache.has(topic)) { onDone(sourceNameCache.get(topic)); return; }
|
||
if (Tinode.isSelfTopicName(topic)) { sourceNameCache.set(topic, '已保存的消息'); onDone('已保存的消息'); return; }
|
||
const call = topic.startsWith('grp') ? getGroup(topic).then(g => g.name) : getUserProfile(topic).then(p => p.display_name);
|
||
call.then(name => { sourceNameCache.set(topic, name); onDone(name); }).catch(() => {});
|
||
}
|
||
|
||
// Generic multi-param navigate -- content-library-view.jsx's original helper was a bare
|
||
// `HashNavigation.navigateTo(view)`, replacing the whole hash and dropping any existing params on
|
||
// every internal tab/link click; nothing in this file could carry a topic/favoriteId through its own
|
||
// navigation even if a caller threaded one in from outside.
|
||
const navigate = (view, params) => {
|
||
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
|
||
Object.entries(params || {}).forEach(([key, value]) => {
|
||
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
|
||
});
|
||
HashNavigation.navigateTo(hash);
|
||
};
|
||
|
||
export const CONTENT_LIBRARY_VIEWS = [
|
||
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
|
||
'favorites-links', 'favorites-select', 'favorite-share', 'file-download',
|
||
'file-downloading', 'file-downloaded', 'history-search', 'history-files',
|
||
'history-links', 'history-images', 'history-videos'
|
||
];
|
||
|
||
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||
function Header({title, back = 'messages-home', backParams, right, onRight}) {
|
||
return <header className="library-header">
|
||
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backParams); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
|
||
<h1>{title}</h1>
|
||
{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}
|
||
</header>;
|
||
}
|
||
function Avatar({item}) { return <span className="library-avatar">{item && item.image ? <img src={item.image} alt="" /> : <Icon>{(item && item.icon) || 'person'}</Icon>}</span>; }
|
||
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 Search({value, onChange, placeholder = '搜索'}) { return <label className="library-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} /></label>; }
|
||
|
||
// -- Drafty extraction helpers: a favorite's snapshot.content (and a history item's flattened content
|
||
// fields) are literally the original message's own content, not a Ch.7-specific shape -- reuse the same
|
||
// Drafty API chat-message.jsx/context-menu.jsx already use for live messages instead of re-deriving it.
|
||
function draftyContentOf(content) {
|
||
return Drafty.isValid(content) ? content : null;
|
||
}
|
||
function firstAttachment(content) {
|
||
const drafty = draftyContentOf(content);
|
||
if (!drafty) return null;
|
||
let found = null;
|
||
Drafty.attachments(drafty, att => { if (!found) found = att; });
|
||
return found;
|
||
}
|
||
// Drafty.getDownloadUrl returns either an already-usable blob: URL (small inband attachment, .val) or
|
||
// the raw .ref relative path (out-of-band, needs an authenticated fetch) -- getFileUrl() takes a bare
|
||
// file_id and builds that path itself, so pull just the id back out of .ref rather than double it up.
|
||
function resolveAttachmentUrl(att) {
|
||
if (!att) return Promise.resolve(null);
|
||
const direct = Drafty.getDownloadUrl(att);
|
||
if (!direct) return Promise.resolve(null);
|
||
if (direct.startsWith('blob:') || direct.startsWith('data:')) return Promise.resolve(direct);
|
||
return getFileUrl(direct.slice(direct.lastIndexOf('/') + 1)).catch(() => null);
|
||
}
|
||
// searchTopicMessages/getTopicMedia results spread the original Drafty content's own fields directly
|
||
// onto each item (mediaMessagePayload/messageSearchPayload flatten `content`, they don't nest it) --
|
||
// reconstruct the Drafty shape from whatever ent/fmt/txt survived so the same attachment-extraction
|
||
// path works here as everywhere else, rather than guessing at flat field names that may not exist.
|
||
function attachmentFromFlat(item) {
|
||
if (item && (item.ent || item.fmt || item.txt !== undefined)) {
|
||
return firstAttachment({txt: item.txt, fmt: item.fmt, ent: item.ent});
|
||
}
|
||
return null;
|
||
}
|
||
function plainTextOf(content, fallbackText) {
|
||
if (typeof content === 'string') return content;
|
||
const drafty = draftyContentOf(content);
|
||
if (drafty) return Drafty.toPlainText(drafty);
|
||
return fallbackText || '';
|
||
}
|
||
// file_type -> {icon, color} matching Ch.6's group-file convention (a colored square + letter/icon).
|
||
const FILE_TYPE_META = {image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'], document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']};
|
||
function extFromName(name) {
|
||
const m = /\.([a-zA-Z0-9]+)$/.exec(name || '');
|
||
return m ? m[1].toUpperCase() : '';
|
||
}
|
||
|
||
function FileCard({name, size, mime, sender, removed, onOpen}) {
|
||
const ext = extFromName(name);
|
||
return <button type="button" className={'library-file-card' + (removed ? ' removed' : '')} onClick={removed ? undefined : onOpen}>
|
||
<span>{ext || (mime || '').split('/')[0].toUpperCase() || '?'}</span>
|
||
<div><strong>{name || '未命名文件'}</strong><small>{typeof size === 'number' ? bytesToHumanSize(size) : (size || '未知大小')}{sender ? ` · ${sender}` : ''}</small></div>
|
||
{removed ? <em>文件已失效</em> : <Icon>chevron_right</Icon>}
|
||
</button>;
|
||
}
|
||
|
||
// Real playback via the same widgets/audio-player.jsx used for live voice messages, rather than the
|
||
// mock's plain state-toggle with no actual <audio> element -- resolves its own blob URL like Avatar
|
||
// does, so callers don't need their own per-item URL-map bookkeeping just for voice favorites.
|
||
function VoiceCard({att, head}) {
|
||
const [src, setSrc] = React.useState(null);
|
||
React.useEffect(() => { resolveAttachmentUrl(att).then(setSrc); }, [att]);
|
||
const duration = (att && att.duration) || (head && head.duration) || 0;
|
||
return <div className="library-voice-card">
|
||
{src ? <AudioPlayer short src={src} duration={duration} preview={att && att.preview} /> : <Icon>hourglass_empty</Icon>}
|
||
</div>;
|
||
}
|
||
|
||
// No OG-metadata extraction exists anywhere in the backend (favoriteSnapshot/mediaMessagePayload just
|
||
// copy the raw message content -- no title/thumbnail/summary field is ever populated), matching what
|
||
// every one of the reference screenshots for both 我的收藏-链接 and 查找聊天记录-链接 actually show: a
|
||
// plain URL string, not a card. Rendering a rich card here would be inventing data that doesn't exist.
|
||
function LinkText({url, onOpen}) {
|
||
return <button type="button" className="library-link-text" onClick={() => onOpen && onOpen(url)}><Icon>link</Icon><span>{url}</span></button>;
|
||
}
|
||
|
||
function MediaThumb({url, video, duration, onOpen}) {
|
||
return <button type="button" className="library-media-thumb" onClick={onOpen}>
|
||
{url ? <img src={url} alt={video ? '视频缩略图' : '图片缩略图'} /> : <Icon>{video ? 'videocam' : 'image'}</Icon>}
|
||
{video ? <><Icon>play_circle</Icon>{duration ? <small>{secondsToTime(duration)}</small> : null}</> : null}
|
||
</button>;
|
||
}
|
||
|
||
function FavoriteItem({item, onOpen, selecting, selected, onSelect}) {
|
||
const att = firstAttachment(item.snapshot && item.snapshot.content);
|
||
const source = item.source_display || (item.source && item.source.topic) || '';
|
||
return <article className={'library-favorite-item' + (selecting ? ' selecting' : '')}>
|
||
{selecting ? <button type="button" className={'library-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择收藏"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
||
<div className="library-favorite-content">
|
||
{item.type === 'text' ? <p className="library-text-content">{plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}</p> : null}
|
||
{item.type === 'file' ? <FileCard name={att && att.name} size={att && Drafty.getEntitySize(att)} mime={att && att.mime} onOpen={onOpen} /> : null}
|
||
{item.type === 'voice' ? <VoiceCard att={att} head={item.snapshot && item.snapshot.head} /> : null}
|
||
{item.type === 'link' ? <LinkText url={(item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)} onOpen={onOpen} /> : null}
|
||
{item.type === 'image' || item.type === 'video' ? <MediaThumb url={item._thumbUrl} video={item.type === 'video'} onOpen={onOpen} /> : null}
|
||
<footer><span>来自 {source}</span><time>{shortDateFormat(new Date(item.collected_at))}</time></footer>
|
||
</div>
|
||
</article>;
|
||
}
|
||
|
||
const TYPE_ROUTE = {all: 'favorites-mixed', file: 'favorites-files', text: 'favorites-text', voice: 'favorites-voice', link: 'favorites-links'};
|
||
const TYPE_LABELS = [['all', '全部'], ['image', '图片'], ['video', '视频'], ['file', '文件'], ['voice', '语音'], ['text', '文本'], ['link', '链接']];
|
||
|
||
function Favorites({view, tinode, onToast}) {
|
||
const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : 'all';
|
||
const selecting = view === 'favorites-select';
|
||
const [type, setType] = React.useState(selecting ? 'file' : initialType);
|
||
const [query, setQuery] = React.useState('');
|
||
const [selected, setSelected] = React.useState([]);
|
||
// Seeded per-type -- one Favorites instance backs every favorites* tab/route (confirmed multiple
|
||
// entry points: personal-settings' "我的收藏" row, direct-message-view.jsx's attachment-panel
|
||
// "favorite" action), so an unparameterized key would flash a stale, wrong-type list under a
|
||
// freshly-switched tab. `query` is deliberately excluded -- it always resets to '' on a tab switch,
|
||
// so caching a search result under the same key would show it under a plain (non-search) revisit.
|
||
const [items, setItems] = React.useState(() => readCachedData('favorites:' + type) || null);
|
||
const [cursor, setCursor] = React.useState('');
|
||
const [hasMore, setHasMore] = React.useState(false);
|
||
const [fileUrls, setFileUrls] = React.useState({});
|
||
const [sourceNames, setSourceNames] = React.useState({});
|
||
const load = React.useCallback((append) => {
|
||
getFavorites({type, query, cursor: append ? cursor : ''}).then(data => {
|
||
setItems(prev => {
|
||
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
|
||
if (!query) writeCachedData('favorites:' + type, list);
|
||
return list;
|
||
});
|
||
setCursor(data.next_cursor || '');
|
||
setHasMore(!!data.next_cursor);
|
||
}).catch(err => onToast(err.message || '加载失败'));
|
||
}, [type, query, cursor]);
|
||
React.useEffect(() => {
|
||
setItems((!query && readCachedData('favorites:' + type)) || null);
|
||
load(false);
|
||
}, [type, query]);
|
||
// Image/video favorites need an authenticated blob URL, same as any other file_id-backed thumbnail.
|
||
React.useEffect(() => {
|
||
(items || []).forEach(item => {
|
||
if ((item.type !== 'image' && item.type !== 'video') || fileUrls[item.favorite_id]) return;
|
||
const att = firstAttachment(item.snapshot && item.snapshot.content);
|
||
resolveAttachmentUrl(att).then(url => { if (url) setFileUrls(v => ({...v, [item.favorite_id]: url})); });
|
||
});
|
||
}, [items]);
|
||
React.useEffect(() => {
|
||
(items || []).forEach(item => {
|
||
const topic = item.source && item.source.topic;
|
||
if (!topic || sourceNames[topic]) return;
|
||
resolveSourceName(topic, name => name && setSourceNames(v => ({...v, [topic]: name})));
|
||
});
|
||
}, [items]);
|
||
const toggle = id => setSelected(values => values.includes(id) ? values.filter(v => v !== id) : values.concat(id));
|
||
const deleteSelected = () => {
|
||
if (!window.confirm(`确定删除已选择的 ${selected.length} 项收藏吗?`)) return;
|
||
batchDeleteFavorites(selected).then(result => {
|
||
const okIds = new Set(((result && result.items) || []).filter(r => r.ok).map(r => r.favorite_id));
|
||
setItems(values => {
|
||
const next = values.filter(item => !okIds.has(item.favorite_id));
|
||
if (!query) writeCachedData('favorites:' + type, next);
|
||
return next;
|
||
});
|
||
setSelected(values => values.filter(id => !okIds.has(id)));
|
||
onToast(okIds.size === selected.length ? '已删除所选收藏' : `已删除 ${okIds.size}/${selected.length} 项,部分删除失败`);
|
||
}).catch(err => onToast(err.message || '删除失败'));
|
||
};
|
||
const openItem = item => {
|
||
if (item.type === 'file') { navigate('file-download', {favoriteId: item.favorite_id}); return; }
|
||
if (item.type === 'link') {
|
||
const url = (item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text);
|
||
checkURL(url, 'favorite').then(res => {
|
||
if (res.decision === 'allow') window.open(url, '_blank', 'noopener,noreferrer');
|
||
else onToast(res.decision === 'block' ? '该链接存在风险,已被拦截' : '该链接可能存在风险,请谨慎访问');
|
||
}).catch(() => onToast('链接安全校验失败'));
|
||
return;
|
||
}
|
||
onToast('暂不支持预览此类收藏');
|
||
};
|
||
const list = items || [];
|
||
return <div className="library-page">
|
||
<Header title={selecting ? `已选择 ${selected.length} 项` : '我的收藏'} back={selecting ? 'favorites-files' : 'messages-home'}
|
||
right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? 'favorites-files' : 'favorites-select')} />
|
||
<Search value={query} onChange={setQuery} placeholder="搜索收藏内容" />
|
||
<nav className="library-type-tabs">{TYPE_LABELS.map(([id, label]) => <button type="button" key={id} className={type === id ? 'active' : ''}
|
||
onClick={() => { setType(id); if (TYPE_ROUTE[id] && !selecting) navigate(TYPE_ROUTE[id]); }}>{label}</button>)}</nav>
|
||
<main className="library-favorite-list">
|
||
{items === null ? <p className="library-empty-hint">加载中…</p> :
|
||
list.length ? <>{list.map(item => <FavoriteItem key={item.favorite_id} item={{...item, _thumbUrl: fileUrls[item.favorite_id],
|
||
source_display: sourceNames[item.source && item.source.topic]}}
|
||
selecting={selecting} selected={selected.includes(item.favorite_id)} onSelect={() => toggle(item.favorite_id)}
|
||
onOpen={() => openItem(item)} />)}
|
||
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}</> :
|
||
<div className="library-empty"><Icon>bookmark_border</Icon><strong>暂无此类收藏</strong></div>}
|
||
</main>
|
||
{selecting ? <nav className="library-batch-bar">
|
||
<button type="button" disabled={!selected.length} onClick={() => navigate('favorite-share', {ids: selected.join(',')})}><Icon>share</Icon><span>转发</span></button>
|
||
<button type="button" disabled={!selected.length} onClick={deleteSelected}><Icon>delete_outline</Icon><span>删除</span></button>
|
||
</nav> : null}
|
||
</div>;
|
||
}
|
||
|
||
// Reuses the exact same real-contact-list source as Ch.4's batch-forward-dialog.jsx (the app's own
|
||
// already-subscribed chatList, filtered locally) rather than the pre-built but never-actually-wired
|
||
// getForwardTargets() -- that endpoint's field shape was never verified against a real caller anywhere
|
||
// in this codebase, while this pattern is proven live since Chapter 4.
|
||
function FavoriteShare({hashParams, myUserId, chatList, onToast}) {
|
||
const ids = (hashParams && hashParams.ids ? hashParams.ids.split(',') : []).filter(Boolean);
|
||
const [query, setQuery] = React.useState('');
|
||
const [selected, setSelected] = React.useState([]);
|
||
const [confirming, setConfirming] = React.useState(false);
|
||
const [comment, setComment] = React.useState('');
|
||
const [sending, setSending] = React.useState(false);
|
||
const [preview, setPreview] = React.useState(null);
|
||
React.useEffect(() => { if (ids[0]) getFavorite(ids[0]).then(setPreview).catch(() => {}); }, []);
|
||
const toggle = id => setSelected(values => values.includes(id) ? values.filter(v => v !== id) : values.concat(id).slice(0, 9));
|
||
const send = () => {
|
||
if (sending) return;
|
||
setSending(true);
|
||
forwardFavorites(ids, selected, comment.trim()).then(result => {
|
||
const items = (result && result.items) || [];
|
||
const failedCount = items.filter(r => r.status !== 'sent').length;
|
||
onToast(failedCount ? `${selected.length - failedCount}/${selected.length} 个会话分享成功` : `已分享给 ${selected.length} 个会话`);
|
||
window.setTimeout(() => navigate('favorites-files'), 700);
|
||
}).catch(err => onToast(err.message || '分享失败')).finally(() => setSending(false));
|
||
};
|
||
const previewAtt = preview && firstAttachment(preview.snapshot && preview.snapshot.content);
|
||
const contacts = (chatList || []).filter(c => c.acs && c.acs.isJoiner() && c.acs.isWriter() &&
|
||
(!query || topicDisplayName(c.name, c.public, c.name || '').toLowerCase().includes(query.toLowerCase())));
|
||
const nameOf = uid => { const t = contacts.find(c => c.name === uid); return (t && topicDisplayName(t.name, t.public, uid)) || uid; };
|
||
return <div className="library-page">
|
||
<Header title="分享" back="favorites-select" />
|
||
{!confirming ? <>
|
||
<Search value={query} onChange={setQuery} placeholder="搜索联系人或群聊" />
|
||
<p className="library-section-title">最近聊天</p>
|
||
<main className="library-target-list">{contacts.map(item => <button type="button" key={item.name} onClick={() => toggle(item.name)}>
|
||
<Avatar item={{}} /><span><strong>{topicDisplayName(item.name, item.public, item.name)}</strong></span>
|
||
<i className={'library-check' + (selected.includes(item.name) ? ' checked' : '')}><Icon>{selected.includes(item.name) ? 'check' : ''}</Icon></i>
|
||
</button>)}</main>
|
||
<footer className="library-next-bar"><span>已选择 {selected.length}/9</span><button type="button" disabled={!selected.length} onClick={() => setConfirming(true)}>确定</button></footer>
|
||
</> : <main className="library-share-confirm">
|
||
<h2>发送给</h2>
|
||
<div className="library-target-summary">{selected.map(uid => <span key={uid}><Avatar item={{}} /><small>{nameOf(uid)}</small></span>)}</div>
|
||
{preview ? <section>{previewAtt ? <FileCard name={previewAtt.name} size={Drafty.getEntitySize(previewAtt)} onOpen={() => {}} /> :
|
||
<p className="library-text-content">{plainTextOf(preview.snapshot && preview.snapshot.content, preview.snapshot && preview.snapshot.text)}</p>}
|
||
<p>收藏原件会继续保留,并在目标会话中生成一条新消息。</p></section> : null}
|
||
<textarea value={comment} onChange={event => setComment(event.target.value)} placeholder="给朋友留言" />
|
||
<button type="button" disabled={sending} onClick={send}>发送</button>
|
||
</main>}
|
||
</div>;
|
||
}
|
||
|
||
function DownloadPage({hashParams, tinode, onToast}) {
|
||
const [meta, setMeta] = React.useState(null);
|
||
// A failed fetch previously left this stuck on "加载中…" forever -- the toast fired once and faded,
|
||
// but `meta` never left null so the loading branch below never had anything else to show. Tracked
|
||
// separately from `meta` itself since a real "still loading" state also has `meta === null`.
|
||
const [loadError, setLoadError] = React.useState(false);
|
||
const [state, setState] = React.useState('ready');
|
||
const [progress, setProgress] = React.useState(0);
|
||
const downloaderRef = React.useRef(null);
|
||
const {favoriteId, topic, seq} = hashParams || {};
|
||
// Two distinct entry points share this route: Favorites (favoriteId set, Ch.7.1) and History's
|
||
// file tab (topic+seq set, Ch.7.12) -- "back" must return to whichever one actually opened this
|
||
// page, not unconditionally to favorites-files (which History never came from and has no topic
|
||
// param for anyway).
|
||
const backView = favoriteId ? 'favorites-files' : 'history-files';
|
||
const backParams = favoriteId ? undefined : {topic};
|
||
React.useEffect(() => {
|
||
const apply = content => {
|
||
const att = firstAttachment(content);
|
||
if (!att) { onToast('文件信息加载失败'); setLoadError(true); return; }
|
||
setMeta({name: att.name, mime: att.mime, size: Drafty.getEntitySize(att), url: Drafty.getDownloadUrl(att)});
|
||
};
|
||
if (favoriteId) {
|
||
getFavorite(favoriteId).then(fav => apply(fav.snapshot && fav.snapshot.content)).catch(err => { onToast(err.message || '加载失败'); setLoadError(true); });
|
||
} else if (topic && seq) {
|
||
getMessageContext(topic, seq, 0, 0).then(data => {
|
||
const item = (data.items || []).find(m => String(m.seq) === String(seq)) || data.items[0];
|
||
apply(item && item.content);
|
||
}).catch(err => { onToast(err.message || '加载失败'); setLoadError(true); });
|
||
}
|
||
}, [hashParams]);
|
||
const start = () => {
|
||
if (!meta || !tinode) return;
|
||
// A small inband attachment's "url" (from Drafty.getDownloadUrl) is already a ready-to-use
|
||
// blob: URL built from its inline base64 data -- there's nothing to fetch from the server, so
|
||
// getLargeFileHelper().download() would be the wrong tool (it requires a *relative* HTTP path and
|
||
// returns bare `undefined`, not a rejected promise, for anything else -- confirmed by triggering
|
||
// this exact case: chaining .then() off that return value threw "Cannot read properties of
|
||
// undefined"). Save the already-local blob directly instead.
|
||
if (meta.url && (meta.url.startsWith('blob:') || meta.url.startsWith('data:'))) {
|
||
const link = document.createElement('a');
|
||
link.href = meta.url; link.download = meta.name || 'file';
|
||
document.body.appendChild(link); link.click(); document.body.removeChild(link);
|
||
setState('done');
|
||
return;
|
||
}
|
||
// tinode.getLargeFileHelper().download() sends the same two wrong headers as its upload() sibling
|
||
// (X-Tinode-APIKey/X-Tinode-Auth, neither recognized by this backend) -- every real out-of-band
|
||
// download was silently failing. createFileDownloader() is a fixed-headers drop-in with the exact
|
||
// same download()/cancel() interface.
|
||
const downloader = createFileDownloader(tinode);
|
||
if (!downloader) { onToast('当前环境不支持下载'); return; }
|
||
downloaderRef.current = downloader;
|
||
setState('loading'); setProgress(0);
|
||
// download() resolves once the browser's own save dialog/native download has been triggered --
|
||
// there's no server-side download-completion record to poll (confirmed absent on the backend), so
|
||
// "downloaded" here just means the transfer finished, tracked only for this page's own lifetime.
|
||
const result = downloader.download(meta.url, meta.name, meta.mime,
|
||
loaded => setProgress(meta.size ? Math.min(100, Math.round(loaded / meta.size * 100)) : 0),
|
||
err => { setState('ready'); onToast((err && err.message) || '下载失败'); });
|
||
if (result && result.then) {
|
||
result.then(() => setState('done')).catch(() => {}); // onError above already surfaced the failure.
|
||
}
|
||
};
|
||
const cancel = () => { if (downloaderRef.current) downloaderRef.current.cancel(); setState('ready'); setProgress(0); };
|
||
if (!meta) return <div className="library-page"><Header title="文件" back={backView} backParams={backParams} /><main className="library-download">
|
||
<p className="library-empty-hint">{loadError ? '文件不存在或已被删除' : '加载中…'}</p>
|
||
</main></div>;
|
||
return <div className="library-page">
|
||
<Header title="文件" back={backView} backParams={backParams} />
|
||
<main className="library-download">
|
||
<span className="library-download-icon">{extFromName(meta.name) || '?'}</span>
|
||
<h2>{meta.name}</h2>
|
||
<p>该文件不支持在线预览</p>
|
||
{state === 'loading' ? <section className="library-download-progress">
|
||
<div><span>下载中...</span><strong>{progress}%</strong></div>
|
||
<progress value={progress} max="100" />
|
||
<small>{bytesToHumanSize(meta.size * progress / 100)} / {bytesToHumanSize(meta.size)}</small>
|
||
</section> : null}
|
||
{state === 'ready' ? <button type="button" className="library-primary" onClick={start}><Icon>download</Icon>立即下载({bytesToHumanSize(meta.size)})</button> :
|
||
state === 'loading' ? <div className="library-download-actions"><button type="button" onClick={cancel}><Icon>close</Icon>取消</button></div> :
|
||
<div className="library-download-actions"><button type="button" className="primary" onClick={() => onToast('已保存,可在浏览器下载列表中查看')}><Icon>open_in_new</Icon>用其他应用打开</button></div>}
|
||
</main>
|
||
</div>;
|
||
}
|
||
|
||
function Highlight({text, ranges}) {
|
||
if (!ranges || !ranges.length) return text;
|
||
const parts = []; let last = 0;
|
||
ranges.forEach(([start, len], i) => {
|
||
parts.push(text.slice(last, start), <mark key={i}>{text.slice(start, start + len)}</mark>);
|
||
last = start + len;
|
||
});
|
||
parts.push(text.slice(last));
|
||
return parts;
|
||
}
|
||
|
||
function HistoryNav({active}) {
|
||
const tabs = [['history-search', '聊天记录'], ['history-images', '图片'], ['history-videos', '视频'], ['history-files', '文件'], ['history-links', '链接']];
|
||
return <nav className="library-history-tabs">{tabs.map(item => <button type="button" key={item[0]} className={active === item[0] ? 'active' : ''} onClick={() => navigate(item[0])}>{item[1]}</button>)}</nav>;
|
||
}
|
||
|
||
const HISTORY_TITLES = {'history-search': '查找聊天记录', 'history-files': '文件', 'history-links': '链接', 'history-images': '图片', 'history-videos': '视频'};
|
||
const HISTORY_TYPE = {'history-search': 'all', 'history-files': 'file', 'history-links': 'link', 'history-images': 'image', 'history-videos': 'video'};
|
||
// Same bracket-label convention as message-center-view.jsx's draftyPreview() -- messageText() on the
|
||
// backend has no attachment-aware summary, so a non-text search hit's own "snippet" field is often just
|
||
// the raw serialized Drafty content; a fixed label per type reads far better than that.
|
||
const TYPE_LABEL = {image: '[图片]', video: '[视频]', voice: '[语音]', file: '[文件]', link: '[链接]'};
|
||
|
||
function History({view, hashParams, myUserId, onToast}) {
|
||
const topic = hashParams && hashParams.topic;
|
||
// Keyed by both topic and view/tab -- reached from one chat's own settings, and a user plausibly
|
||
// leaves to tweak a setting and comes back to keep browsing/searching the same conversation's
|
||
// records (or checks a different chat's history later the same session), so dropping either half
|
||
// of the key would bleed one chat's or one tab's data into another's.
|
||
const cacheKey = topic ? `history:${topic}:${view}` : null;
|
||
const [query, setQuery] = React.useState('');
|
||
const [items, setItems] = React.useState(() => (cacheKey && readCachedData(cacheKey)) || null);
|
||
const [cursor, setCursor] = React.useState('');
|
||
const [hasMore, setHasMore] = React.useState(false);
|
||
const [mediaUrls, setMediaUrls] = React.useState({});
|
||
const [senderNames, setSenderNames] = React.useState({});
|
||
const load = React.useCallback((append) => {
|
||
if (!topic) return;
|
||
const type = HISTORY_TYPE[view];
|
||
const call = view === 'history-search' ? searchTopicMessages(topic, {query, type, cursor: append ? cursor : ''}) :
|
||
getTopicMedia(topic, type, append ? cursor : '');
|
||
call.then(data => {
|
||
setItems(prev => {
|
||
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
|
||
if (!query && cacheKey) writeCachedData(cacheKey, list);
|
||
return list;
|
||
});
|
||
setCursor(data.next_cursor || ''); setHasMore(!!data.next_cursor);
|
||
}).catch(err => onToast(err.message || '加载失败'));
|
||
}, [topic, view, query, cursor]);
|
||
React.useEffect(() => {
|
||
setItems((!query && cacheKey && readCachedData(cacheKey)) || null);
|
||
load(false);
|
||
}, [topic, view, query]);
|
||
React.useEffect(() => {
|
||
if (view !== 'history-images' && view !== 'history-videos') return;
|
||
(items || []).forEach(item => {
|
||
if (mediaUrls[item.seq]) return;
|
||
resolveAttachmentUrl(attachmentFromFlat(item)).then(url => { if (url) setMediaUrls(v => ({...v, [item.seq]: url})); });
|
||
});
|
||
}, [items, view]);
|
||
// item.from (like snapshot.from on a favorite) is a bare uid with no "usr" prefix -- every other uid
|
||
// in this app (topic names, hash params, etc.) is fully-prefixed, so it has to be added back before
|
||
// this can be looked up like any other user id.
|
||
React.useEffect(() => {
|
||
(items || []).forEach(item => {
|
||
const from = item.from;
|
||
if (!from || senderNames[from] || from === myUserId || ('usr' + from) === myUserId) return;
|
||
getUserProfile(from.startsWith('usr') ? from : 'usr' + from).then(p => {
|
||
setSenderNames(v => ({...v, [from]: p.display_name}));
|
||
}).catch(() => {});
|
||
});
|
||
}, [items]);
|
||
// Mirrors tinode-web.jsx's own handleTopicSelected exactly (HashNavigation.setUrlTopic('', topicName)
|
||
// -- an empty base hash, not the current one, per its "Clear newTopicParams after use" comment) so
|
||
// this lands the same way a normal chat-list click does, then layers the jump target on as a param;
|
||
// navigate()'s own helper only ever touches path[0]+params, never path[1] (the actual topic slot).
|
||
const jump = seq => HashNavigation.navigateTo(HashNavigation.addUrlParam(HashNavigation.setUrlTopic('', topic), 'jumpseq', seq));
|
||
const nameOf = from => {
|
||
if (!from) return '';
|
||
if (from === myUserId || ('usr' + from) === myUserId) return '我';
|
||
return senderNames[from] || from;
|
||
};
|
||
const openLink = url => {
|
||
checkURL(url, 'message').then(res => {
|
||
if (res.decision === 'allow') window.open(url, '_blank', 'noopener,noreferrer');
|
||
else onToast(res.decision === 'block' ? '该链接存在风险,已被拦截' : '该链接可能存在风险,请谨慎访问');
|
||
}).catch(() => onToast('链接安全校验失败'));
|
||
};
|
||
const list = items || [];
|
||
return <div className="library-page">
|
||
{/* Reached from either a friend's chat settings (usrXXX topic, reads hashParams.uid) or a
|
||
group's (grpXXX topic, reads hashParams.topic) -- the topic's own prefix says which settings
|
||
page to return to and which param name it expects; passing the wrong one is the same
|
||
blank-page failure as History's other entry points had before this was a switch. */}
|
||
<Header title={HISTORY_TITLES[view]}
|
||
back={topic && topic.startsWith('grp') ? 'group-settings' : 'friend-settings'}
|
||
backParams={topic && topic.startsWith('grp') ? {topic} : {uid: topic}} />
|
||
{view === 'history-search' ? <Search value={query} onChange={setQuery} placeholder="搜索当前会话" /> : null}
|
||
<HistoryNav active={view} />
|
||
<main className="library-history-content">
|
||
{items === null ? <p className="library-empty-hint">加载中…</p> : !list.length ?
|
||
<div className="library-empty"><Icon>{view === 'history-search' ? 'search_off' : 'inbox'}</Icon><strong>暂无内容</strong></div> :
|
||
view === 'history-search' ? <section className="library-message-results">
|
||
{list.map(item => <button type="button" key={item.seq} onClick={() => jump(item.seq)}>
|
||
<Avatar item={{}} /><span><strong>{nameOf(item.from)}</strong><p>{item.type === 'text' ?
|
||
<Highlight text={item.snippet || ''} ranges={item.matches} /> : (TYPE_LABEL[item.type] || item.snippet || '')}</p>
|
||
<small>{shortDateFormat(new Date(item.ts))}</small></span><Icon>chevron_right</Icon>
|
||
</button>)}
|
||
</section> :
|
||
view === 'history-files' ? <section className="library-record-files">{list.map(item => {
|
||
const att = attachmentFromFlat(item);
|
||
return <React.Fragment key={item.seq}>
|
||
<div className="library-record-header"><strong>{nameOf(item.from)}</strong><small>{shortDateFormat(new Date(item.ts))}</small></div>
|
||
<FileCard name={att ? att.name : item.name} size={att ? Drafty.getEntitySize(att) : item.size} mime={att && att.mime}
|
||
removed={item.availability === 'removed'} onOpen={() => navigate('file-download', {topic, seq: item.seq})} />
|
||
</React.Fragment>;
|
||
})}</section> :
|
||
view === 'history-links' ? <section className="library-record-links">{list.map(item => <React.Fragment key={item.seq}>
|
||
<div className="library-record-header"><strong>{nameOf(item.from)}</strong><small>{shortDateFormat(new Date(item.ts))}</small></div>
|
||
<LinkText url={item.url || plainTextOf(item.txt !== undefined ? {txt: item.txt, fmt: item.fmt, ent: item.ent} : undefined, item.text)} onOpen={openLink} />
|
||
</React.Fragment>)}</section> :
|
||
<section className="library-media-grid">{list.map(item => <MediaThumb key={item.seq} url={mediaUrls[item.seq]} video={view === 'history-videos'}
|
||
duration={item.duration} onOpen={() => jump(item.seq)} />)}</section>}
|
||
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}
|
||
</main>
|
||
</div>;
|
||
}
|
||
|
||
export default function ContentLibraryView({view, hashParams, tinode, myUserId, onTopicSelected, chatList}) {
|
||
const [toast, setToast] = React.useState(''); let content;
|
||
if (view.startsWith('favorites')) content = <Favorites view={view} tinode={tinode} onToast={setToast} />;
|
||
else if (view === 'favorite-share') content = <FavoriteShare hashParams={hashParams} myUserId={myUserId} chatList={chatList} onToast={setToast} />;
|
||
else if (view.startsWith('file-download')) content = <DownloadPage hashParams={hashParams} tinode={tinode} onToast={setToast} />;
|
||
else content = <History view={view} hashParams={hashParams} myUserId={myUserId} onToast={setToast} />;
|
||
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||
}
|