diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 7af4914..b965cd8 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -2,6 +2,7 @@ import * as path from 'path' import * as http from 'http' import * as https from 'https' +import crypto from 'crypto' import { fileURLToPath } from 'url' import ExcelJS from 'exceljs' import { getEmojiPath } from 'wechat-emojis' @@ -215,6 +216,8 @@ class ExportService { private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000 private readonly exportStatsCacheMaxEntries = 16 private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED' + private mediaFileCachePopulatePending = new Map>() + private mediaFileCacheReadyDirs = new Set() constructor() { this.configService = new ConfigService() @@ -449,6 +452,109 @@ class ExportService { } } + private getMediaFileCacheRoot(): string { + return path.join(this.configService.getCacheBasePath(), 'export-media-files') + } + + private async ensureMediaFileCacheDir(dirPath: string): Promise { + if (this.mediaFileCacheReadyDirs.has(dirPath)) return + await fs.promises.mkdir(dirPath, { recursive: true }) + this.mediaFileCacheReadyDirs.add(dirPath) + } + + private async getMediaFileStat(sourcePath: string): Promise<{ size: number; mtimeMs: number } | null> { + try { + const stat = await fs.promises.stat(sourcePath) + if (!stat.isFile()) return null + return { + size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0, + mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0 + } + } catch { + return null + } + } + + private buildMediaFileCachePath( + kind: 'image' | 'video' | 'emoji', + sourcePath: string, + fileStat: { size: number; mtimeMs: number } + ): string { + const normalizedSource = path.resolve(sourcePath) + const rawKey = `${kind}\u001f${normalizedSource}\u001f${fileStat.size}\u001f${fileStat.mtimeMs}` + const digest = crypto.createHash('sha1').update(rawKey).digest('hex') + const ext = path.extname(normalizedSource) || '' + return path.join(this.getMediaFileCacheRoot(), kind, digest.slice(0, 2), `${digest}${ext}`) + } + + private async resolveMediaFileCachePath( + kind: 'image' | 'video' | 'emoji', + sourcePath: string + ): Promise<{ cachePath: string; fileStat: { size: number; mtimeMs: number } } | null> { + const fileStat = await this.getMediaFileStat(sourcePath) + if (!fileStat) return null + const cachePath = this.buildMediaFileCachePath(kind, sourcePath, fileStat) + return { cachePath, fileStat } + } + + private async populateMediaFileCache( + kind: 'image' | 'video' | 'emoji', + sourcePath: string + ): Promise { + const resolved = await this.resolveMediaFileCachePath(kind, sourcePath) + if (!resolved) return null + const { cachePath } = resolved + if (await this.pathExists(cachePath)) return cachePath + + const pending = this.mediaFileCachePopulatePending.get(cachePath) + if (pending) return pending + + const task = (async () => { + try { + await this.ensureMediaFileCacheDir(path.dirname(cachePath)) + if (await this.pathExists(cachePath)) return cachePath + + const tempPath = `${cachePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` + const copied = await this.copyFileOptimized(sourcePath, tempPath) + if (!copied.success) { + await fs.promises.rm(tempPath, { force: true }).catch(() => { }) + return null + } + await fs.promises.rename(tempPath, cachePath).catch(async (error) => { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code === 'EEXIST') { + await fs.promises.rm(tempPath, { force: true }).catch(() => { }) + return + } + await fs.promises.rm(tempPath, { force: true }).catch(() => { }) + throw error + }) + return cachePath + } catch { + return null + } finally { + this.mediaFileCachePopulatePending.delete(cachePath) + } + })() + + this.mediaFileCachePopulatePending.set(cachePath, task) + return task + } + + private async resolvePreferredMediaSource( + kind: 'image' | 'video' | 'emoji', + sourcePath: string + ): Promise { + const resolved = await this.resolveMediaFileCachePath(kind, sourcePath) + if (!resolved) return sourcePath + if (await this.pathExists(resolved.cachePath)) { + return resolved.cachePath + } + // 未命中缓存时异步回填,不阻塞当前导出路径 + void this.populateMediaFileCache(kind, sourcePath) + return sourcePath + } + private isMediaExportEnabled(options: ExportOptions): boolean { return options.exportMedia === true && Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) @@ -2418,7 +2524,8 @@ class ExportService { const ext = path.extname(sourcePath) || '.jpg' const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) - const copied = await this.copyFileOptimized(sourcePath, destPath) + const preferredSource = await this.resolvePreferredMediaSource('image', sourcePath) + const copied = await this.copyFileOptimized(preferredSource, destPath) if (!copied.success) { if (copied.code === 'ENOENT') { console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`) @@ -2639,7 +2746,8 @@ class ExportService { const key = msg.emojiMd5 || String(msg.localId) const fileName = `${key}${ext}` const destPath = path.join(emojisDir, fileName) - const copied = await this.copyFileOptimized(localPath, destPath) + const preferredSource = await this.resolvePreferredMediaSource('emoji', localPath) + const copied = await this.copyFileOptimized(preferredSource, destPath) if (!copied.success) return null return { @@ -2681,7 +2789,8 @@ class ExportService { const fileName = path.basename(sourcePath) const destPath = path.join(videosDir, fileName) - const copied = await this.copyFileOptimized(sourcePath, destPath) + const preferredSource = await this.resolvePreferredMediaSource('video', sourcePath) + const copied = await this.copyFileOptimized(preferredSource, destPath) if (!copied.success) return null return { diff --git a/src/components/JumpToDatePopover.scss b/src/components/JumpToDatePopover.scss index bceda25..f9839a6 100644 --- a/src/components/JumpToDatePopover.scss +++ b/src/components/JumpToDatePopover.scss @@ -137,18 +137,22 @@ margin-top: 1px; font-size: 13px; line-height: 1; - color: #16a34a; + color: var(--primary, #07c160); font-weight: 700; } .jump-date-popover .day-cell.selected .day-count { - color: #86efac; + color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%); } .jump-date-popover .day-count-loading { position: static; margin-top: 1px; - color: #22c55e; + color: var(--primary, #07c160); +} + +.jump-date-popover .day-cell.selected .day-count-loading { + color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%); } .jump-date-popover .spin { diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 7d16fa4..5e439c0 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1841,9 +1841,9 @@ // 回到底部按钮 .scroll-to-bottom { - position: sticky; + position: absolute; bottom: 20px; - align-self: center; + left: 50%; padding: 8px 16px; border-radius: 20px; background: var(--bg-secondary); @@ -1858,13 +1858,13 @@ font-size: 13px; z-index: 10; opacity: 0; - transform: translateY(20px); + transform: translate(-50%, 20px); pointer-events: none; transition: all 0.3s ease; &.show { opacity: 1; - transform: translateY(0); + transform: translate(-50%, 0); pointer-events: auto; } @@ -2069,6 +2069,10 @@ object-fit: contain; } +.emoji-message-wrapper { + display: inline-block; +} + .emoji-loading { width: 90px; height: 90px; @@ -3660,11 +3664,11 @@ // 批量转写按钮 .batch-transcribe-btn { &:hover:not(:disabled) { - color: var(--primary-color); + color: var(--primary); } &.transcribing { - color: var(--primary-color); + color: var(--primary); cursor: pointer; opacity: 1 !important; } @@ -3688,7 +3692,7 @@ border-bottom: 1px solid var(--border-color); svg { - color: var(--primary-color); + color: var(--primary); } h3 { @@ -3726,7 +3730,7 @@ .batch-dates-btn { padding: 0.35rem 0.75rem; font-size: 12px; - color: var(--primary-color); + color: var(--primary); background: transparent; border: 1px solid var(--border-color); border-radius: 6px; @@ -3735,7 +3739,7 @@ &:hover { background: var(--bg-hover); - border-color: var(--primary-color); + border-color: var(--primary); } } } @@ -3768,9 +3772,14 @@ } input[type="checkbox"] { - accent-color: var(--primary-color); + accent-color: var(--primary); cursor: pointer; flex-shrink: 0; + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 45%, transparent); + outline-offset: 1px; + } } .batch-date-label { @@ -3813,7 +3822,7 @@ .value { font-size: 14px; font-weight: 600; - color: var(--primary-color); + color: var(--primary); } .batch-concurrency-field { @@ -3939,7 +3948,7 @@ &.btn-primary, &.batch-transcribe-start-btn { - background: var(--primary-color); + background: var(--primary); color: #000; &:hover { diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index d8fd2b1..19363e4 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -190,6 +190,21 @@ function normalizeSearchAvatarUrl(value?: string | null): string | undefined { return normalized } +function resolveSessionDisplayName( + displayName?: string | null, + sessionId?: string | null +): string | undefined { + const normalizedSessionId = String(sessionId || '').trim() + const normalizedDisplayName = normalizeSearchIdentityText(displayName) + if (!normalizedDisplayName) return undefined + if (normalizedSessionId && normalizedDisplayName === normalizedSessionId) return undefined + return normalizedDisplayName +} + +function isFoldPlaceholderSession(sessionId?: string | null): boolean { + return String(sessionId || '').toLowerCase().includes('placeholder_foldgroup') +} + function isWxidLikeSearchIdentity(value?: string | null): boolean { const normalized = String(value || '').trim().toLowerCase() if (!normalized) return false @@ -398,6 +413,8 @@ const CHAT_SESSION_WINDOW_CACHE_TTL_MS = 12 * 60 * 60 * 1000 const CHAT_SESSION_WINDOW_CACHE_MAX_SESSIONS = 30 const CHAT_SESSION_WINDOW_CACHE_MAX_MESSAGES = 300 const GROUP_MEMBERS_PANEL_CACHE_TTL_MS = 10 * 60 * 1000 +const SESSION_CONTACT_PROFILE_RETRY_INTERVAL_MS = 15 * 1000 +const SESSION_CONTACT_PROFILE_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000 function buildChatSessionListCacheKey(scope: string): string { return `weflow.chat.sessions.v1::${scope || 'default'}` @@ -507,6 +524,13 @@ interface SessionExportCacheMeta { source: 'memory' | 'disk' | 'fresh' } +interface SessionContactProfile { + displayName?: string + avatarUrl?: string + alias?: string + updatedAt: number +} + type GroupMessageCountStatus = 'loading' | 'ready' | 'failed' interface GroupPanelMember { @@ -995,11 +1019,17 @@ function ChatPage(props: ChatPageProps) { const enrichCancelledRef = useRef(false) const isScrollingRef = useRef(false) const sessionScrollTimeoutRef = useRef(null) + const pendingSessionContactEnrichRef = useRef>(new Set()) + const sessionContactEnrichAttemptAtRef = useRef>(new Map()) + const sessionContactProfileCacheRef = useRef>(new Map()) const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) const messageKeySetRef = useRef>(new Set()) const lastMessageTimeRef = useRef(0) + const isMessageListAtBottomRef = useRef(true) + const lastObservedMessageCountRef = useRef(0) + const lastVisibleSenderWarmupAtRef = useRef(0) const sessionMapRef = useRef>(new Map()) const sessionsRef = useRef([]) const currentSessionRef = useRef(null) @@ -1044,6 +1074,51 @@ function ChatPage(props: ChatPageProps) { return username.includes('@chatroom') }, []) + const mergeSessionContactPresentation = useCallback((session: ChatSession, previousSession?: ChatSession): ChatSession => { + const username = String(session.username || '').trim() + if (!username || isFoldPlaceholderSession(username)) { + return session + } + + const now = Date.now() + const cacheMap = sessionContactProfileCacheRef.current + const cachedProfile = cacheMap.get(username) + if (cachedProfile && now - cachedProfile.updatedAt > SESSION_CONTACT_PROFILE_CACHE_TTL_MS) { + cacheMap.delete(username) + } + const profile = cacheMap.get(username) + + const sessionDisplayName = resolveSessionDisplayName(session.displayName, username) + const previousDisplayName = resolveSessionDisplayName(previousSession?.displayName, username) + const profileDisplayName = resolveSessionDisplayName(profile?.displayName, username) + const resolvedDisplayName = sessionDisplayName || previousDisplayName || profileDisplayName || session.displayName || username + + const sessionAvatarUrl = normalizeSearchAvatarUrl(session.avatarUrl) + const previousAvatarUrl = normalizeSearchAvatarUrl(previousSession?.avatarUrl) + const profileAvatarUrl = normalizeSearchAvatarUrl(profile?.avatarUrl) + const resolvedAvatarUrl = sessionAvatarUrl || previousAvatarUrl || profileAvatarUrl + + const sessionAlias = normalizeSearchIdentityText(session.alias) + const previousAlias = normalizeSearchIdentityText(previousSession?.alias) + const profileAlias = normalizeSearchIdentityText(profile?.alias) + const resolvedAlias = sessionAlias || previousAlias || profileAlias + + if ( + resolvedDisplayName === session.displayName && + resolvedAvatarUrl === session.avatarUrl && + resolvedAlias === session.alias + ) { + return session + } + + return { + ...session, + displayName: resolvedDisplayName, + avatarUrl: resolvedAvatarUrl, + alias: resolvedAlias + } + }, []) + const clearExportPrepareState = useCallback(() => { pendingExportRequestIdRef.current = null setIsPreparingExportDialog(false) @@ -2277,6 +2352,9 @@ function ChatPage(props: ChatPageProps) { const handleAccountChanged = useCallback(async () => { senderAvatarCache.clear() senderAvatarLoading.clear() + sessionContactProfileCacheRef.current.clear() + pendingSessionContactEnrichRef.current.clear() + sessionContactEnrichAttemptAtRef.current.clear() preloadImageKeysRef.current.clear() lastPreloadSessionRef.current = null pendingSessionLoadRef.current = null @@ -2365,6 +2443,7 @@ function ChatPage(props: ChatPageProps) { // 同步 currentSessionId 到 ref useEffect(() => { currentSessionRef.current = currentSessionId + isMessageListAtBottomRef.current = true topRangeLoadLockRef.current = false bottomRangeLoadLockRef.current = false setShowScrollToBottom(false) @@ -2423,11 +2502,9 @@ function ChatPage(props: ChatPageProps) { if (result.success && result.sessions) { // 确保 sessions 是数组 const sessionsArray = Array.isArray(result.sessions) ? result.sessions : [] - const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray + const nextSessions = mergeSessions(sessionsArray) // 确保 nextSessions 也是数组 if (Array.isArray(nextSessions)) { - - setSessions(nextSessions) sessionsRef.current = nextSessions persistSessionListCache(scope, nextSessions) @@ -2436,11 +2513,12 @@ function ChatPage(props: ChatPageProps) { void enrichSessionsContactInfo(nextSessions) } else { console.error('mergeSessions returned non-array:', nextSessions) - setSessions(sessionsArray) - sessionsRef.current = sessionsArray - persistSessionListCache(scope, sessionsArray) - void hydrateSessionStatuses(sessionsArray) - void enrichSessionsContactInfo(sessionsArray) + const fallbackSessions = sessionsArray.map((session) => mergeSessionContactPresentation(session)) + setSessions(fallbackSessions) + sessionsRef.current = fallbackSessions + persistSessionListCache(scope, fallbackSessions) + void hydrateSessionStatuses(fallbackSessions) + void enrichSessionsContactInfo(fallbackSessions) } } else if (!result.success) { setConnectionError(result.error || '获取会话失败') @@ -2457,99 +2535,102 @@ function ChatPage(props: ChatPageProps) { } } - // 分批异步加载联系人信息(优化性能:防止重复加载,滚动时暂停,只在空闲时加载) + // 分批异步加载联系人信息(优化:缓存优先 + 可持续队列 + 首屏优先批次) const enrichSessionsContactInfo = async (sessions: ChatSession[]) => { - if (sessions.length === 0) return + if (Array.isArray(sessions) && sessions.length > 0) { + const now = Date.now() + for (const session of sessions) { + const username = String(session.username || '').trim() + if (!username || isFoldPlaceholderSession(username)) continue - // 防止重复加载 - if (isEnrichingRef.current) { + const profileCache = sessionContactProfileCacheRef.current + const cachedProfile = profileCache.get(username) + if (cachedProfile && now - cachedProfile.updatedAt > SESSION_CONTACT_PROFILE_CACHE_TTL_MS) { + profileCache.delete(username) + } - return + const hasAvatar = Boolean(normalizeSearchAvatarUrl(session.avatarUrl)) + const hasDisplayName = Boolean(resolveSessionDisplayName(session.displayName, username)) + if (hasAvatar && hasDisplayName) continue + + const profile = profileCache.get(username) + const profileHasAvatar = Boolean(normalizeSearchAvatarUrl(profile?.avatarUrl)) + const profileHasDisplayName = Boolean(resolveSessionDisplayName(profile?.displayName, username)) + if (profileHasAvatar && profileHasDisplayName) continue + + const lastAttemptAt = sessionContactEnrichAttemptAtRef.current.get(username) || 0 + if (now - lastAttemptAt < SESSION_CONTACT_PROFILE_RETRY_INTERVAL_MS) continue + + pendingSessionContactEnrichRef.current.add(username) + } } + if (pendingSessionContactEnrichRef.current.size === 0) return + if (isEnrichingRef.current) return + isEnrichingRef.current = true enrichCancelledRef.current = false - - const totalStart = performance.now() - - // 移除初始 500ms 延迟,让后台加载与 UI 渲染并行 - - // 检查是否被取消 - if (enrichCancelledRef.current) { - isEnrichingRef.current = false - return - } + const batchSize = 8 + let processedBatchCount = 0 try { - // 找出需要加载联系人信息的会话(没有头像或者没有显示名称的) - const needEnrich = sessions.filter(s => !s.avatarUrl || !s.displayName || s.displayName === s.username) - if (needEnrich.length === 0) { - - isEnrichingRef.current = false - return - } - - - - // 批量补齐联系人,平衡吞吐和 UI 流畅性 - const batchSize = 8 - let loadedCount = 0 - - for (let i = 0; i < needEnrich.length; i += batchSize) { - // 如果正在滚动,暂停加载 + while (!enrichCancelledRef.current && pendingSessionContactEnrichRef.current.size > 0) { if (isScrollingRef.current) { - - // 等待滚动结束 while (isScrollingRef.current && !enrichCancelledRef.current) { await new Promise(resolve => setTimeout(resolve, 120)) } - if (enrichCancelledRef.current) break } - - // 检查是否被取消 if (enrichCancelledRef.current) break + const usernames = Array.from(pendingSessionContactEnrichRef.current).slice(0, batchSize) + if (usernames.length === 0) break + usernames.forEach((username) => pendingSessionContactEnrichRef.current.delete(username)) + + const attemptAt = Date.now() + usernames.forEach((username) => sessionContactEnrichAttemptAtRef.current.set(username, attemptAt)) + const batchStart = performance.now() - const batch = needEnrich.slice(i, i + batchSize) - const usernames = batch.map(s => s.username) + const shouldRunImmediately = processedBatchCount < 2 + if (shouldRunImmediately) { + await loadContactInfoBatch(usernames) + } else { + await new Promise((resolve) => { + if ('requestIdleCallback' in window) { + window.requestIdleCallback(() => { + void loadContactInfoBatch(usernames).finally(resolve) + }, { timeout: 700 }) + } else { + setTimeout(() => { + void loadContactInfoBatch(usernames).finally(resolve) + }, 80) + } + }) + } + processedBatchCount += 1 - // 使用 requestIdleCallback 延迟执行,避免阻塞UI - await new Promise((resolve) => { - if ('requestIdleCallback' in window) { - window.requestIdleCallback(() => { - void loadContactInfoBatch(usernames).then(() => resolve()) - }, { timeout: 700 }) - } else { - setTimeout(() => { - void loadContactInfoBatch(usernames).then(() => resolve()) - }, 80) - } - }) - - loadedCount += batch.length const batchTime = performance.now() - batchStart if (batchTime > 200) { - console.warn(`[性能监控] 批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(needEnrich.length / batchSize)} 耗时: ${batchTime.toFixed(2)}ms (已加载: ${loadedCount}/${needEnrich.length})`) + console.warn(`[性能监控] 联系人批次 ${processedBatchCount} 耗时: ${batchTime.toFixed(2)}ms, batch=${usernames.length}`) } - // 批次间延迟,给UI更多时间(DLL调用可能阻塞,需要更长的延迟) - if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) { - const delay = isScrollingRef.current ? 260 : 120 + if (!enrichCancelledRef.current && pendingSessionContactEnrichRef.current.size > 0) { + const delay = isScrollingRef.current ? 220 : 90 await new Promise(resolve => setTimeout(resolve, delay)) } } const totalTime = performance.now() - totalStart - if (!enrichCancelledRef.current) { - - } else { - + if (totalTime > 500) { + console.info(`[性能监控] 联系人补齐总耗时: ${totalTime.toFixed(2)}ms`) } } catch (e) { console.error('加载联系人信息失败:', e) } finally { isEnrichingRef.current = false + if (!enrichCancelledRef.current && pendingSessionContactEnrichRef.current.size > 0) { + void enrichSessionsContactInfo([]) + } } } @@ -2605,6 +2686,7 @@ function ChatPage(props: ChatPageProps) { if (hasChanges) { const updateStart = performance.now() setSessions(updatedSessions) + sessionsRef.current = updatedSessions lastUpdateTimeRef.current = Date.now() const updateTime = performance.now() - updateStart if (updateTime > 50) { @@ -2644,18 +2726,34 @@ function ChatPage(props: ChatPageProps) { // 将更新加入队列,用于侧边栏更新 const contacts = result.contacts || {} for (const [username, contact] of Object.entries(contacts)) { - contactUpdateQueueRef.current.set(username, contact) + const normalizedDisplayName = resolveSessionDisplayName(contact.displayName, username) || contact.displayName + const normalizedAvatarUrl = normalizeSearchAvatarUrl(contact.avatarUrl) + const normalizedAlias = normalizeSearchIdentityText(contact.alias) + contactUpdateQueueRef.current.set(username, { + displayName: normalizedDisplayName, + avatarUrl: normalizedAvatarUrl, + alias: normalizedAlias + }) + + if (normalizedDisplayName || normalizedAvatarUrl || normalizedAlias) { + sessionContactProfileCacheRef.current.set(username, { + displayName: normalizedDisplayName, + avatarUrl: normalizedAvatarUrl, + alias: normalizedAlias, + updatedAt: Date.now() + }) + } // 如果是自己的信息且当前个人头像为空,同步更新 - if (myWxid && username === myWxid && contact.avatarUrl && !myAvatarUrl) { + if (myWxid && username === myWxid && normalizedAvatarUrl && !myAvatarUrl) { - setMyAvatarUrl(contact.avatarUrl) + setMyAvatarUrl(normalizedAvatarUrl) } // 【核心优化】同步更新全局发送者头像缓存,供 MessageBubble 使用 senderAvatarCache.set(username, { - avatarUrl: contact.avatarUrl, - displayName: contact.displayName + avatarUrl: normalizedAvatarUrl, + displayName: normalizedDisplayName }) } // 触发批量更新 @@ -3919,11 +4017,28 @@ function ChatPage(props: ChatPageProps) { const handleMessageRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { visibleMessageRangeRef.current = range const total = messages.length + const shouldWarmupVisibleGroupSenders = Boolean( + currentSessionId && ( + isGroupChatSession(currentSessionId) || + ( + standaloneSessionWindow && + normalizedInitialSessionId && + currentSessionId === normalizedInitialSessionId && + normalizedStandaloneInitialContactType === 'group' + ) + ) + ) if (total <= 0) { + isMessageListAtBottomRef.current = true setShowScrollToBottom(prev => (prev ? false : prev)) return } + if (range.endIndex >= Math.max(total - 2, 0)) { + isMessageListAtBottomRef.current = true + setShowScrollToBottom(prev => (prev ? false : prev)) + } + if ( range.startIndex <= 2 && !topRangeLoadLockRef.current && @@ -3947,6 +4062,29 @@ function ChatPage(props: ChatPageProps) { bottomRangeLoadLockRef.current = true void loadLaterMessages() } + + if (shouldWarmupVisibleGroupSenders) { + const now = Date.now() + if (now - lastVisibleSenderWarmupAtRef.current >= 180) { + lastVisibleSenderWarmupAtRef.current = now + const latestMessages = useChatStore.getState().messages || [] + const visibleStart = Math.max(range.startIndex - 12, 0) + const visibleEnd = Math.min(range.endIndex + 20, total - 1) + const pendingUsernames = new Set() + for (let index = visibleStart; index <= visibleEnd; index += 1) { + const msg = latestMessages[index] + if (!msg || msg.isSend === 1) continue + const sender = String(msg.senderUsername || '').trim() + if (!sender) continue + if (senderAvatarCache.has(sender) || senderAvatarLoading.has(sender)) continue + pendingUsernames.add(sender) + if (pendingUsernames.size >= 24) break + } + if (pendingUsernames.size > 0) { + warmupGroupSenderProfiles([...pendingUsernames], false) + } + } + } }, [ messages.length, isLoadingMore, @@ -3957,21 +4095,52 @@ function ChatPage(props: ChatPageProps) { currentOffset, jumpStartTime, jumpEndTime, + isGroupChatSession, + standaloneSessionWindow, + normalizedInitialSessionId, + normalizedStandaloneInitialContactType, + warmupGroupSenderProfiles, loadMessages, loadLaterMessages ]) const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => { - if (!atBottom) { - bottomRangeLoadLockRef.current = false - } - if (messages.length <= 0 || isLoadingMessages || isSessionSwitching || suppressScrollToBottomButtonRef.current) { + if (messages.length <= 0) { + isMessageListAtBottomRef.current = true setShowScrollToBottom(prev => (prev ? false : prev)) return } - const shouldShow = !atBottom + + const listEl = messageListRef.current + const distanceFromBottom = listEl + ? (listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)) + : Number.POSITIVE_INFINITY + const nearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(messages.length - 2, 0) + const nearBottomByDistance = distanceFromBottom <= 140 + const effectiveAtBottom = atBottom || nearBottomByRange || nearBottomByDistance + isMessageListAtBottomRef.current = effectiveAtBottom + + if (!effectiveAtBottom) { + bottomRangeLoadLockRef.current = false + } + + if ( + isLoadingMessages || + isSessionSwitching || + isLoadingMore || + suppressScrollToBottomButtonRef.current + ) { + setShowScrollToBottom(prev => (prev ? false : prev)) + return + } + + if (effectiveAtBottom) { + setShowScrollToBottom(prev => (prev ? false : prev)) + return + } + const shouldShow = distanceFromBottom > 180 setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow)) - }, [messages.length, isLoadingMessages, isSessionSwitching]) + }, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching]) const handleMessageAtTopStateChange = useCallback((atTop: boolean) => { if (!atTop) { @@ -3990,7 +4159,8 @@ function ChatPage(props: ChatPageProps) { prev.lastTimestamp === next.lastTimestamp && prev.lastMsgType === next.lastMsgType && prev.displayName === next.displayName && - prev.avatarUrl === next.avatarUrl + prev.avatarUrl === next.avatarUrl && + prev.alias === next.alias ) }, []) @@ -4001,15 +4171,16 @@ function ChatPage(props: ChatPageProps) { return Array.isArray(sessionsRef.current) ? sessionsRef.current : [] } if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) { - return nextSessions + return nextSessions.map((next) => mergeSessionContactPresentation(next)) } const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s])) return nextSessions.map((next) => { const prev = prevMap.get(next.username) - if (!prev) return next - return isSameSession(prev, next) ? prev : next + const merged = mergeSessionContactPresentation(next, prev) + if (!prev) return merged + return isSameSession(prev, merged) ? prev : merged }) - }, [isSameSession]) + }, [isSameSession, mergeSessionContactPresentation]) const flashNewMessages = useCallback((keys: string[]) => { if (keys.length === 0) return @@ -4052,6 +4223,7 @@ function ChatPage(props: ChatPageProps) { // 滚动到底部 const scrollToBottom = useCallback(() => { suppressScrollToBottomButton(220) + isMessageListAtBottomRef.current = true setShowScrollToBottom(false) const lastIndex = messages.length - 1 if (lastIndex >= 0 && messageVirtuosoRef.current) { @@ -4122,6 +4294,9 @@ function ChatPage(props: ChatPageProps) { clearTimeout(sessionScrollTimeoutRef.current) } contactUpdateQueueRef.current.clear() + pendingSessionContactEnrichRef.current.clear() + sessionContactEnrichAttemptAtRef.current.clear() + sessionContactProfileCacheRef.current.clear() enrichCancelledRef.current = true isEnrichingRef.current = false } @@ -4145,6 +4320,32 @@ function ChatPage(props: ChatPageProps) { lastMessageTimeRef.current = lastMsg?.createTime ?? 0 }, [messages, getMessageKey]) + useEffect(() => { + lastObservedMessageCountRef.current = messages.length + if (messages.length <= 0) { + isMessageListAtBottomRef.current = true + } + }, [currentSessionId]) + + useEffect(() => { + const previousCount = lastObservedMessageCountRef.current + const currentCount = messages.length + lastObservedMessageCountRef.current = currentCount + if (currentCount <= previousCount) return + if (!currentSessionId || isLoadingMessages || isSessionSwitching) return + const wasNearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(previousCount - 2, 0) + if (!isMessageListAtBottomRef.current && !wasNearBottomByRange) return + suppressScrollToBottomButton(220) + isMessageListAtBottomRef.current = true + requestAnimationFrame(() => { + const latestMessages = useChatStore.getState().messages || [] + const lastIndex = latestMessages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) + } + }) + }, [messages.length, currentSessionId, isLoadingMessages, isSessionSwitching, suppressScrollToBottomButton]) + useEffect(() => { currentSessionRef.current = currentSessionId }, [currentSessionId]) @@ -4197,6 +4398,36 @@ function ChatPage(props: ChatPageProps) { sessionMapRef.current = nextMap }, [sessions]) + useEffect(() => { + if (!Array.isArray(sessions) || sessions.length === 0) return + const now = Date.now() + const cache = sessionContactProfileCacheRef.current + + for (const session of sessions) { + const username = String(session.username || '').trim() + if (!username || isFoldPlaceholderSession(username)) continue + + const displayName = resolveSessionDisplayName(session.displayName, username) + const avatarUrl = normalizeSearchAvatarUrl(session.avatarUrl) + const alias = normalizeSearchIdentityText(session.alias) + if (!displayName && !avatarUrl && !alias) continue + + const prev = cache.get(username) + cache.set(username, { + displayName: displayName || prev?.displayName, + avatarUrl: avatarUrl || prev?.avatarUrl, + alias: alias || prev?.alias, + updatedAt: now + }) + } + + for (const [username, profile] of cache.entries()) { + if (now - profile.updatedAt > SESSION_CONTACT_PROFILE_CACHE_TTL_MS) { + cache.delete(username) + } + } + }, [sessions]) + useEffect(() => { sessionsRef.current = Array.isArray(sessions) ? sessions : [] }, [sessions]) @@ -5958,7 +6189,7 @@ function ChatPage(props: ChatPageProps) { customScrollParent={messageListScrollParent ?? undefined} data={messages} overscan={360} - followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)} + followOutput={(atBottom) => (atBottom || isMessageListAtBottomRef.current ? 'auto' : false)} atBottomThreshold={80} atBottomStateChange={handleMessageAtBottomStateChange} atTopStateChange={handleMessageAtTopStateChange} @@ -6852,9 +7083,26 @@ const emojiDataUrlCache = new Map() const imageDataUrlCache = new Map() const voiceDataUrlCache = new Map() const voiceTranscriptCache = new Map() +type SharedImageDecryptResult = { success: boolean; localPath?: string; liveVideoPath?: string; error?: string } +const imageDecryptInFlight = new Map>() const senderAvatarCache = new Map() const senderAvatarLoading = new Map>() +function getSharedImageDecryptTask( + key: string, + createTask: () => Promise +): Promise { + const existing = imageDecryptInFlight.get(key) + if (existing) return existing + const task = createTask().finally(() => { + if (imageDecryptInFlight.get(key) === task) { + imageDecryptInFlight.delete(key) + } + }) + imageDecryptInFlight.set(key, task) + return task +} + const buildVoiceCacheIdentity = ( sessionId: string, message: Pick @@ -6933,6 +7181,7 @@ function MessageBubble({ const isSent = message.isSend === 1 const [senderAvatarUrl, setSenderAvatarUrl] = useState(undefined) const [senderName, setSenderName] = useState(undefined) + const senderProfileRequestSeqRef = useRef(0) const [emojiError, setEmojiError] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false) @@ -6963,6 +7212,11 @@ function MessageBubble({ const imageUpdateCheckedRef = useRef(null) const imageClickTimerRef = useRef(null) const imageContainerRef = useRef(null) + const emojiContainerRef = useRef(null) + const imageResizeBaselineRef = useRef(null) + const emojiResizeBaselineRef = useRef(null) + const imageObservedHeightRef = useRef(null) + const emojiObservedHeightRef = useRef(null) const imageAutoDecryptTriggered = useRef(false) const imageAutoHdTriggered = useRef(null) const [imageInView, setImageInView] = useState(false) @@ -7058,12 +7312,104 @@ function MessageBubble({ return imageContainerRef.current?.closest('.message-list') ?? null }, []) - // 获取头像首字母 - const getAvatarLetter = (name: string): string => { - if (!name) return '?' - const chars = [...name] - return chars[0] || '?' - } + const stabilizeScrollerByDelta = useCallback((host: HTMLElement | null, delta: number) => { + if (!host) return + if (!Number.isFinite(delta) || Math.abs(delta) < 1) return + const scroller = host.closest('.message-list') as HTMLDivElement | null + if (!scroller) return + + const distanceFromBottom = scroller.scrollHeight - (scroller.scrollTop + scroller.clientHeight) + if (distanceFromBottom <= 96) return + + const scrollerRect = scroller.getBoundingClientRect() + const hostRect = host.getBoundingClientRect() + const hostTopInScroller = hostRect.top - scrollerRect.top + scroller.scrollTop + const viewportBottom = scroller.scrollTop + scroller.clientHeight + if (hostTopInScroller > viewportBottom + 24) return + + scroller.scrollTop += delta + }, []) + + const bindResizeObserverForHost = useCallback(( + host: HTMLElement | null, + observedHeightRef: React.MutableRefObject, + pendingBaselineRef: React.MutableRefObject + ) => { + if (!host) return + + const initialHeight = host.getBoundingClientRect().height + observedHeightRef.current = Number.isFinite(initialHeight) && initialHeight > 0 ? initialHeight : null + if (typeof ResizeObserver === 'undefined') return + + const observer = new ResizeObserver(() => { + const nextHeight = host.getBoundingClientRect().height + if (!Number.isFinite(nextHeight) || nextHeight <= 0) { + observedHeightRef.current = null + return + } + const previousHeight = observedHeightRef.current + observedHeightRef.current = nextHeight + if (!Number.isFinite(previousHeight) || (previousHeight as number) <= 0) return + if (pendingBaselineRef.current !== null) return + stabilizeScrollerByDelta(host, nextHeight - (previousHeight as number)) + }) + + observer.observe(host) + return () => { + observer.disconnect() + } + }, [stabilizeScrollerByDelta]) + + const captureResizeBaseline = useCallback( + (host: HTMLElement | null, baselineRef: React.MutableRefObject) => { + if (!host) return + const height = host.getBoundingClientRect().height + if (!Number.isFinite(height) || height <= 0) return + baselineRef.current = height + }, + [] + ) + + const stabilizeScrollAfterResize = useCallback( + (host: HTMLElement | null, baselineRef: React.MutableRefObject) => { + if (!host) return + const baseline = baselineRef.current + baselineRef.current = null + if (!Number.isFinite(baseline) || (baseline as number) <= 0) return + + requestAnimationFrame(() => { + const nextHeight = host.getBoundingClientRect().height + stabilizeScrollerByDelta(host, nextHeight - (baseline as number)) + }) + }, + [stabilizeScrollerByDelta] + ) + + const captureImageResizeBaseline = useCallback(() => { + captureResizeBaseline(imageContainerRef.current, imageResizeBaselineRef) + }, [captureResizeBaseline]) + + const captureEmojiResizeBaseline = useCallback(() => { + captureResizeBaseline(emojiContainerRef.current, emojiResizeBaselineRef) + }, [captureResizeBaseline]) + + const stabilizeImageScrollAfterResize = useCallback(() => { + stabilizeScrollAfterResize(imageContainerRef.current, imageResizeBaselineRef) + }, [stabilizeScrollAfterResize]) + + const stabilizeEmojiScrollAfterResize = useCallback(() => { + stabilizeScrollAfterResize(emojiContainerRef.current, emojiResizeBaselineRef) + }, [stabilizeScrollAfterResize]) + + useEffect(() => { + if (!isImage) return + return bindResizeObserverForHost(imageContainerRef.current, imageObservedHeightRef, imageResizeBaselineRef) + }, [isImage, imageLocalPath, imageLoading, imageError, bindResizeObserverForHost]) + + useEffect(() => { + if (!isEmoji) return + return bindResizeObserverForHost(emojiContainerRef.current, emojiObservedHeightRef, emojiResizeBaselineRef) + }, [isEmoji, emojiLocalPath, emojiLoading, emojiError, bindResizeObserverForHost]) // 下载表情包 const downloadEmoji = () => { @@ -7072,6 +7418,7 @@ function MessageBubble({ // 先检查缓存 const cached = emojiDataUrlCache.get(cacheKey) if (cached) { + captureEmojiResizeBaseline() setEmojiLocalPath(cached) setEmojiError(false) return @@ -7082,6 +7429,7 @@ function MessageBubble({ window.electronAPI.chat.downloadEmoji(message.emojiCdnUrl, message.emojiMd5).then((result: { success: boolean; localPath?: string; error?: string }) => { if (result.success && result.localPath) { emojiDataUrlCache.set(cacheKey, result.localPath) + captureEmojiResizeBaseline() setEmojiLocalPath(result.localPath) } else { setEmojiError(true) @@ -7095,37 +7443,55 @@ function MessageBubble({ // 群聊中获取发送者信息 (如果自己发的没头像,也尝试拉取) useEffect(() => { - if (message.senderUsername && (isGroupChat || (isSent && !myAvatarUrl))) { - const sender = message.senderUsername - const cached = senderAvatarCache.get(sender) - if (cached) { - setSenderAvatarUrl(cached.avatarUrl) - setSenderName(cached.displayName) - return - } - const pending = senderAvatarLoading.get(sender) - if (pending) { - pending.then((result: { avatarUrl?: string; displayName?: string } | null) => { - if (result) { - setSenderAvatarUrl(result.avatarUrl) - setSenderName(result.displayName) - } - }) - return - } - const request = window.electronAPI.chat.getContactAvatar(sender) - senderAvatarLoading.set(sender, request) - request.then((result: { avatarUrl?: string; displayName?: string } | null) => { - if (result) { - senderAvatarCache.set(sender, result) - setSenderAvatarUrl(result.avatarUrl) - setSenderName(result.displayName) - } - }).catch(() => { }).finally(() => { - senderAvatarLoading.delete(sender) - }) + const sender = String(message.senderUsername || '').trim() + const cached = sender ? senderAvatarCache.get(sender) : undefined + setSenderAvatarUrl(cached?.avatarUrl || message.senderAvatarUrl || undefined) + setSenderName(cached?.displayName || message.senderDisplayName || undefined) + + if (!sender || !(isGroupChat || (isSent && !myAvatarUrl))) return + + const requestSeq = senderProfileRequestSeqRef.current + 1 + senderProfileRequestSeqRef.current = requestSeq + let cancelled = false + const applyProfile = (result: { avatarUrl?: string; displayName?: string } | null) => { + if (!result || cancelled) return + if (requestSeq !== senderProfileRequestSeqRef.current) return + if (result.avatarUrl) setSenderAvatarUrl(result.avatarUrl) + if (result.displayName) setSenderName(result.displayName) } - }, [isGroupChat, isSent, message.senderUsername, myAvatarUrl]) + + if (cached) { + applyProfile(cached) + return () => { + cancelled = true + } + } + + const pending = senderAvatarLoading.get(sender) + if (pending) { + pending.then(applyProfile).catch(() => { }) + return () => { + cancelled = true + } + } + + const request = window.electronAPI.chat.getContactAvatar(sender) + senderAvatarLoading.set(sender, request) + request.then((result: { avatarUrl?: string; displayName?: string } | null) => { + if (result) { + senderAvatarCache.set(sender, result) + } + applyProfile(result) + }).catch(() => { }).finally(() => { + if (senderAvatarLoading.get(sender) === request) { + senderAvatarLoading.delete(sender) + } + }) + + return () => { + cancelled = true + } + }, [isGroupChat, isSent, message.senderAvatarUrl, message.senderDisplayName, message.senderUsername, myAvatarUrl]) // 解析转账消息的付款方和收款方显示名称 useEffect(() => { @@ -7152,17 +7518,18 @@ function MessageBubble({ if (emojiLocalPath) return // 后端已从本地缓存找到文件(转发表情包无 CDN URL 的情况) if (isEmoji && message.emojiLocalPath && !emojiLocalPath) { + captureEmojiResizeBaseline() setEmojiLocalPath(message.emojiLocalPath) return } if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) { downloadEmoji() } - }, [isEmoji, message.emojiCdnUrl, message.emojiLocalPath, emojiLocalPath, emojiLoading, emojiError]) + }, [isEmoji, message.emojiCdnUrl, message.emojiLocalPath, emojiLocalPath, emojiLoading, emojiError, captureEmojiResizeBaseline]) - const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => { - if (!isImage) return - if (imageLoading || imageDecryptPendingRef.current) return + const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false): Promise => { + if (!isImage) return { success: false } + if (imageDecryptPendingRef.current) return { success: false } imageDecryptPendingRef.current = true if (!silent) { setImageLoading(true) @@ -7170,14 +7537,20 @@ function MessageBubble({ } try { if (message.imageMd5 || message.imageDatName) { - const result = await window.electronAPI.image.decrypt({ - sessionId: session.username, - imageMd5: message.imageMd5 || undefined, - imageDatName: message.imageDatName, - force: forceUpdate + const sharedDecryptKey = `${session.username}:${imageCacheKey}:${forceUpdate ? 'force' : 'normal'}` + const result = await getSharedImageDecryptTask(sharedDecryptKey, async () => { + return await window.electronAPI.image.decrypt({ + sessionId: session.username, + imageMd5: message.imageMd5 || undefined, + imageDatName: message.imageDatName, + force: forceUpdate + }) as SharedImageDecryptResult }) if (result.success && result.localPath) { imageDataUrlCache.set(imageCacheKey, result.localPath) + if (imageLocalPath !== result.localPath) { + captureImageResizeBaseline() + } setImageLocalPath(result.localPath) setImageHasUpdate(false) if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) @@ -7190,9 +7563,12 @@ function MessageBubble({ const mime = detectImageMimeFromBase64(fallback.data) const dataUrl = `data:${mime};base64,${fallback.data}` imageDataUrlCache.set(imageCacheKey, dataUrl) + if (imageLocalPath !== dataUrl) { + captureImageResizeBaseline() + } setImageLocalPath(dataUrl) setImageHasUpdate(false) - return { success: true, localPath: dataUrl } as any + return { success: true, localPath: dataUrl } } if (!silent) setImageError(true) } catch { @@ -7201,8 +7577,8 @@ function MessageBubble({ if (!silent) setImageLoading(false) imageDecryptPendingRef.current = false } - return { success: false } as any - }, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64]) + return { success: false } + }, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline]) const triggerForceHd = useCallback(() => { if (!message.imageMd5 && !message.imageDatName) return @@ -7263,6 +7639,9 @@ function MessageBubble({ finalImagePath = resolved.localPath finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath imageDataUrlCache.set(imageCacheKey, resolved.localPath) + if (imageLocalPath !== resolved.localPath) { + captureImageResizeBaseline() + } setImageLocalPath(resolved.localPath) if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath) setImageHasUpdate(Boolean(resolved.hasUpdate)) @@ -7275,6 +7654,7 @@ function MessageBubble({ imageLiveVideoPath, imageLocalPath, imageCacheKey, + captureImageResizeBaseline, message.imageDatName, message.imageMd5, requestImageDecrypt, @@ -7304,6 +7684,7 @@ function MessageBubble({ if (result.success && result.localPath) { imageDataUrlCache.set(imageCacheKey, result.localPath) if (!imageLocalPath || imageLocalPath !== result.localPath) { + captureImageResizeBaseline() setImageLocalPath(result.localPath) setImageError(false) } @@ -7314,7 +7695,7 @@ function MessageBubble({ return () => { cancelled = true } - }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username]) + }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline]) useEffect(() => { if (!isImage) return @@ -7342,15 +7723,21 @@ function MessageBubble({ (payload.imageMd5 && payload.imageMd5 === message.imageMd5) || (payload.imageDatName && payload.imageDatName === message.imageDatName) if (matchesCacheKey) { - imageDataUrlCache.set(imageCacheKey, payload.localPath) - setImageLocalPath(payload.localPath) + const cachedPath = imageDataUrlCache.get(imageCacheKey) + if (cachedPath !== payload.localPath) { + imageDataUrlCache.set(imageCacheKey, payload.localPath) + } + if (imageLocalPath !== payload.localPath) { + captureImageResizeBaseline() + } + setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath)) setImageError(false) } }) return () => { unsubscribe?.() } - }, [isImage, imageCacheKey, message.imageDatName, message.imageMd5]) + }, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline]) // 图片进入视野前自动解密(懒加载) useEffect(() => { @@ -7655,10 +8042,116 @@ function MessageBubble({ void requestVoiceTranscript() }, [autoTranscribeVoiceEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript]) + // 去除企业微信 ID 前缀 + const cleanMessageContent = useCallback((content: string) => { + if (!content) return '' + return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '') + }, []) + + // 解析混合文本和表情 + const renderTextWithEmoji = useCallback((text: string) => { + if (!text) return text + const parts = text.split(/\[(.*?)\]/g) + return parts.map((part, index) => { + // 奇数索引是捕获组的内容(即括号内的文字) + if (index % 2 === 1) { + // @ts-ignore + const path = getEmojiPath(part as any) + if (path) { + // path 例如 'assets/face/微笑.png',需要添加 base 前缀 + return ( + {`[${part}]`} + ) + } + return `[${part}]` + } + return part + }) + }, []) + + const cleanedParsedContent = useMemo( + () => cleanMessageContent(message.parsedContent || ''), + [cleanMessageContent, message.parsedContent] + ) + + const appMsgRawXml = message.rawContent || message.parsedContent || '' + const appMsgContainsTag = useMemo( + () => appMsgRawXml.includes(' { + if (!appMsgContainsTag) return null + try { + const start = appMsgRawXml.indexOf('') + const xml = start >= 0 ? appMsgRawXml.slice(start) : appMsgRawXml + const doc = new DOMParser().parseFromString(xml, 'text/xml') + if (doc.querySelector('parsererror')) return null + return doc + } catch { + return null + } + }, [appMsgContainsTag, appMsgRawXml]) + const appMsgTextCache = useMemo(() => new Map(), [appMsgDoc]) + const queryAppMsgText = useCallback((selector: string): string => { + const cached = appMsgTextCache.get(selector) + if (cached !== undefined) return cached + const value = appMsgDoc?.querySelector(selector)?.textContent?.trim() || '' + appMsgTextCache.set(selector, value) + return value + }, [appMsgDoc, appMsgTextCache]) + + const locationMessageMeta = useMemo(() => { + if (message.localType !== 48) return null + const raw = message.rawContent || '' + const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置' + const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || '' + const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0)) + const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0)) + const zoom = 15 + const tileX = Math.floor((lng + 180) / 360 * Math.pow(2, zoom)) + const latRad = lat * Math.PI / 180 + const tileY = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom)) + const mapTileUrl = (lat && lng) + ? `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}` + : '' + return { poiname, label, lat, lng, mapTileUrl } + }, [message.localType, message.rawContent, message.locationPoiname, message.locationLabel, message.locationLat, message.locationLng]) + + // 检测是否为链接卡片消息 + const isLinkMessage = String(message.localType) === '21474836529' || appMsgContainsTag + const bubbleClass = isSent ? 'sent' : 'received' + + // 头像逻辑: + // - 自己发的:优先使用 myAvatarUrl,缺失则用 senderAvatarUrl (补救) + // - 群聊中对方发的:使用发送者头像 + // - 私聊中对方发的:使用会话头像 + const fallbackSenderName = String(message.senderDisplayName || message.senderUsername || '').trim() || undefined + const resolvedSenderName = senderName || fallbackSenderName + const resolvedSenderAvatarUrl = senderAvatarUrl || message.senderAvatarUrl + const avatarUrl = isSent + ? (myAvatarUrl || resolvedSenderAvatarUrl) + : (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl) + + // 是否有引用消息 + const hasQuote = message.quotedContent && message.quotedContent.length > 0 + + const handlePlayVideo = useCallback(async () => { + if (!videoInfo?.videoUrl) return + try { + await window.electronAPI.window.openVideoPlayerWindow(videoInfo.videoUrl) + } catch (e) { + console.error('打开视频播放窗口失败:', e) + } + }, [videoInfo?.videoUrl]) + // Selection mode handling removed from here to allow normal rendering // We will wrap the output instead - - // Regular rendering logic... if (isSystem) { return (
0 - - // 去除企业微信 ID 前缀 - const cleanMessageContent = (content: string) => { - if (!content) return '' - return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '') - } - - // 解析混合文本和表情 - const renderTextWithEmoji = (text: string) => { - if (!text) return text - const parts = text.split(/\[(.*?)\]/g) - return parts.map((part, index) => { - // 奇数索引是捕获组的内容(即括号内的文字) - if (index % 2 === 1) { - // @ts-ignore - const path = getEmojiPath(part as any) - if (path) { - // path 例如 'assets/face/微笑.png',需要添加 base 前缀 - return ( - {`[${part}]`} - ) - } - return `[${part}]` - } - return part - }) - } - // 渲染消息内容 const renderContent = () => { if (isImage) { @@ -7775,8 +8214,14 @@ function MessageBubble({ alt="图片" className="image-message" onClick={() => { void handleOpenImageViewer() }} - onLoad={() => setImageError(false)} - onError={() => setImageError(true)} + onLoad={() => { + setImageError(false) + stabilizeImageScrollAfterResize() + }} + onError={() => { + imageResizeBaselineRef.current = null + setImageError(true) + }} /> {imageLiveVideoPath && (
@@ -7792,15 +8237,6 @@ function MessageBubble({ // 视频消息 if (isVideo) { - const handlePlayVideo = useCallback(async () => { - if (!videoInfo?.videoUrl) return - try { - await window.electronAPI.window.openVideoPlayerWindow(videoInfo.videoUrl) - } catch (e) { - console.error('打开视频播放窗口失败:', e) - } - }, [videoInfo?.videoUrl]) - // 未进入可视区域时显示占位符 if (!isVideoVisible) { return ( @@ -8077,18 +8513,8 @@ function MessageBubble({ // 位置消息 if (message.localType === 48) { - const raw = message.rawContent || '' - const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置' - const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || '' - const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0)) - const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0)) - const zoom = 15 - const tileX = Math.floor((lng + 180) / 360 * Math.pow(2, zoom)) - const latRad = lat * Math.PI / 180 - const tileY = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom)) - const mapTileUrl = (lat && lng) - ? `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}` - : '' + if (!locationMessageMeta) return null + const { poiname, label, lat, lng, mapTileUrl } = locationMessageMeta return (
window.electronAPI.shell.openExternal(`https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(poiname || label)}`)}>
@@ -8114,28 +8540,15 @@ function MessageBubble({ // 链接消息 (AppMessage) const appMsgRichPreview = (() => { - const rawXml = message.rawContent || '' - if (!rawXml || (!rawXml.includes(' { - if (doc) return doc - try { - const start = rawXml.indexOf('') - const xml = start >= 0 ? rawXml.slice(start) : rawXml - doc = new DOMParser().parseFromString(xml, 'text/xml') - } catch { - doc = null - } - return doc - } - const q = (selector: string) => getDoc()?.querySelector(selector)?.textContent?.trim() || '' + const rawXml = appMsgRawXml + if (!appMsgContainsTag) return null + const q = queryAppMsgText const xmlType = message.xmlType || q('appmsg > type') || q('type') // type 57: 引用回复消息,解析 refermsg 渲染为引用样式 if (xmlType === '57') { - const replyText = q('title') || cleanMessageContent(message.parsedContent) || '' + const replyText = q('title') || cleanedParsedContent || '' const referContent = q('refermsg > content') || '' const referSender = q('refermsg > displayname') || '' const referType = q('refermsg > type') || '' @@ -8177,7 +8590,7 @@ function MessageBubble({ ) } - const title = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || 'Card' + const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card' const desc = message.appMsgDesc || q('des') const url = message.linkUrl || q('url') const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl') @@ -8212,12 +8625,7 @@ function MessageBubble({ // 对视频号提取真实标题,避免出现 "当前版本不支持该内容" let displayTitle = title if (kind === 'finder' && (!displayTitle || displayTitle.includes('不支持'))) { - try { - const d = new DOMParser().parseFromString(rawXml, 'text/xml') - displayTitle = d.querySelector('finderFeed desc')?.textContent?.trim() || desc || '' - } catch { - displayTitle = desc || '' - } + displayTitle = q('finderFeed > desc') || q('finderFeed desc') || desc || '' } const openExternal = (e: React.MouseEvent, nextUrl?: string) => { @@ -8268,7 +8676,7 @@ function MessageBubble({ if (kind === 'quote') { // 引用回复消息(appMsgKind='quote',xmlType=57) - const replyText = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || '' + const replyText = message.linkTitle || q('title') || cleanedParsedContent || '' const referContent = message.quotedContent || q('refermsg > content') || '' const referSender = message.quotedSender || q('refermsg > displayname') || '' return ( @@ -8284,14 +8692,7 @@ function MessageBubble({ if (kind === 'red-packet') { // 专属红包卡片 - const greeting = (() => { - try { - const d = getDoc() - if (!d) return '' - return d.querySelector('receivertitle')?.textContent?.trim() || - d.querySelector('sendertitle')?.textContent?.trim() || '' - } catch { return '' } - })() + const greeting = q('receivertitle') || q('sendertitle') || '' return (
@@ -8459,36 +8860,18 @@ function MessageBubble({ return appMsgRichPreview } - const isAppMsg = message.rawContent?.includes('')) - - const parser = new DOMParser() - parsedDoc = parser.parseFromString(xmlContent, 'text/xml') - - title = parsedDoc.querySelector('title')?.textContent || '链接' - desc = parsedDoc.querySelector('des')?.textContent || '' - url = parsedDoc.querySelector('url')?.textContent || '' - appMsgType = parsedDoc.querySelector('appmsg > type')?.textContent || parsedDoc.querySelector('type')?.textContent || '' - textAnnouncement = parsedDoc.querySelector('textannouncement')?.textContent || '' - } catch (e) { - console.error('解析 AppMsg 失败:', e) - } + if (appMsgContainsTag) { + const q = queryAppMsgText + const title = q('title') || '链接' + const desc = q('des') + const url = q('url') + const appMsgType = message.xmlType || q('appmsg > type') || q('type') + const textAnnouncement = q('textannouncement') + const parsedDoc: Document | null = appMsgDoc // 引用回复消息 (type=57),防止被误判为链接 if (appMsgType === '57') { - const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanMessageContent(message.parsedContent) || '' + const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || '' const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || '' const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || '' const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || '' @@ -8758,14 +9141,16 @@ function MessageBubble({ // 没有 cdnUrl 或加载失败,显示占位符 if ((!message.emojiCdnUrl && !message.emojiLocalPath) || emojiError) { return ( -
- - - - - - - 表情包未缓存 +
+
+ + + + + + + 表情包未缓存 +
) } @@ -8773,20 +9158,31 @@ function MessageBubble({ // 显示加载中 if (emojiLoading || !emojiLocalPath) { return ( -
- +
+
+ +
) } // 显示表情图片 return ( - 表情 setEmojiError(true)} - /> +
+ 表情 { + setEmojiError(false) + stabilizeEmojiScrollAfterResize() + }} + onError={() => { + emojiResizeBaselineRef.current = null + setEmojiError(true) + }} + /> +
) } @@ -8801,13 +9197,13 @@ function MessageBubble({ {message.quotedSender && {message.quotedSender}} {renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))}
-
{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}
+
{renderTextWithEmoji(cleanedParsedContent)}
) } // 普通消息 - return
{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}
+ return
{renderTextWithEmoji(cleanedParsedContent)}
} return ( @@ -8858,7 +9254,7 @@ function MessageBubble({
@@ -8867,7 +9263,7 @@ function MessageBubble({ {/* 群聊中显示发送者名称 */} {isGroupChat && !isSent && (
- {senderName || message.senderUsername || '群成员'} + {resolvedSenderName || '群成员'}
)} {renderContent()}