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 ; } function Header({title, back = 'messages-home', backParams, right, onRight}) { return

{title}

{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 ; } // -- 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 ; } // 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