Files
WeChatDataAnalysis/frontend/composables/chat/useChatMessages.js
T
2977094657 a1c99c8704 improvement(log): 补充聊天消息与会话加载阶段日志
- 为会话加载、刷新和联系人初始化补充阶段性 trace
- 为消息加载、归一化、状态提交和媒体刷新补充耗时日志
- 便于定位聊天页卡顿和加载异常
2026-04-14 00:20:36 +08:00

977 lines
30 KiB
JavaScript

import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import {
formatFileSize,
formatTimeDivider,
getVoiceDurationInSeconds,
getVoiceWidth
} from '~/lib/chat/formatters'
import { createPerfTrace } from '~/lib/chat/perf-logger'
import { createMessageNormalizer, dedupeMessagesById } from '~/lib/chat/message-normalizer'
export const useChatMessages = ({
api,
apiBase,
selectedAccount,
selectedContact,
realtimeStore,
realtimeEnabled,
desktopAutoRealtime,
privacyMode,
searchContext
}) => {
const messagePageSize = 50
const allMessages = ref({})
const messagesMeta = ref({})
const isLoadingMessages = ref(false)
const messagesError = ref('')
const messageContainerRef = ref(null)
const activeMessagesFor = ref('')
const showJumpToBottom = ref(false)
let lastRenderMessagesFingerprint = ''
const isDesktopRenderer = () => {
if (!process.client || typeof window === 'undefined') return false
return !!window.wechatDesktop?.__brand
}
const logMessagePhase = (phase, details = {}) => {
const payload = {
account: String(selectedAccount.value || '').trim(),
selectedUsername: String(selectedContact.value?.username || '').trim(),
activeMessagesFor: String(activeMessagesFor.value || '').trim(),
...details
}
if (isDesktopRenderer()) {
try {
window.wechatDesktop?.logDebug?.('chat-messages', phase, payload)
} catch {}
}
console.info(`[chat-messages] ${phase}`, payload)
}
const summarizeRenderTypes = (list) => {
const counts = {}
for (const item of Array.isArray(list) ? list : []) {
const key = String(item?.renderType || 'unknown').trim() || 'unknown'
counts[key] = Number(counts[key] || 0) + 1
}
return counts
}
const previewImageUrl = ref(null)
const previewVideoUrl = ref(null)
const previewVideoPosterUrl = ref('')
const previewVideoError = ref('')
const voiceRefs = new Map()
const currentPlayingVoice = ref(null)
const playingVoiceId = ref(null)
const highlightServerIdStr = ref('')
const highlightMessageId = ref('')
let highlightTimer = null
const messageTypeFilter = ref('all')
const localMediaVersion = ref(0)
const messageTypeFilterOptions = [
{ value: 'all', label: '全部' },
{ value: 'text', label: '文本' },
{ value: 'image', label: '图片' },
{ value: 'emoji', label: '表情' },
{ value: 'video', label: '视频' },
{ value: 'voice', label: '语音' },
{ value: 'file', label: '文件' },
{ value: 'link', label: '链接' },
{ value: 'quote', label: '引用' },
{ value: 'chatHistory', label: '聊天记录' },
{ value: 'transfer', label: '转账' },
{ value: 'redPacket', label: '红包' },
{ value: 'location', label: '位置' },
{ value: 'voip', label: '通话' },
{ value: 'system', label: '系统' }
]
const normalizeMessage = createMessageNormalizer({
apiBase,
getSelectedAccount: () => selectedAccount.value,
getSelectedContact: () => selectedContact.value,
getLocalMediaVersion: () => localMediaVersion.value
})
const bumpLocalMediaVersion = () => {
localMediaVersion.value = (localMediaVersion.value + 1) % 1000000000
return localMediaVersion.value
}
const renormalizeLoadedMessages = (username) => {
const key = String(username || '').trim()
if (!key) return
const existing = allMessages.value[key]
if (!Array.isArray(existing) || !existing.length) return
const refreshed = dedupeMessagesById(existing.map((message) => {
const normalized = normalizeMessage(message)
return {
...message,
...normalized,
_emojiDownloading: !!message?._emojiDownloading,
_emojiDownloaded: typeof message?._emojiDownloaded === 'boolean' ? message._emojiDownloaded : normalized._emojiDownloaded,
_quoteImageError: false,
_quoteThumbError: false
}
}))
allMessages.value = {
...allMessages.value,
[key]: refreshed
}
}
const messages = computed(() => {
if (!selectedContact.value) return []
return allMessages.value[selectedContact.value.username] || []
})
const hasMoreMessages = computed(() => {
if (!selectedContact.value) return false
const key = selectedContact.value.username
const meta = messagesMeta.value[key]
if (!meta) return false
if (meta.hasMore != null) return !!meta.hasMore
const total = Number(meta.total || 0)
const loaded = messages.value.length
return total > loaded
})
const reverseMessageSides = ref(false)
const reverseSidesStorageKey = computed(() => {
const account = String(selectedAccount.value || '').trim()
const username = String(selectedContact.value?.username || '').trim()
if (account && username) return `wechatda:reverse_message_sides:${account}:${username}`
return 'wechatda:reverse_message_sides:global'
})
const loadReverseMessageSides = () => {
if (!process.client) return
try {
const value = localStorage.getItem(reverseSidesStorageKey.value)
reverseMessageSides.value = value === '1'
} catch {}
}
watch(reverseSidesStorageKey, () => loadReverseMessageSides(), { immediate: true })
watch(reverseMessageSides, (value) => {
if (!process.client) return
try {
localStorage.setItem(reverseSidesStorageKey.value, value ? '1' : '0')
} catch {}
})
const toggleReverseMessageSides = () => {
reverseMessageSides.value = !reverseMessageSides.value
}
const renderMessages = computed(() => {
const list = messages.value || []
const reverseSides = !!reverseMessageSides.value
const fingerprint = `${String(selectedContact.value?.username || '').trim()}:${list.length}:${reverseSides ? '1' : '0'}`
const shouldLogRender = isDesktopRenderer() && fingerprint !== lastRenderMessagesFingerprint
if (shouldLogRender) {
logMessagePhase('renderMessages:start', {
count: list.length,
reverseSides
})
}
let previousTs = 0
const rendered = list.map((message) => {
const ts = Number(message.createTime || 0)
const show = !previousTs || (ts && Math.abs(ts - previousTs) >= 300)
if (ts) previousTs = ts
const originalIsSent = !!message?.isSent
return {
...message,
_originalIsSent: originalIsSent,
isSent: reverseSides ? !originalIsSent : originalIsSent,
showTimeDivider: !!show,
timeDivider: formatTimeDivider(ts)
}
})
if (shouldLogRender) {
lastRenderMessagesFingerprint = fingerprint
logMessagePhase('renderMessages:end', {
count: rendered.length,
reverseSides
})
}
return rendered
})
const updateJumpToBottomState = () => {
const container = messageContainerRef.value
if (!container) {
showJumpToBottom.value = false
return
}
const distance = container.scrollHeight - container.scrollTop - container.clientHeight
showJumpToBottom.value = distance > 160
}
const scrollToBottom = () => {
const container = messageContainerRef.value
if (!container) return
container.scrollTop = container.scrollHeight
updateJumpToBottomState()
}
const flashMessage = (id) => {
highlightMessageId.value = String(id || '').trim()
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
highlightMessageId.value = ''
highlightServerIdStr.value = ''
highlightTimer = null
}, 2200)
}
const scrollToMessageId = async (id) => {
const target = String(id || '').trim()
if (!target) return false
await nextTick()
const container = messageContainerRef.value
const element = container?.querySelector?.(`[data-msg-id="${CSS.escape(target)}"]`)
if (!element || typeof element.scrollIntoView !== 'function') return false
element.scrollIntoView({ block: 'center', behavior: 'smooth' })
return true
}
const openImagePreview = (url) => {
previewImageUrl.value = String(url || '').trim() || null
}
const closeImagePreview = () => {
previewImageUrl.value = null
}
const openVideoPreview = (url, poster) => {
previewVideoUrl.value = String(url || '').trim() || null
previewVideoPosterUrl.value = String(poster || '').trim()
previewVideoError.value = ''
}
const closeVideoPreview = () => {
previewVideoUrl.value = null
previewVideoPosterUrl.value = ''
previewVideoError.value = ''
}
const onPreviewVideoError = () => {
previewVideoError.value = '视频加载失败,可能是资源不存在或无法访问。'
}
const setVoiceRef = (id, element) => {
const key = String(id || '').trim()
if (!key) return
if (element) {
voiceRefs.set(key, element)
} else {
voiceRefs.delete(key)
}
}
const playVoiceById = async (voiceId) => {
const key = String(voiceId || '').trim()
if (!key) return
const audio = voiceRefs.get(key)
if (!audio) return
try {
if (currentPlayingVoice.value && currentPlayingVoice.value !== audio) {
currentPlayingVoice.value.pause()
currentPlayingVoice.value.currentTime = 0
}
} catch {}
if (currentPlayingVoice.value === audio && !audio.paused) {
try {
audio.pause()
audio.currentTime = 0
} catch {}
currentPlayingVoice.value = null
playingVoiceId.value = null
return
}
try {
await audio.play()
currentPlayingVoice.value = audio
playingVoiceId.value = key
audio.onended = () => {
if (playingVoiceId.value === key) {
currentPlayingVoice.value = null
playingVoiceId.value = null
}
}
} catch {}
}
const playVoice = async (message) => {
await playVoiceById(message?.id)
}
const getQuoteVoiceId = (message) => `quote-${String(message?.quoteServerId || message?.id || '')}`
const playQuoteVoice = async (message) => {
await playVoiceById(getQuoteVoiceId(message))
}
const isQuotedVoice = (message) => String(message?.quoteType || '').trim() === '34'
const isQuotedImage = (message) => {
return !!String(message?.quoteImageUrl || '').trim() || String(message?.quoteContent || '').trim() === '[图片]'
}
const isQuotedLink = (message) => {
return String(message?.quoteType || '').trim() === '5' || !!String(message?.quoteThumbUrl || '').trim()
}
const getQuotedLinkText = (message) => {
const title = String(message?.quoteTitle || '').trim()
const content = String(message?.quoteContent || '').trim()
return content || title || ''
}
const onQuoteImageError = (message) => {
if (message) message._quoteImageError = true
}
const onQuoteThumbError = (message) => {
if (message) message._quoteThumbError = true
}
const onAvatarError = (event, target) => {
try { event?.target && (event.target.style.display = 'none') } catch {}
try { if (target) target.avatar = null } catch {}
}
const shouldShowEmojiDownload = (message) => {
if (!message?.emojiMd5) return false
const url = String(message?.emojiRemoteUrl || '').trim()
if (!url) return false
if (!/^https?:\/\//i.test(url)) return false
return true
}
const onEmojiDownloadClick = async (message) => {
if (!process.client) return
if (!message?.emojiMd5) return
if (!selectedAccount.value) return
const emojiUrl = String(message?.emojiRemoteUrl || '').trim()
if (!emojiUrl) {
window.alert('该表情没有可用的下载地址')
return
}
if (message._emojiDownloading) return
message._emojiDownloading = true
try {
await api.downloadChatEmoji({
account: selectedAccount.value,
md5: message.emojiMd5,
emoji_url: emojiUrl,
force: false
})
message._emojiDownloaded = true
if (message.emojiLocalUrl) {
message.emojiUrl = message.emojiLocalUrl
}
} catch (error) {
window.alert(error?.message || '下载失败')
} finally {
message._emojiDownloading = false
}
}
const onFileClick = async (message) => {
if (!message?.fileMd5) return
try {
if (!selectedAccount.value) return
if (!selectedContact.value?.username) return
await api.openChatMediaFolder({
account: selectedAccount.value,
username: selectedContact.value.username,
kind: 'file',
md5: message.fileMd5
})
} catch (error) {
console.error('打开文件夹失败:', error)
}
}
const loadMessages = async ({ username, reset }) => {
if (!username || !selectedAccount.value) return
const trace = createPerfTrace('chat-messages', {
account: String(selectedAccount.value || '').trim(),
selectedUsername: String(selectedContact.value?.username || '').trim(),
username: String(username || '').trim(),
reset: !!reset
})
trace.log('loadMessages:enter', {
activeMessagesFor: String(activeMessagesFor.value || '').trim()
})
messagesError.value = ''
isLoadingMessages.value = true
activeMessagesFor.value = username
try {
const existing = allMessages.value[username] || []
const container = messageContainerRef.value
const beforeScrollHeight = container ? container.scrollHeight : 0
const beforeScrollTop = container ? container.scrollTop : 0
const offset = reset ? 0 : existing.length
const params = {
account: selectedAccount.value,
username,
limit: messagePageSize,
offset,
order: 'asc'
}
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
params.render_types = messageTypeFilter.value
}
if (realtimeEnabled.value) {
params.source = 'realtime'
}
trace.log('loadMessages:request:start', {
offset,
existingCount: existing.length,
renderTypeFilter: messageTypeFilter.value,
realtime: !!realtimeEnabled.value
})
const response = await api.listChatMessages(params)
trace.log('loadMessages:request:end', {
rawCount: Array.isArray(response?.messages) ? response.messages.length : 0,
total: Number(response?.total || 0),
hasMore: response?.hasMore
})
const raw = response?.messages || []
trace.log('loadMessages:normalize:start', {
rawCount: raw.length
})
const mapped = dedupeMessagesById(raw.map(normalizeMessage))
trace.log('loadMessages:normalize:end', {
mappedCount: mapped.length,
renderTypeCounts: summarizeRenderTypes(mapped)
})
if (activeMessagesFor.value !== username) {
trace.log('loadMessages:abort-stale', {
activeMessagesFor: activeMessagesFor.value
})
return
}
trace.log('loadMessages:state-commit:start', {
mappedCount: mapped.length
})
if (reset) {
allMessages.value = { ...allMessages.value, [username]: mapped }
} else {
const existingIds = new Set(existing.map((message) => String(message?.id || '')))
const older = mapped.filter((message) => {
const id = String(message?.id || '')
if (!id) return true
if (existingIds.has(id)) return false
existingIds.add(id)
return true
})
allMessages.value = {
...allMessages.value,
[username]: [...older, ...existing]
}
}
trace.log('loadMessages:state-commit:end', {
storedCount: (allMessages.value[username] || []).length
})
messagesMeta.value = {
...messagesMeta.value,
[username]: {
total: Number(response?.total || 0),
hasMore: response?.hasMore
}
}
trace.log('loadMessages:meta-commit:end', {
total: Number(response?.total || 0),
hasMore: response?.hasMore
})
trace.log('loadMessages:nextTick:start')
await nextTick()
trace.log('loadMessages:nextTick:end', {
renderedCount: (allMessages.value[username] || []).length
})
const nextContainer = messageContainerRef.value
if (nextContainer) {
if (reset) {
nextContainer.scrollTop = nextContainer.scrollHeight
} else {
const afterScrollHeight = nextContainer.scrollHeight
nextContainer.scrollTop = beforeScrollTop + (afterScrollHeight - beforeScrollHeight)
}
}
updateJumpToBottomState()
trace.log('loadMessages:scroll:end', {
hasContainer: !!nextContainer,
scrollTop: nextContainer ? nextContainer.scrollTop : null,
scrollHeight: nextContainer ? nextContainer.scrollHeight : null
})
} catch (error) {
trace.log('loadMessages:error', {
message: String(error?.message || ''),
errorName: String(error?.name || '')
})
console.error('[chat-messages] loadMessages:error', {
account: String(selectedAccount.value || '').trim(),
username: String(username || '').trim(),
reset: !!reset,
error
})
messagesError.value = error?.message || '加载聊天记录失败'
} finally {
isLoadingMessages.value = false
trace.log('loadMessages:exit', {
loading: isLoadingMessages.value,
error: messagesError.value
})
}
}
const loadMoreMessages = async () => {
if (!selectedContact.value) return
if (searchContext.value?.active) return
await loadMessages({ username: selectedContact.value.username, reset: false })
}
const refreshSelectedMessages = async () => {
if (!selectedContact.value) return
bumpLocalMediaVersion()
await loadMessages({ username: selectedContact.value.username, reset: true })
}
const refreshCurrentMessageMedia = async () => {
if (!selectedContact.value?.username) return
const trace = createPerfTrace('chat-messages', {
account: String(selectedAccount.value || '').trim(),
username: String(selectedContact.value?.username || '').trim(),
action: 'refreshCurrentMessageMedia'
})
trace.log('refreshCurrentMessageMedia:start', {
localMediaVersion: Number(localMediaVersion.value || 0)
})
bumpLocalMediaVersion()
trace.log('refreshCurrentMessageMedia:version-bumped', {
localMediaVersion: Number(localMediaVersion.value || 0)
})
renormalizeLoadedMessages(selectedContact.value.username)
trace.log('refreshCurrentMessageMedia:renormalized', {
renderedCount: (allMessages.value[selectedContact.value.username] || []).length
})
await nextTick()
trace.log('refreshCurrentMessageMedia:end')
}
const refreshRealtimeIncremental = async () => {
if (!realtimeEnabled.value || !selectedAccount.value || !selectedContact.value?.username) return
if (searchContext.value?.active || isLoadingMessages.value) return
const username = selectedContact.value.username
const existing = allMessages.value[username] || []
if (!existing.length) return
const container = messageContainerRef.value
const atBottom = !!container && (container.scrollHeight - container.scrollTop - container.clientHeight) < 80
const params = {
account: selectedAccount.value,
username,
limit: 30,
offset: 0,
order: 'asc',
source: 'realtime'
}
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
params.render_types = messageTypeFilter.value
}
try {
const response = await api.listChatMessages(params)
if (selectedContact.value?.username !== username) return
const rawMessages = response?.messages || []
const latest = rawMessages.map(normalizeMessage)
const seenIds = new Set(existing.map((message) => String(message?.id || '')))
const newOnes = []
for (const message of latest) {
const id = String(message?.id || '')
if (!id || seenIds.has(id)) continue
seenIds.add(id)
newOnes.push(message)
}
if (!newOnes.length) return
allMessages.value = { ...allMessages.value, [username]: [...existing, ...newOnes] }
await nextTick()
const nextContainer = messageContainerRef.value
if (nextContainer && atBottom) {
nextContainer.scrollTop = nextContainer.scrollHeight
}
updateJumpToBottomState()
} catch (error) {
console.error('[chat-messages] refreshRealtimeIncremental:error', {
account: String(selectedAccount.value || '').trim(),
username: String(username || '').trim(),
error
})
}
}
let realtimeRefreshFuture = null
let realtimeRefreshQueued = false
const queueRealtimeRefresh = () => {
if (realtimeRefreshFuture) {
realtimeRefreshQueued = true
return
}
realtimeRefreshFuture = refreshRealtimeIncremental().finally(() => {
realtimeRefreshFuture = null
if (realtimeRefreshQueued) {
realtimeRefreshQueued = false
queueRealtimeRefresh()
}
})
}
const tryEnableRealtimeAuto = async () => {
if (!process.client || typeof window === 'undefined') return
if (!desktopAutoRealtime.value || realtimeEnabled.value || !selectedAccount.value) return
try {
await realtimeStore.enable({ silent: true })
} catch {}
}
const clearVoicePlaybackState = () => {
try {
currentPlayingVoice.value?.pause?.()
if (currentPlayingVoice.value) currentPlayingVoice.value.currentTime = 0
} catch {}
currentPlayingVoice.value = null
playingVoiceId.value = null
voiceRefs.clear()
}
const resetMessageState = () => {
clearVoicePlaybackState()
allMessages.value = {}
messagesMeta.value = {}
messagesError.value = ''
highlightMessageId.value = ''
highlightServerIdStr.value = ''
}
const contactProfileCardOpen = ref(false)
const contactProfileCardMessageId = ref('')
const contactProfileLoading = ref(false)
const contactProfileError = ref('')
const contactProfileData = ref(null)
let contactProfileHoverHideTimer = null
const contactProfileResolvedName = computed(() => {
const profile = contactProfileData.value || {}
const displayName = String(profile?.displayName || '').trim()
if (displayName) return displayName
const contactName = String(selectedContact.value?.name || '').trim()
if (contactName) return contactName
return String(profile?.username || selectedContact.value?.username || '').trim()
})
const contactProfileResolvedUsername = computed(() => {
const profile = contactProfileData.value || {}
return String(profile?.username || selectedContact.value?.username || '').trim()
})
const contactProfileResolvedNickname = computed(() => String(contactProfileData.value?.nickname || '').trim())
const contactProfileResolvedAlias = computed(() => String(contactProfileData.value?.alias || '').trim())
const contactProfileResolvedRegion = computed(() => String(contactProfileData.value?.region || '').trim())
const contactProfileResolvedRemark = computed(() => String(contactProfileData.value?.remark || '').trim())
const contactProfileResolvedSignature = computed(() => String(contactProfileData.value?.signature || '').trim())
const contactProfileResolvedSource = computed(() => String(contactProfileData.value?.source || '').trim())
const contactProfileResolvedAvatar = computed(() => {
const avatar = String(contactProfileData.value?.avatar || '').trim()
if (avatar) return avatar
return String(selectedContact.value?.avatar || '').trim()
})
const contactProfileResolvedGender = computed(() => {
const value = contactProfileData.value?.gender
if (value == null || value === '') return ''
const gender = Number(value)
if (!Number.isFinite(gender)) return ''
if (gender === 1) return '男'
if (gender === 2) return '女'
if (gender === 0) return '未知'
return String(gender)
})
const contactProfileResolvedSourceScene = computed(() => {
const value = contactProfileData.value?.sourceScene
if (value == null || value === '') return null
const scene = Number(value)
return Number.isFinite(scene) ? scene : null
})
const fetchContactProfile = async (options = {}) => {
const username = String(options?.username || contactProfileData.value?.username || selectedContact.value?.username || '').trim()
const displayNameFallback = String(options?.displayName || '').trim()
const avatarFallback = String(options?.avatar || '').trim()
const account = String(selectedAccount.value || '').trim()
if (!username || !account) {
contactProfileData.value = null
return
}
contactProfileLoading.value = true
contactProfileError.value = ''
try {
const response = await api.listChatContacts({
account,
include_friends: true,
include_groups: true,
include_officials: true
})
const list = Array.isArray(response?.contacts) ? response.contacts : []
const matched = list.find((item) => String(item?.username || '').trim() === username)
if (matched) {
const normalized = { ...matched, username }
if (!String(normalized.displayName || '').trim() && displayNameFallback) {
normalized.displayName = displayNameFallback
}
if (!String(normalized.avatar || '').trim() && avatarFallback) {
normalized.avatar = avatarFallback
}
contactProfileData.value = normalized
} else {
contactProfileData.value = {
username,
displayName: displayNameFallback || selectedContact.value?.name || username,
avatar: avatarFallback || selectedContact.value?.avatar || '',
nickname: '',
alias: '',
gender: null,
region: '',
remark: '',
signature: '',
source: '',
sourceScene: null
}
}
} catch (error) {
contactProfileData.value = {
username,
displayName: displayNameFallback || selectedContact.value?.name || username,
avatar: avatarFallback || selectedContact.value?.avatar || '',
nickname: '',
alias: '',
gender: null,
region: '',
remark: '',
signature: '',
source: '',
sourceScene: null
}
contactProfileError.value = error?.message || '加载联系人资料失败'
} finally {
contactProfileLoading.value = false
}
}
const clearContactProfileHoverHideTimer = () => {
if (contactProfileHoverHideTimer) {
clearTimeout(contactProfileHoverHideTimer)
contactProfileHoverHideTimer = null
}
}
const closeContactProfileCard = () => {
contactProfileCardOpen.value = false
contactProfileCardMessageId.value = ''
}
const onMessageAvatarMouseEnter = async (message) => {
if (!!message?.isSent) return
const messageId = String(message?.id ?? '').trim()
if (!messageId) return
const username = String(message?.senderUsername || '').trim()
if (!username || username === 'self') return
const senderName = String(message?.senderDisplayName || message?.sender || '').trim()
const senderAvatar = String(message?.avatar || '').trim()
if (!contactProfileData.value || String(contactProfileData.value?.username || '').trim() !== username) {
contactProfileData.value = {
username,
displayName: senderName || username,
avatar: senderAvatar,
nickname: '',
alias: '',
gender: null,
region: '',
remark: '',
signature: '',
source: '',
sourceScene: null
}
} else {
if (!String(contactProfileData.value?.displayName || '').trim() && senderName) {
contactProfileData.value.displayName = senderName
}
if (!String(contactProfileData.value?.avatar || '').trim() && senderAvatar) {
contactProfileData.value.avatar = senderAvatar
}
}
clearContactProfileHoverHideTimer()
contactProfileCardMessageId.value = messageId
contactProfileCardOpen.value = true
await fetchContactProfile({ username, displayName: senderName, avatar: senderAvatar })
}
const onMessageAvatarMouseLeave = () => {
clearContactProfileHoverHideTimer()
contactProfileHoverHideTimer = setTimeout(() => {
closeContactProfileCard()
}, 120)
}
const onContactCardMouseEnter = () => {
clearContactProfileHoverHideTimer()
}
watch(
() => selectedContact.value?.username,
() => {
clearContactProfileHoverHideTimer()
closeContactProfileCard()
contactProfileError.value = ''
contactProfileData.value = null
}
)
watch(
() => selectedAccount.value,
() => {
clearContactProfileHoverHideTimer()
closeContactProfileCard()
contactProfileError.value = ''
contactProfileData.value = null
}
)
onUnmounted(() => {
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = null
clearContactProfileHoverHideTimer()
clearVoicePlaybackState()
})
return {
allMessages,
messagesMeta,
messages,
renderMessages,
hasMoreMessages,
isLoadingMessages,
messagesError,
messageContainerRef,
showJumpToBottom,
messagePageSize,
messageTypeFilter,
messageTypeFilterOptions,
reverseMessageSides,
previewImageUrl,
previewVideoUrl,
previewVideoPosterUrl,
previewVideoError,
voiceRefs,
currentPlayingVoice,
playingVoiceId,
highlightServerIdStr,
highlightMessageId,
contactProfileCardOpen,
contactProfileCardMessageId,
contactProfileLoading,
contactProfileError,
contactProfileData,
contactProfileResolvedName,
contactProfileResolvedUsername,
contactProfileResolvedNickname,
contactProfileResolvedAlias,
contactProfileResolvedGender,
contactProfileResolvedRegion,
contactProfileResolvedRemark,
contactProfileResolvedSignature,
contactProfileResolvedSource,
contactProfileResolvedSourceScene,
contactProfileResolvedAvatar,
normalizeMessage,
updateJumpToBottomState,
scrollToBottom,
flashMessage,
scrollToMessageId,
openImagePreview,
closeImagePreview,
openVideoPreview,
closeVideoPreview,
onPreviewVideoError,
setVoiceRef,
playVoice,
playQuoteVoice,
getQuoteVoiceId,
getVoiceDurationInSeconds,
getVoiceWidth,
isQuotedVoice,
isQuotedImage,
isQuotedLink,
getQuotedLinkText,
onQuoteImageError,
onQuoteThumbError,
onAvatarError,
shouldShowEmojiDownload,
onEmojiDownloadClick,
onFileClick,
toggleReverseMessageSides,
loadMessages,
loadMoreMessages,
refreshSelectedMessages,
refreshCurrentMessageMedia,
refreshRealtimeIncremental,
queueRealtimeRefresh,
tryEnableRealtimeAuto,
resetMessageState,
fetchContactProfile,
clearContactProfileHoverHideTimer,
closeContactProfileCard,
onMessageAvatarMouseEnter,
onMessageAvatarMouseLeave,
onContactCardMouseEnter,
formatFileSize
}
}