mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
a1c99c8704
- 为会话加载、刷新和联系人初始化补充阶段性 trace - 为消息加载、归一化、状态提交和媒体刷新补充耗时日志 - 便于定位聊天页卡顿和加载异常
977 lines
30 KiB
JavaScript
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
|
|
}
|
|
}
|