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 {children} ; }
function Header({title, back = 'messages-home', backParams, right, onRight}) {
return
{ if (!HashNavigation.goBack()) navigate(back, backParams); }} aria-label="返回">arrow_back_ios_new
{title}
{right ? {right} : }
;
}
function Avatar({item}) { return {item && item.image ? : {(item && item.icon) || 'person'} } ; }
function Toast({message, onDone}) { React.useEffect(() => { if (!message) return undefined; const timer = window.setTimeout(onDone, 2000); return () => window.clearTimeout(timer); }, [message, onDone]); return message ?
{message}
: null; }
function Search({value, onChange, placeholder = '搜索'}) { return search onChange(event.target.value)} placeholder={placeholder} /> ; }
// -- 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
{ext || (mime || '').split('/')[0].toUpperCase() || '?'}
{name || '未命名文件'} {typeof size === 'number' ? bytesToHumanSize(size) : (size || '未知大小')}{sender ? ` · ${sender}` : ''}
{removed ? 文件已失效 : chevron_right }
;
}
// 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 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
{src ?
:
hourglass_empty }
;
}
// 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 onOpen && onOpen(url)}>link {url} ;
}
function MediaThumb({url, video, duration, onOpen}) {
return
{url ? : {video ? 'videocam' : 'image'} }
{video ? <>play_circle {duration ? {secondsToTime(duration)} : null}> : null}
;
}
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
{selecting ? {selected ? 'check' : ''} : null}
{item.type === 'text' ?
{plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}
: null}
{item.type === 'file' ?
: null}
{item.type === 'voice' ?
: null}
{item.type === 'link' ?
: null}
{item.type === 'image' || item.type === 'video' ?
: null}
来自 {source} {shortDateFormat(new Date(item.collected_at))}
;
}
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
navigate(selecting ? 'favorites-files' : 'favorites-select')} />
{TYPE_LABELS.map(([id, label]) => { setType(id); if (TYPE_ROUTE[id] && !selecting) navigate(TYPE_ROUTE[id]); }}>{label} )}
{items === null ? 加载中…
:
list.length ? <>{list.map(item => toggle(item.favorite_id)}
onOpen={() => openItem(item)} />)}
{hasMore ? load(true)}>加载更多 : null}> :
bookmark_border 暂无此类收藏
}
{selecting ?
navigate('favorite-share', {ids: selected.join(',')})}>share 转发
delete_outline 删除
: null}
;
}
// 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
{!confirming ? <>
最近聊天
{contacts.map(item => toggle(item.name)}>
{topicDisplayName(item.name, item.public, item.name)}
{selected.includes(item.name) ? 'check' : ''}
)}
已选择 {selected.length}/9 setConfirming(true)}>确定
> :
发送给
{selected.map(uid =>
{nameOf(uid)} )}
{preview ? {previewAtt ? {}} /> :
{plainTextOf(preview.snapshot && preview.snapshot.content, preview.snapshot && preview.snapshot.text)}
}
收藏原件会继续保留,并在目标会话中生成一条新消息。
: null}
}
;
}
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
{loadError ? '文件不存在或已被删除' : '加载中…'}
;
return
{extFromName(meta.name) || '?'}
{meta.name}
该文件不支持在线预览
{state === 'loading' ?
下载中... {progress}%
{bytesToHumanSize(meta.size * progress / 100)} / {bytesToHumanSize(meta.size)}
: null}
{state === 'ready' ? download 立即下载({bytesToHumanSize(meta.size)}) :
state === 'loading' ? close 取消
:
onToast('已保存,可在浏览器下载列表中查看')}>open_in_new 用其他应用打开
}
;
}
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), {text.slice(start, start + len)} );
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 {tabs.map(item => navigate(item[0])}>{item[1]} )} ;
}
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
{/* 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. */}
{view === 'history-search' ?
: null}
{items === null ? 加载中…
: !list.length ?
{view === 'history-search' ? 'search_off' : 'inbox'} 暂无内容
:
view === 'history-search' ?
{list.map(item => jump(item.seq)}>
{nameOf(item.from)} {item.type === 'text' ?
: (TYPE_LABEL[item.type] || item.snippet || '')}
{shortDateFormat(new Date(item.ts))} chevron_right
)}
:
view === 'history-files' ? {list.map(item => {
const att = attachmentFromFlat(item);
return
{nameOf(item.from)} {shortDateFormat(new Date(item.ts))}
navigate('file-download', {topic, seq: item.seq})} />
;
})} :
view === 'history-links' ? {list.map(item =>
{nameOf(item.from)} {shortDateFormat(new Date(item.ts))}
)} :
{list.map(item => jump(item.seq)} />)} }
{hasMore ? load(true)}>加载更多 : null}
;
}
export default function ContentLibraryView({view, hashParams, tinode, myUserId, onTopicSelected, chatList}) {
const [toast, setToast] = React.useState(''); let content;
if (view.startsWith('favorites')) content = ;
else if (view === 'favorite-share') content = ;
else if (view.startsWith('file-download')) content = ;
else content = ;
return <>{content} setToast('')} />>;
}