mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-24 07:26:48 +00:00
修复 #820;支持企业用户会话显示;优化聊天页面性能
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -76,3 +76,4 @@ wechat-research-site
|
|||||||
.codex
|
.codex
|
||||||
weflow-web-offical
|
weflow-web-offical
|
||||||
/Wedecrypt
|
/Wedecrypt
|
||||||
|
/scripts/syncwcdb.py
|
||||||
@@ -3997,4 +3997,3 @@ app.on('window-all-closed', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -103,8 +103,10 @@ class AnalyticsService {
|
|||||||
if (username === 'filehelper') return false
|
if (username === 'filehelper') return false
|
||||||
if (username.startsWith('gh_')) return false
|
if (username.startsWith('gh_')) return false
|
||||||
|
|
||||||
|
if (username.toLowerCase() === 'weixin') return false
|
||||||
|
|
||||||
const excludeList = [
|
const excludeList = [
|
||||||
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||||
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
||||||
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
||||||
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ class AnnualReportService {
|
|||||||
const rows = sessionResult.sessions as Record<string, any>[]
|
const rows = sessionResult.sessions as Record<string, any>[]
|
||||||
|
|
||||||
const excludeList = [
|
const excludeList = [
|
||||||
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||||
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
||||||
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
||||||
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
||||||
@@ -185,6 +185,7 @@ class AnnualReportService {
|
|||||||
if (username === 'filehelper') return false
|
if (username === 'filehelper') return false
|
||||||
if (username.startsWith('gh_')) return false
|
if (username.startsWith('gh_')) return false
|
||||||
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
|
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
|
||||||
|
if (username.toLowerCase() === 'weixin') return false
|
||||||
|
|
||||||
for (const prefix of excludeList) {
|
for (const prefix of excludeList) {
|
||||||
if (username.startsWith(prefix) || username === prefix) return false
|
if (username.startsWith(prefix) || username === prefix) return false
|
||||||
|
|||||||
@@ -428,6 +428,9 @@ class ChatService {
|
|||||||
private contactExtendedSelectableColumns: string[] | null = null
|
private contactExtendedSelectableColumns: string[] | null = null
|
||||||
private contactLabelNameMapCache: Map<number, string> | null = null
|
private contactLabelNameMapCache: Map<number, string> | null = null
|
||||||
private contactLabelNameMapCacheAt = 0
|
private contactLabelNameMapCacheAt = 0
|
||||||
|
private readonly visibilityAnomalyLogWindowMs = 30000
|
||||||
|
private readonly visibilityAnomalyLogBurst = 3
|
||||||
|
private visibilityAnomalyLogState = new Map<string, { windowStart: number; total: number; suppressed: number }>()
|
||||||
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
|
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
|
||||||
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
|
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
|
||||||
private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>()
|
private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>()
|
||||||
@@ -480,7 +483,7 @@ class ChatService {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractErrorCode(message?: string): number | null {
|
private extractErrorCode(message?: string | null): number | null {
|
||||||
const text = String(message || '').trim()
|
const text = String(message || '').trim()
|
||||||
if (!text) return null
|
if (!text) return null
|
||||||
const match = text.match(/(?:错误码\s*[::]\s*|\()(-?\d{2,6})(?:\)|\b)/)
|
const match = text.match(/(?:错误码\s*[::]\s*|\()(-?\d{2,6})(?:\)|\b)/)
|
||||||
@@ -804,6 +807,20 @@ class ChatService {
|
|||||||
return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` }
|
return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openimLocalTypeMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(rows.map((row) =>
|
||||||
|
String(
|
||||||
|
row.username ||
|
||||||
|
row.user_name ||
|
||||||
|
row.userName ||
|
||||||
|
row.usrName ||
|
||||||
|
row.UsrName ||
|
||||||
|
row.talker ||
|
||||||
|
row.talker_id ||
|
||||||
|
row.talkerId ||
|
||||||
|
''
|
||||||
|
).trim()
|
||||||
|
))
|
||||||
|
|
||||||
// 转换为 ChatSession(先加载缓存,但不等待额外状态查询)
|
// 转换为 ChatSession(先加载缓存,但不等待额外状态查询)
|
||||||
const sessions: ChatSession[] = []
|
const sessions: ChatSession[] = []
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -821,7 +838,11 @@ class ChatService {
|
|||||||
row.talkerId ||
|
row.talkerId ||
|
||||||
''
|
''
|
||||||
|
|
||||||
if (!this.shouldKeepSession(username)) continue
|
let sessionLocalType = this.getSessionLocalType(row)
|
||||||
|
if (!Number.isFinite(sessionLocalType) && this.isEnterpriseOpenimUsername(username)) {
|
||||||
|
sessionLocalType = openimLocalTypeMap.get(username)
|
||||||
|
}
|
||||||
|
if (!this.shouldKeepSession(username, sessionLocalType)) continue
|
||||||
|
|
||||||
const sortTs = parseInt(
|
const sortTs = parseInt(
|
||||||
row.sort_timestamp ||
|
row.sort_timestamp ||
|
||||||
@@ -921,13 +942,19 @@ class ChatService {
|
|||||||
|
|
||||||
for (const row of contactResult.contacts as Record<string, any>[]) {
|
for (const row of contactResult.contacts as Record<string, any>[]) {
|
||||||
const username = String(row.username || '').trim()
|
const username = String(row.username || '').trim()
|
||||||
if (!username.startsWith('gh_') || existing.has(username)) continue
|
if (!username || existing.has(username)) continue
|
||||||
|
const lowered = username.toLowerCase()
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN)
|
||||||
|
const isOfficial = username.startsWith('gh_')
|
||||||
|
const isSpecialWeixin = lowered.startsWith('weixin') && lowered !== 'weixin'
|
||||||
|
const isSpecialOpenim = this.isAllowedEnterpriseOpenimByLocalType(username, localType)
|
||||||
|
if (!isOfficial && !isSpecialWeixin && !isSpecialOpenim) continue
|
||||||
|
|
||||||
sessions.push({
|
sessions.push({
|
||||||
username,
|
username,
|
||||||
type: 0,
|
type: 0,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
summary: '查看公众号历史消息',
|
summary: isOfficial ? '查看公众号历史消息' : '暂无会话记录',
|
||||||
sortTimestamp: 0,
|
sortTimestamp: 0,
|
||||||
lastTimestamp: 0,
|
lastTimestamp: 0,
|
||||||
lastMsgType: 0,
|
lastMsgType: 0,
|
||||||
@@ -1875,11 +1902,21 @@ class ChatService {
|
|||||||
let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other'
|
let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other'
|
||||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
|
||||||
const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim()
|
const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim()
|
||||||
|
const loweredUsername = username.toLowerCase()
|
||||||
|
const isOpenimEnterprise = this.isEnterpriseOpenimUsername(username)
|
||||||
|
if (isOpenimEnterprise && !this.isAllowedEnterpriseOpenimByLocalType(username, localType)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const isVisibleWeixinContact = loweredUsername.startsWith('weixin') && loweredUsername !== 'weixin'
|
||||||
|
|
||||||
if (username.endsWith('@chatroom')) {
|
if (username.endsWith('@chatroom')) {
|
||||||
type = 'group'
|
type = 'group'
|
||||||
} else if (username.startsWith('gh_')) {
|
} else if (username.startsWith('gh_')) {
|
||||||
type = 'official'
|
type = 'official'
|
||||||
|
} else if (isOpenimEnterprise) {
|
||||||
|
type = 'friend'
|
||||||
|
} else if (isVisibleWeixinContact) {
|
||||||
|
type = 'friend'
|
||||||
} else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) {
|
} else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) {
|
||||||
type = 'friend'
|
type = 'friend'
|
||||||
} else if (localType === 0 && quanPin) {
|
} else if (localType === 0 && quanPin) {
|
||||||
@@ -1965,7 +2002,7 @@ class ChatService {
|
|||||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchSize = Math.max(1, limit || this.messageBatchDefault)
|
const requestLimit = Math.max(1, Math.floor(limit || this.messageBatchDefault))
|
||||||
|
|
||||||
// 使用互斥锁保护游标状态访问
|
// 使用互斥锁保护游标状态访问
|
||||||
while (this.messageCursorMutex) {
|
while (this.messageCursorMutex) {
|
||||||
@@ -1988,13 +2025,14 @@ class ChatService {
|
|||||||
|
|
||||||
// 只在以下情况重新创建游标:
|
// 只在以下情况重新创建游标:
|
||||||
// 1. 没有游标状态
|
// 1. 没有游标状态
|
||||||
// 2. offset 为 0 (重新加载会话)
|
// 2. offset 变化导致游标位置不一致
|
||||||
// 3. batchSize 改变
|
// 3. startTime/endTime 改变(视为全新查询)
|
||||||
// 4. startTime/endTime 改变(视为全新查询)
|
// 4. ascending 改变
|
||||||
// 5. ascending 改变
|
//
|
||||||
|
// 注意:requestLimit 允许动态变化(前端可按“越往上拉批次越大”策略请求),
|
||||||
|
// 不应触发游标重建,否则会造成额外 reopen/skip 开销与抖动。
|
||||||
const needNewCursor = !state ||
|
const needNewCursor = !state ||
|
||||||
offset !== state.fetched || // Offset mismatch -> must reset cursor
|
offset !== state.fetched || // Offset mismatch -> must reset cursor
|
||||||
state.batchSize !== batchSize ||
|
|
||||||
state.startTime !== startTime ||
|
state.startTime !== startTime ||
|
||||||
state.endTime !== endTime ||
|
state.endTime !== endTime ||
|
||||||
state.ascending !== ascending
|
state.ascending !== ascending
|
||||||
@@ -2011,15 +2049,16 @@ class ChatService {
|
|||||||
|
|
||||||
// 创建新游标
|
// 创建新游标
|
||||||
// 注意:WeFlow 数据库中的 create_time 是以秒为单位的
|
// 注意:WeFlow 数据库中的 create_time 是以秒为单位的
|
||||||
|
const cursorBatchSize = Math.max(1, Math.floor(state?.batchSize || requestLimit || this.messageBatchDefault))
|
||||||
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
||||||
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
||||||
const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp)
|
const cursorResult = await wcdbService.openMessageCursor(sessionId, cursorBatchSize, ascending, beginTimestamp, endTimestamp)
|
||||||
if (!cursorResult.success || !cursorResult.cursor) {
|
if (!cursorResult.success || !cursorResult.cursor) {
|
||||||
console.error('[ChatService] 打开消息游标失败:', cursorResult.error)
|
console.error('[ChatService] 打开消息游标失败:', cursorResult.error)
|
||||||
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
state = { cursor: cursorResult.cursor, fetched: 0, batchSize: cursorBatchSize, startTime, endTime, ascending }
|
||||||
this.messageCursors.set(sessionId, state)
|
this.messageCursors.set(sessionId, state)
|
||||||
await this.trimMessageCursorStates(sessionId)
|
await this.trimMessageCursorStates(sessionId)
|
||||||
|
|
||||||
@@ -2030,19 +2069,53 @@ class ChatService {
|
|||||||
if (offset > 0) {
|
if (offset > 0) {
|
||||||
console.warn(`[ChatService] 新游标需跳过 ${offset} 条消息(startTime=${startTime}, endTime=${endTime})`)
|
console.warn(`[ChatService] 新游标需跳过 ${offset} 条消息(startTime=${startTime}, endTime=${endTime})`)
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
const maxSkipAttempts = Math.ceil(offset / batchSize) + 5 // 防止无限循环
|
const maxSkipAttempts = Math.ceil(offset / cursorBatchSize) + 5 // 防止无限循环
|
||||||
let attempts = 0
|
let attempts = 0
|
||||||
|
let emptySkipBatchStreak = 0
|
||||||
while (skipped < offset && attempts < maxSkipAttempts) {
|
while (skipped < offset && attempts < maxSkipAttempts) {
|
||||||
attempts++
|
attempts++
|
||||||
const skipBatch = await wcdbService.fetchMessageBatch(state.cursor)
|
const skipBatch = await wcdbService.fetchMessageBatch(state.cursor)
|
||||||
if (!skipBatch.success) {
|
if (!skipBatch.success) {
|
||||||
console.error('[ChatService] 跳过消息批次失败:', skipBatch.error)
|
console.error('[ChatService] 跳过消息批次失败:', skipBatch.error)
|
||||||
|
await this.closeMessageCursorBySession(sessionId)
|
||||||
return { success: false, error: skipBatch.error || '跳过消息失败' }
|
return { success: false, error: skipBatch.error || '跳过消息失败' }
|
||||||
}
|
}
|
||||||
if (!skipBatch.rows || skipBatch.rows.length === 0) {
|
if (!skipBatch.rows || skipBatch.rows.length === 0) {
|
||||||
|
if (skipBatch.hasMore && emptySkipBatchStreak < 2) {
|
||||||
|
emptySkipBatchStreak += 1
|
||||||
|
console.warn(
|
||||||
|
`[ChatService] 跳过遇到空批次,继续重试: streak=${emptySkipBatchStreak}, skipped=${skipped}/${offset}`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部分会话在“新游标 + offset 跳过”路径会出现首批空数据但实际仍有消息,
|
||||||
|
// 回退到稳定的 direct-offset 路径避免误判到底。
|
||||||
|
if (skipped === 0 && startTime === 0 && endTime === 0 && !ascending) {
|
||||||
|
const fallbackResult = await this.getMessagesByOffsetStable(sessionId, offset, requestLimit)
|
||||||
|
if (fallbackResult.success && Array.isArray(fallbackResult.messages)) {
|
||||||
|
await this.closeMessageCursorBySession(sessionId)
|
||||||
|
releaseMessageCursorMutex?.()
|
||||||
|
this.messageCacheService.set(sessionId, fallbackResult.messages)
|
||||||
|
console.warn(
|
||||||
|
`[ChatService] 游标跳过异常,已切换 direct-offset 兜底: session=${sessionId}, offset=${offset}, returned=${fallbackResult.messages.length}, hasMore=${fallbackResult.hasMore === true}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messages: fallbackResult.messages,
|
||||||
|
hasMore: fallbackResult.hasMore === true,
|
||||||
|
nextOffset: Number.isFinite(fallbackResult.nextOffset)
|
||||||
|
? Math.floor(fallbackResult.nextOffset as number)
|
||||||
|
: offset + fallbackResult.messages.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.warn(`[ChatService] 跳过时数据耗尽: skipped=${skipped}/${offset}`)
|
console.warn(`[ChatService] 跳过时数据耗尽: skipped=${skipped}/${offset}`)
|
||||||
|
await this.closeMessageCursorBySession(sessionId)
|
||||||
return { success: true, messages: [], hasMore: false, nextOffset: skipped }
|
return { success: true, messages: [], hasMore: false, nextOffset: skipped }
|
||||||
}
|
}
|
||||||
|
emptySkipBatchStreak = 0
|
||||||
|
|
||||||
const count = skipBatch.rows.length
|
const count = skipBatch.rows.length
|
||||||
// Check if we overshot the offset
|
// Check if we overshot the offset
|
||||||
@@ -2060,6 +2133,7 @@ class ChatService {
|
|||||||
|
|
||||||
if (!skipBatch.hasMore) {
|
if (!skipBatch.hasMore) {
|
||||||
console.warn(`[ChatService] 跳过后无更多数据: skipped=${skipped}/${offset}`)
|
console.warn(`[ChatService] 跳过后无更多数据: skipped=${skipped}/${offset}`)
|
||||||
|
await this.closeMessageCursorBySession(sessionId)
|
||||||
return { success: true, messages: [], hasMore: false, nextOffset: skipped }
|
return { success: true, messages: [], hasMore: false, nextOffset: skipped }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2080,7 +2154,7 @@ class ChatService {
|
|||||||
const collected = await this.collectVisibleMessagesFromCursor(
|
const collected = await this.collectVisibleMessagesFromCursor(
|
||||||
sessionId,
|
sessionId,
|
||||||
state.cursor,
|
state.cursor,
|
||||||
limit,
|
requestLimit,
|
||||||
state.bufferedMessages as Record<string, any>[] | undefined
|
state.bufferedMessages as Record<string, any>[] | undefined
|
||||||
)
|
)
|
||||||
state.bufferedMessages = collected.bufferedRows
|
state.bufferedMessages = collected.bufferedRows
|
||||||
@@ -2202,6 +2276,54 @@ class ChatService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getMessagesByOffsetStable(
|
||||||
|
sessionId: string,
|
||||||
|
offset: number,
|
||||||
|
limit: number
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
messages?: Message[]
|
||||||
|
hasMore?: boolean
|
||||||
|
nextOffset?: number
|
||||||
|
rawRows?: number
|
||||||
|
filteredOut?: number
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
const pageLimit = Math.max(1, Math.floor(limit || this.messageBatchDefault))
|
||||||
|
const safeOffset = Math.max(0, Math.floor(offset || 0))
|
||||||
|
const probeLimit = Math.min(500, pageLimit + 1)
|
||||||
|
|
||||||
|
const result = await wcdbService.getMessages(sessionId, probeLimit, safeOffset)
|
||||||
|
if (!result.success || !Array.isArray(result.messages)) {
|
||||||
|
return { success: false, error: result.error || '获取消息失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawRows = result.messages as Record<string, any>[]
|
||||||
|
const hasMore = rawRows.length > pageLimit
|
||||||
|
const selectedRows = hasMore ? rawRows.slice(0, pageLimit) : rawRows
|
||||||
|
const mapped = this.mapRowsToMessages(selectedRows)
|
||||||
|
const visible = mapped.filter((msg) => this.isMessageVisibleForSession(sessionId, msg))
|
||||||
|
const outputMessages = (visible.length === 0 && mapped.length > 0)
|
||||||
|
? mapped
|
||||||
|
: visible
|
||||||
|
if (visible.length === 0 && mapped.length > 0) {
|
||||||
|
console.warn(`[ChatService] getMessagesByOffsetStable 可见性过滤回退: session=${sessionId} mapped=${mapped.length}`)
|
||||||
|
}
|
||||||
|
const normalized = this.normalizeMessageOrder(outputMessages)
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
await this.repairEmojiMessages(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messages: normalized,
|
||||||
|
hasMore,
|
||||||
|
nextOffset: safeOffset + selectedRows.length,
|
||||||
|
rawRows: selectedRows.length,
|
||||||
|
filteredOut: Math.max(0, mapped.length - visible.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> {
|
async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> {
|
||||||
try {
|
try {
|
||||||
@@ -2210,31 +2332,22 @@ class ChatService {
|
|||||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 聊天页首屏优先走稳定路径:直接拉取固定窗口并做本地确定性排序,
|
// 聊天页首屏优先走稳定路径:固定 offset=0 的 direct-offset 读取。
|
||||||
// 避免游标首批在极端数据分布下出现不稳定边界。
|
const stableResult = await this.getMessagesByOffsetStable(sessionId, 0, limit)
|
||||||
const pageLimit = Math.max(1, Math.floor(limit || this.messageBatchDefault))
|
if (!stableResult.success || !Array.isArray(stableResult.messages)) {
|
||||||
const probeLimit = Math.min(500, pageLimit + 1)
|
return { success: false, error: stableResult.error || '获取最新消息失败' }
|
||||||
const result = await wcdbService.getMessages(sessionId, probeLimit, 0)
|
|
||||||
if (!result.success || !Array.isArray(result.messages)) {
|
|
||||||
return { success: false, error: result.error || '获取最新消息失败' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawRows = result.messages as Record<string, any>[]
|
|
||||||
const hasMore = rawRows.length > pageLimit
|
|
||||||
const selectedRows = hasMore ? rawRows.slice(0, pageLimit) : rawRows
|
|
||||||
const mapped = this.mapRowsToMessages(selectedRows)
|
|
||||||
const visible = mapped.filter((msg) => this.isMessageVisibleForSession(sessionId, msg))
|
|
||||||
const normalized = this.normalizeMessageOrder(visible)
|
|
||||||
await this.repairEmojiMessages(normalized)
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[ChatService] getLatestMessages(stable) session=${sessionId} rawRows=${rawRows.length} visibleMessagesReturned=${normalized.length} nextOffset=${selectedRows.length} hasMore=${hasMore}`
|
`[ChatService] getLatestMessages(stable) session=${sessionId} rawRows=${stableResult.rawRows || 0} visibleMessagesReturned=${stableResult.messages.length} filteredOut=${stableResult.filteredOut || 0} nextOffset=${stableResult.nextOffset || 0} hasMore=${stableResult.hasMore === true}`
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
messages: normalized,
|
messages: stableResult.messages,
|
||||||
hasMore,
|
hasMore: stableResult.hasMore === true,
|
||||||
nextOffset: selectedRows.length
|
nextOffset: Number.isFinite(stableResult.nextOffset)
|
||||||
|
? Math.floor(stableResult.nextOffset as number)
|
||||||
|
: stableResult.messages.length
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: 获取最新消息失败:', e)
|
console.error('ChatService: 获取最新消息失败:', e)
|
||||||
@@ -2365,18 +2478,55 @@ class ChatService {
|
|||||||
const sortSeq = Number.isFinite(input.sortSeq) ? Math.max(0, Math.floor(input.sortSeq)) : 0
|
const sortSeq = Number.isFinite(input.sortSeq) ? Math.max(0, Math.floor(input.sortSeq)) : 0
|
||||||
const localType = Number.isFinite(input.localType) ? Math.floor(input.localType) : 0
|
const localType = Number.isFinite(input.localType) ? Math.floor(input.localType) : 0
|
||||||
const senderUsername = this.encodeMessageKeySegment(input.senderUsername || '')
|
const senderUsername = this.encodeMessageKeySegment(input.senderUsername || '')
|
||||||
|
const dbPath = String(input.dbPath || '').trim()
|
||||||
const dbName = String(input.dbName || '').trim() || (input.dbPath ? basename(input.dbPath, extname(input.dbPath)) : '')
|
const dbName = String(input.dbName || '').trim() || (input.dbPath ? basename(input.dbPath, extname(input.dbPath)) : '')
|
||||||
const tableName = String(input.tableName || '').trim()
|
const tableName = String(input.tableName || '').trim()
|
||||||
|
const sourceScope = dbPath || dbName
|
||||||
|
|
||||||
if (localId > 0 && dbName && tableName) {
|
if (localId > 0 && sourceScope && tableName) {
|
||||||
return `${this.encodeMessageKeySegment(dbName)}:${this.encodeMessageKeySegment(tableName)}:${localId}`
|
return `${this.encodeMessageKeySegment(sourceScope)}:${this.encodeMessageKeySegment(tableName)}:${localId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localId > 0 && sourceScope) {
|
||||||
|
// 当底层未返回 table_name 时,避免使用 db:_:localId(会误并同库不同表的消息)。
|
||||||
|
return `local:${this.encodeMessageKeySegment(sourceScope)}:${localId}:${createTime}:${sortSeq}:${senderUsername}:${localType}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serverId > 0) {
|
if (serverId > 0) {
|
||||||
return `server:${serverId}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}`
|
const scopedServer = sourceScope ? `${this.encodeMessageKeySegment(sourceScope)}:${serverId}` : String(serverId)
|
||||||
|
return `server:${scopedServer}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `fallback:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}`
|
return `fallback:${this.encodeMessageKeySegment(sourceScope)}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private logVisibilityAnomaly(sessionId: string, msg: Message): void {
|
||||||
|
const key = String(sessionId || '').trim() || '__unknown__'
|
||||||
|
const now = Date.now()
|
||||||
|
let state = this.visibilityAnomalyLogState.get(key)
|
||||||
|
if (!state || (now - state.windowStart) > this.visibilityAnomalyLogWindowMs) {
|
||||||
|
if (state && state.suppressed > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[ChatService] 会话可见性异常日志已抑制: sessionId=${key}, suppressed=${state.suppressed}, windowMs=${this.visibilityAnomalyLogWindowMs}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state = { windowStart: now, total: 0, suppressed: 0 }
|
||||||
|
this.visibilityAnomalyLogState.set(key, state)
|
||||||
|
if (this.visibilityAnomalyLogState.size > 256) {
|
||||||
|
const oldest = this.visibilityAnomalyLogState.keys().next()
|
||||||
|
if (!oldest.done) {
|
||||||
|
this.visibilityAnomalyLogState.delete(oldest.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.total += 1
|
||||||
|
if (state.total <= this.visibilityAnomalyLogBurst) {
|
||||||
|
console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.suppressed += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
private isMessageVisibleForSession(sessionId: string, msg: Message): boolean {
|
private isMessageVisibleForSession(sessionId: string, msg: Message): boolean {
|
||||||
@@ -2390,7 +2540,7 @@ class ChatService {
|
|||||||
if (msg.isSend === 1) {
|
if (msg.isSend === 1) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
|
this.logVisibilityAnomaly(sessionId, msg)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2421,10 +2571,12 @@ class ChatService {
|
|||||||
bufferedRows?: Record<string, any>[]
|
bufferedRows?: Record<string, any>[]
|
||||||
}> {
|
}> {
|
||||||
const visibleMessages: Message[] = []
|
const visibleMessages: Message[] = []
|
||||||
|
const filteredCandidates: Message[] = []
|
||||||
let queuedRows = Array.isArray(initialRows) ? initialRows.slice() : []
|
let queuedRows = Array.isArray(initialRows) ? initialRows.slice() : []
|
||||||
let rawRowsConsumed = 0
|
let rawRowsConsumed = 0
|
||||||
let filteredOut = 0
|
let filteredOut = 0
|
||||||
let cursorMayHaveMore = queuedRows.length > 0
|
let cursorMayHaveMore = queuedRows.length > 0
|
||||||
|
let emptyBatchStreak = 0
|
||||||
|
|
||||||
while (visibleMessages.length < limit) {
|
while (visibleMessages.length < limit) {
|
||||||
if (queuedRows.length === 0) {
|
if (queuedRows.length === 0) {
|
||||||
@@ -2441,8 +2593,13 @@ class ChatService {
|
|||||||
const batchRows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
const batchRows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||||
cursorMayHaveMore = batch.hasMore === true
|
cursorMayHaveMore = batch.hasMore === true
|
||||||
if (batchRows.length === 0) {
|
if (batchRows.length === 0) {
|
||||||
|
if (cursorMayHaveMore && emptyBatchStreak < 2) {
|
||||||
|
emptyBatchStreak += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
emptyBatchStreak = 0
|
||||||
queuedRows = batchRows
|
queuedRows = batchRows
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2462,6 +2619,9 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
filteredOut += 1
|
filteredOut += 1
|
||||||
|
if (visibleMessages.length === 0 && filteredCandidates.length < limit) {
|
||||||
|
filteredCandidates.push(msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2478,8 +2638,19 @@ class ChatService {
|
|||||||
console.warn(`[ChatService] 过滤了 ${filteredOut} 条异常消息`)
|
console.warn(`[ChatService] 过滤了 ${filteredOut} 条异常消息`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = this.normalizeMessageOrder(visibleMessages)
|
let outputMessages = visibleMessages
|
||||||
|
if (outputMessages.length === 0 && filteredCandidates.length > 0) {
|
||||||
|
// 回退策略:某些会话 sender_username 与 sessionId 可能不一致,避免整批被误过滤为 0 条。
|
||||||
|
outputMessages = filteredCandidates
|
||||||
|
console.warn(
|
||||||
|
`[ChatService] 会话可见性过滤触发回退: session=${sessionId} fallbackCount=${filteredCandidates.length}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = this.normalizeMessageOrder(outputMessages)
|
||||||
|
if (normalized.length > 0) {
|
||||||
await this.repairEmojiMessages(normalized)
|
await this.repairEmojiMessages(normalized)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
messages: normalized,
|
messages: normalized,
|
||||||
@@ -6519,15 +6690,60 @@ class ChatService {
|
|||||||
return String(raw || '').replace(/\s+/g, '').trim()
|
return String(raw || '').replace(/\s+/g, '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldKeepSession(username: string): boolean {
|
private getSessionLocalType(row: Record<string, any>): number | undefined {
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN)
|
||||||
|
return Number.isFinite(localType) ? Math.floor(localType) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadContactLocalTypeMapForEnterpriseOpenim(usernames: string[]): Promise<Map<string, number>> {
|
||||||
|
const normalizedUsernames = Array.from(new Set(
|
||||||
|
(usernames || [])
|
||||||
|
.map((value) => String(value || '').trim())
|
||||||
|
.filter((value) => value && this.isEnterpriseOpenimUsername(value))
|
||||||
|
))
|
||||||
|
const localTypeMap = new Map<string, number>()
|
||||||
|
if (normalizedUsernames.length === 0) {
|
||||||
|
return localTypeMap
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const contactResult = await wcdbService.getContactsCompact(normalizedUsernames)
|
||||||
|
if (!contactResult.success || !Array.isArray(contactResult.contacts)) {
|
||||||
|
return localTypeMap
|
||||||
|
}
|
||||||
|
for (const row of contactResult.contacts as Record<string, any>[]) {
|
||||||
|
const username = String(row.username || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN)
|
||||||
|
if (!Number.isFinite(localType)) continue
|
||||||
|
localTypeMap.set(username, Math.floor(localType))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return localTypeMap
|
||||||
|
}
|
||||||
|
return localTypeMap
|
||||||
|
}
|
||||||
|
|
||||||
|
private isEnterpriseOpenimUsername(username: string): boolean {
|
||||||
|
const lowered = String(username || '').trim().toLowerCase()
|
||||||
|
return lowered.includes('@openim') && !lowered.includes('@kefu.openim')
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAllowedEnterpriseOpenimByLocalType(username: string, localType?: number): boolean {
|
||||||
|
if (!this.isEnterpriseOpenimUsername(username)) return false
|
||||||
|
return Number.isFinite(localType) && Math.floor(localType as number) === 5
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldKeepSession(username: string, localType?: number): boolean {
|
||||||
if (!username) return false
|
if (!username) return false
|
||||||
const lowered = username.toLowerCase()
|
const lowered = username.toLowerCase()
|
||||||
// 排除所有 placeholder 会话(包括折叠群)
|
// 排除所有 placeholder 会话(包括折叠群)
|
||||||
if (lowered.includes('@placeholder')) return false
|
if (lowered.includes('@placeholder')) return false
|
||||||
if (username.startsWith('gh_')) return false
|
if (username.startsWith('gh_')) return false
|
||||||
|
|
||||||
|
if (lowered === 'weixin') return false
|
||||||
|
|
||||||
const excludeList = [
|
const excludeList = [
|
||||||
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||||
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
||||||
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
||||||
'userexperience_alarm', 'helper_folders',
|
'userexperience_alarm', 'helper_folders',
|
||||||
@@ -6538,7 +6754,11 @@ class ChatService {
|
|||||||
if (username.startsWith(prefix) || username === prefix) return false
|
if (username.startsWith(prefix) || username === prefix) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (username.includes('@kefu.openim') || username.includes('@openim')) return false
|
if (username.includes('@kefu.openim')) return false
|
||||||
|
// 全局约束:企业 openim 仅允许 localType=5。
|
||||||
|
if (this.isEnterpriseOpenimUsername(username)) {
|
||||||
|
return this.isAllowedEnterpriseOpenimByLocalType(username, localType)
|
||||||
|
}
|
||||||
if (username.includes('service_')) return false
|
if (username.includes('service_')) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -8618,6 +8838,7 @@ class ChatService {
|
|||||||
let groupSessionIds = Array.isArray(options?.groupSessionIds)
|
let groupSessionIds = Array.isArray(options?.groupSessionIds)
|
||||||
? options!.groupSessionIds!.map((value) => String(value || '').trim()).filter(Boolean)
|
? options!.groupSessionIds!.map((value) => String(value || '').trim()).filter(Boolean)
|
||||||
: []
|
: []
|
||||||
|
const privateSessionLocalTypeMap = new Map<string, number>()
|
||||||
const hasExplicitGroupScope = Array.isArray(options?.groupSessionIds)
|
const hasExplicitGroupScope = Array.isArray(options?.groupSessionIds)
|
||||||
&& options!.groupSessionIds!.some((value) => String(value || '').trim().length > 0)
|
&& options!.groupSessionIds!.some((value) => String(value || '').trim().length > 0)
|
||||||
|
|
||||||
@@ -8626,26 +8847,46 @@ class ChatService {
|
|||||||
if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) {
|
if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) {
|
||||||
return { success: false, error: sessionsResult.error || '读取会话列表失败' }
|
return { success: false, error: sessionsResult.error || '读取会话列表失败' }
|
||||||
}
|
}
|
||||||
|
const openimLocalTypeMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(
|
||||||
|
(sessionsResult.sessions as Array<Record<string, any>>).map((session) => String(session.username || session.user_name || '').trim())
|
||||||
|
)
|
||||||
for (const session of sessionsResult.sessions as Array<Record<string, any>>) {
|
for (const session of sessionsResult.sessions as Array<Record<string, any>>) {
|
||||||
const sessionId = String(session.username || session.user_name || '').trim()
|
const sessionId = String(session.username || session.user_name || '').trim()
|
||||||
if (!sessionId) continue
|
if (!sessionId) continue
|
||||||
|
let sessionLocalType = this.getSessionLocalType(session)
|
||||||
|
if (!Number.isFinite(sessionLocalType) && this.isEnterpriseOpenimUsername(sessionId)) {
|
||||||
|
sessionLocalType = openimLocalTypeMap.get(sessionId)
|
||||||
|
}
|
||||||
|
if (typeof sessionLocalType === 'number' && Number.isFinite(sessionLocalType)) {
|
||||||
|
privateSessionLocalTypeMap.set(sessionId, sessionLocalType)
|
||||||
|
}
|
||||||
const sessionLastTs = this.normalizeTimestampSeconds(
|
const sessionLastTs = this.normalizeTimestampSeconds(
|
||||||
Number(session.lastTimestamp || session.sortTimestamp || 0)
|
Number(session.lastTimestamp || session.sortTimestamp || 0)
|
||||||
)
|
)
|
||||||
if (sessionId.endsWith('@chatroom')) {
|
if (sessionId.endsWith('@chatroom')) {
|
||||||
groupSessionIds.push(sessionId)
|
groupSessionIds.push(sessionId)
|
||||||
} else {
|
} else {
|
||||||
if (!this.shouldKeepSession(sessionId)) continue
|
if (!this.shouldKeepSession(sessionId, sessionLocalType)) continue
|
||||||
if (begin > 0 && sessionLastTs > 0 && sessionLastTs < begin) continue
|
if (begin > 0 && sessionLastTs > 0 && sessionLastTs < begin) continue
|
||||||
privateSessionIds.push(sessionId)
|
privateSessionIds.push(sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unresolvedOpenimPrivateSessionIds = privateSessionIds.filter((value) =>
|
||||||
|
this.isEnterpriseOpenimUsername(value) && !privateSessionLocalTypeMap.has(value)
|
||||||
|
)
|
||||||
|
if (unresolvedOpenimPrivateSessionIds.length > 0) {
|
||||||
|
const fallbackMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(unresolvedOpenimPrivateSessionIds)
|
||||||
|
for (const [username, localType] of fallbackMap.entries()) {
|
||||||
|
privateSessionLocalTypeMap.set(username, localType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
privateSessionIds = Array.from(new Set(
|
privateSessionIds = Array.from(new Set(
|
||||||
privateSessionIds
|
privateSessionIds
|
||||||
.map((value) => String(value || '').trim())
|
.map((value) => String(value || '').trim())
|
||||||
.filter((value) => value && !value.endsWith('@chatroom') && this.shouldKeepSession(value))
|
.filter((value) => value && !value.endsWith('@chatroom') && this.shouldKeepSession(value, privateSessionLocalTypeMap.get(value)))
|
||||||
))
|
))
|
||||||
groupSessionIds = Array.from(new Set(
|
groupSessionIds = Array.from(new Set(
|
||||||
groupSessionIds
|
groupSessionIds
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -22,7 +22,7 @@ export function GlobalSessionMonitor() {
|
|||||||
// 去重辅助函数:获取消息 key
|
// 去重辅助函数:获取消息 key
|
||||||
const getMessageKey = (msg: Message) => {
|
const getMessageKey = (msg: Message) => {
|
||||||
if (msg.messageKey) return msg.messageKey
|
if (msg.messageKey) return msg.messageKey
|
||||||
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理数据库变更
|
// 处理数据库变更
|
||||||
|
|||||||
@@ -72,11 +72,146 @@ const GLOBAL_MSG_SEARCH_CANCELED_ERROR = '__WEFLOW_GLOBAL_MSG_SEARCH_CANCELED__'
|
|||||||
const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2
|
const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2
|
||||||
const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare'
|
const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare'
|
||||||
const MESSAGE_LIST_SCROLL_IDLE_MS = 160
|
const MESSAGE_LIST_SCROLL_IDLE_MS = 160
|
||||||
const MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS = 160
|
const MESSAGE_TOP_EDGE_LOAD_COOLDOWN_MS = 160
|
||||||
const MESSAGE_EDGE_TRIGGER_DISTANCE_PX = 96
|
const MESSAGE_EDGE_TRIGGER_DISTANCE_PX = 96
|
||||||
|
const MESSAGE_HISTORY_INITIAL_LIMIT = 50
|
||||||
|
const MESSAGE_HISTORY_HEAVY_UNREAD_INITIAL_LIMIT = 70
|
||||||
|
const MESSAGE_HISTORY_GROWTH_STEP = 20
|
||||||
|
const MESSAGE_HISTORY_MAX_LIMIT = 180
|
||||||
|
const MESSAGE_VIRTUAL_OVERSCAN_PX = 140
|
||||||
|
const BYTES_PER_MEGABYTE = 1024 * 1024
|
||||||
|
const EMOJI_CACHE_MAX_ENTRIES = 260
|
||||||
|
const EMOJI_CACHE_MAX_BYTES = 32 * BYTES_PER_MEGABYTE
|
||||||
|
const IMAGE_CACHE_MAX_ENTRIES = 360
|
||||||
|
const IMAGE_CACHE_MAX_BYTES = 64 * BYTES_PER_MEGABYTE
|
||||||
|
const VOICE_CACHE_MAX_ENTRIES = 120
|
||||||
|
const VOICE_CACHE_MAX_BYTES = 24 * BYTES_PER_MEGABYTE
|
||||||
|
const VOICE_TRANSCRIPT_CACHE_MAX_ENTRIES = 1800
|
||||||
|
const VOICE_TRANSCRIPT_CACHE_MAX_BYTES = 2 * BYTES_PER_MEGABYTE
|
||||||
|
const SENDER_AVATAR_CACHE_MAX_ENTRIES = 2000
|
||||||
|
const AUTO_MEDIA_TASK_MAX_CONCURRENCY = 2
|
||||||
|
const AUTO_MEDIA_TASK_MAX_QUEUE = 80
|
||||||
|
|
||||||
type RequestIdleCallbackCompat = (callback: () => void, options?: { timeout?: number }) => number
|
type RequestIdleCallbackCompat = (callback: () => void, options?: { timeout?: number }) => number
|
||||||
|
|
||||||
|
type BoundedCacheOptions<V> = {
|
||||||
|
maxEntries: number
|
||||||
|
maxBytes?: number
|
||||||
|
estimate?: (value: V) => number
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoundedCache<V> = {
|
||||||
|
get: (key: string) => V | undefined
|
||||||
|
set: (key: string, value: V) => void
|
||||||
|
has: (key: string) => boolean
|
||||||
|
delete: (key: string) => boolean
|
||||||
|
clear: () => void
|
||||||
|
readonly size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateStringBytes(value: string): number {
|
||||||
|
return Math.max(0, value.length * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBoundedCache<V>(options: BoundedCacheOptions<V>): BoundedCache<V> {
|
||||||
|
const { maxEntries, maxBytes, estimate } = options
|
||||||
|
const storage = new Map<string, V>()
|
||||||
|
const valueSizes = new Map<string, number>()
|
||||||
|
let currentBytes = 0
|
||||||
|
|
||||||
|
const estimateSize = (value: V): number => {
|
||||||
|
if (!estimate) return 1
|
||||||
|
const raw = estimate(value)
|
||||||
|
if (!Number.isFinite(raw) || raw <= 0) return 1
|
||||||
|
return Math.max(1, Math.round(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeKey = (key: string): boolean => {
|
||||||
|
if (!storage.has(key)) return false
|
||||||
|
const previousSize = valueSizes.get(key) || 0
|
||||||
|
currentBytes = Math.max(0, currentBytes - previousSize)
|
||||||
|
valueSizes.delete(key)
|
||||||
|
return storage.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = (key: string, value: V) => {
|
||||||
|
storage.delete(key)
|
||||||
|
storage.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prune = () => {
|
||||||
|
const shouldPruneByBytes = Number.isFinite(maxBytes) && (maxBytes as number) > 0
|
||||||
|
while (storage.size > maxEntries || (shouldPruneByBytes && currentBytes > (maxBytes as number))) {
|
||||||
|
const oldestKey = storage.keys().next().value as string | undefined
|
||||||
|
if (!oldestKey) break
|
||||||
|
removeKey(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(key: string) {
|
||||||
|
const value = storage.get(key)
|
||||||
|
if (value === undefined) return undefined
|
||||||
|
touch(key, value)
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
set(key: string, value: V) {
|
||||||
|
const nextSize = estimateSize(value)
|
||||||
|
if (storage.has(key)) {
|
||||||
|
const previousSize = valueSizes.get(key) || 0
|
||||||
|
currentBytes = Math.max(0, currentBytes - previousSize)
|
||||||
|
}
|
||||||
|
storage.set(key, value)
|
||||||
|
valueSizes.set(key, nextSize)
|
||||||
|
currentBytes += nextSize
|
||||||
|
prune()
|
||||||
|
},
|
||||||
|
has(key: string) {
|
||||||
|
return storage.has(key)
|
||||||
|
},
|
||||||
|
delete(key: string) {
|
||||||
|
return removeKey(key)
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
storage.clear()
|
||||||
|
valueSizes.clear()
|
||||||
|
currentBytes = 0
|
||||||
|
},
|
||||||
|
get size() {
|
||||||
|
return storage.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoMediaTaskQueue: Array<() => void> = []
|
||||||
|
let autoMediaTaskRunningCount = 0
|
||||||
|
|
||||||
|
function enqueueAutoMediaTask<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const runTask = () => {
|
||||||
|
autoMediaTaskRunningCount += 1
|
||||||
|
task()
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
.finally(() => {
|
||||||
|
autoMediaTaskRunningCount = Math.max(0, autoMediaTaskRunningCount - 1)
|
||||||
|
const next = autoMediaTaskQueue.shift()
|
||||||
|
if (next) next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoMediaTaskRunningCount < AUTO_MEDIA_TASK_MAX_CONCURRENCY) {
|
||||||
|
runTask()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (autoMediaTaskQueue.length >= AUTO_MEDIA_TASK_MAX_QUEUE) {
|
||||||
|
reject(new Error('AUTO_MEDIA_TASK_QUEUE_FULL'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autoMediaTaskQueue.push(runTask)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleWhenIdle(task: () => void, options?: { timeout?: number; fallbackDelay?: number }): void {
|
function scheduleWhenIdle(task: () => void, options?: { timeout?: number; fallbackDelay?: number }): void {
|
||||||
const requestIdleCallbackFn = (
|
const requestIdleCallbackFn = (
|
||||||
globalThis as typeof globalThis & { requestIdleCallback?: RequestIdleCallbackCompat }
|
globalThis as typeof globalThis & { requestIdleCallback?: RequestIdleCallbackCompat }
|
||||||
@@ -1293,7 +1428,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
const getMessageKey = useCallback((msg: Message): string => {
|
const getMessageKey = useCallback((msg: Message): string => {
|
||||||
if (msg.messageKey) return msg.messageKey
|
if (msg.messageKey) return msg.messageKey
|
||||||
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||||
}, [])
|
}, [])
|
||||||
const initialRevealTimerRef = useRef<number | null>(null)
|
const initialRevealTimerRef = useRef<number | null>(null)
|
||||||
const sessionListRef = useRef<HTMLDivElement>(null)
|
const sessionListRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -1473,6 +1608,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const searchKeywordRef = useRef('')
|
const searchKeywordRef = useRef('')
|
||||||
const preloadImageKeysRef = useRef<Set<string>>(new Set())
|
const preloadImageKeysRef = useRef<Set<string>>(new Set())
|
||||||
const lastPreloadSessionRef = useRef<string | null>(null)
|
const lastPreloadSessionRef = useRef<string | null>(null)
|
||||||
|
const messageMediaPreloadTimerRef = useRef<number | null>(null)
|
||||||
const detailRequestSeqRef = useRef(0)
|
const detailRequestSeqRef = useRef(0)
|
||||||
const groupMembersRequestSeqRef = useRef(0)
|
const groupMembersRequestSeqRef = useRef(0)
|
||||||
const groupMembersPanelCacheRef = useRef<Map<string, GroupMembersPanelCacheEntry>>(new Map())
|
const groupMembersPanelCacheRef = useRef<Map<string, GroupMembersPanelCacheEntry>>(new Map())
|
||||||
@@ -2793,6 +2929,11 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [loadMyAvatar, resolveChatCacheScope])
|
}, [loadMyAvatar, resolveChatCacheScope])
|
||||||
|
|
||||||
const handleAccountChanged = useCallback(async () => {
|
const handleAccountChanged = useCallback(async () => {
|
||||||
|
emojiDataUrlCache.clear()
|
||||||
|
imageDataUrlCache.clear()
|
||||||
|
voiceDataUrlCache.clear()
|
||||||
|
voiceTranscriptCache.clear()
|
||||||
|
imageDecryptInFlight.clear()
|
||||||
senderAvatarCache.clear()
|
senderAvatarCache.clear()
|
||||||
senderAvatarLoading.clear()
|
senderAvatarLoading.clear()
|
||||||
quotedSenderDisplayCache.clear()
|
quotedSenderDisplayCache.clear()
|
||||||
@@ -2804,6 +2945,10 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
sessionContactEnrichAttemptAtRef.current.clear()
|
sessionContactEnrichAttemptAtRef.current.clear()
|
||||||
preloadImageKeysRef.current.clear()
|
preloadImageKeysRef.current.clear()
|
||||||
lastPreloadSessionRef.current = null
|
lastPreloadSessionRef.current = null
|
||||||
|
if (messageMediaPreloadTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(messageMediaPreloadTimerRef.current)
|
||||||
|
messageMediaPreloadTimerRef.current = null
|
||||||
|
}
|
||||||
pendingSessionLoadRef.current = null
|
pendingSessionLoadRef.current = null
|
||||||
initialLoadRequestedSessionRef.current = null
|
initialLoadRequestedSessionRef.current = null
|
||||||
sessionSwitchRequestSeqRef.current += 1
|
sessionSwitchRequestSeqRef.current += 1
|
||||||
@@ -3321,8 +3466,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setIsRefreshingMessages(false)
|
setIsRefreshingMessages(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 消息批量大小控制(保持稳定,避免游标反复重建)
|
// 消息批量大小控制(会话内逐步增大,减少频繁触顶加载)
|
||||||
const currentBatchSizeRef = useRef(50)
|
const currentBatchSizeRef = useRef(MESSAGE_HISTORY_INITIAL_LIMIT)
|
||||||
|
|
||||||
const warmupGroupSenderProfiles = useCallback((usernames: string[], defer = false) => {
|
const warmupGroupSenderProfiles = useCallback((usernames: string[], defer = false) => {
|
||||||
if (!Array.isArray(usernames) || usernames.length === 0) return
|
if (!Array.isArray(usernames) || usernames.length === 0) return
|
||||||
@@ -3386,14 +3531,21 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
let messageLimit: number
|
let messageLimit: number
|
||||||
|
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
|
const defaultInitialLimit = unreadCount > 99
|
||||||
|
? MESSAGE_HISTORY_HEAVY_UNREAD_INITIAL_LIMIT
|
||||||
|
: MESSAGE_HISTORY_INITIAL_LIMIT
|
||||||
const preferredLimit = Number.isFinite(options.forceInitialLimit)
|
const preferredLimit = Number.isFinite(options.forceInitialLimit)
|
||||||
? Math.max(10, Math.floor(options.forceInitialLimit as number))
|
? Math.max(10, Math.floor(options.forceInitialLimit as number))
|
||||||
: (unreadCount > 99 ? 30 : 40)
|
: defaultInitialLimit
|
||||||
currentBatchSizeRef.current = preferredLimit
|
currentBatchSizeRef.current = Math.min(preferredLimit, MESSAGE_HISTORY_MAX_LIMIT)
|
||||||
messageLimit = preferredLimit
|
|
||||||
} else {
|
|
||||||
// 同一会话内保持固定批量,避免后端游标因 batch 改变而重建
|
|
||||||
messageLimit = currentBatchSizeRef.current
|
messageLimit = currentBatchSizeRef.current
|
||||||
|
} else {
|
||||||
|
const grownBatchSize = Math.min(
|
||||||
|
Math.max(currentBatchSizeRef.current, MESSAGE_HISTORY_INITIAL_LIMIT) + MESSAGE_HISTORY_GROWTH_STEP,
|
||||||
|
MESSAGE_HISTORY_MAX_LIMIT
|
||||||
|
)
|
||||||
|
currentBatchSizeRef.current = grownBatchSize
|
||||||
|
messageLimit = grownBatchSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4099,7 +4251,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
void loadMessages(normalizedSessionId, 0, 0, 0, false, {
|
void loadMessages(normalizedSessionId, 0, 0, 0, false, {
|
||||||
preferLatestPath: true,
|
preferLatestPath: true,
|
||||||
deferGroupSenderWarmup: true,
|
deferGroupSenderWarmup: true,
|
||||||
forceInitialLimit: 30,
|
forceInitialLimit: MESSAGE_HISTORY_INITIAL_LIMIT,
|
||||||
switchRequestSeq
|
switchRequestSeq
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -4590,24 +4742,40 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow))
|
setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow))
|
||||||
}, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching])
|
}, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching])
|
||||||
|
|
||||||
|
const triggerTopEdgeHistoryLoad = useCallback((): boolean => {
|
||||||
|
if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreMessages) return false
|
||||||
|
const listEl = messageListRef.current
|
||||||
|
if (!listEl) return false
|
||||||
|
const distanceFromTop = Math.max(0, listEl.scrollTop)
|
||||||
|
if (distanceFromTop > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return false
|
||||||
|
if (topRangeLoadLockRef.current) return false
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - topRangeLoadLastTriggerAtRef.current < MESSAGE_TOP_EDGE_LOAD_COOLDOWN_MS) return false
|
||||||
|
topRangeLoadLastTriggerAtRef.current = now
|
||||||
|
topRangeLoadLockRef.current = true
|
||||||
|
isMessageListAtBottomRef.current = false
|
||||||
|
void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
||||||
|
return true
|
||||||
|
}, [
|
||||||
|
currentSessionId,
|
||||||
|
isLoadingMore,
|
||||||
|
isLoadingMessages,
|
||||||
|
hasMoreMessages,
|
||||||
|
loadMessages,
|
||||||
|
currentOffset,
|
||||||
|
jumpStartTime,
|
||||||
|
jumpEndTime
|
||||||
|
])
|
||||||
|
|
||||||
const handleMessageListWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
const handleMessageListWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
markMessageListScrolling()
|
markMessageListScrolling()
|
||||||
if (!currentSessionId || isLoadingMore || isLoadingMessages) return
|
if (!currentSessionId || isLoadingMore || isLoadingMessages) return
|
||||||
const listEl = messageListRef.current
|
const listEl = messageListRef.current
|
||||||
if (!listEl) return
|
if (!listEl) return
|
||||||
const distanceFromTop = listEl.scrollTop
|
|
||||||
const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)
|
const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)
|
||||||
|
|
||||||
if (event.deltaY <= -18) {
|
if (event.deltaY <= -18) {
|
||||||
if (!hasMoreMessages) return
|
triggerTopEdgeHistoryLoad()
|
||||||
if (distanceFromTop > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return
|
|
||||||
if (topRangeLoadLockRef.current) return
|
|
||||||
const now = Date.now()
|
|
||||||
if (now - topRangeLoadLastTriggerAtRef.current < MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS) return
|
|
||||||
topRangeLoadLastTriggerAtRef.current = now
|
|
||||||
topRangeLoadLockRef.current = true
|
|
||||||
isMessageListAtBottomRef.current = false
|
|
||||||
void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4623,22 +4791,21 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [
|
}, [
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
hasMoreLater,
|
hasMoreLater,
|
||||||
hasMoreMessages,
|
|
||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
currentOffset,
|
|
||||||
jumpStartTime,
|
|
||||||
jumpEndTime,
|
|
||||||
markMessageListScrolling,
|
markMessageListScrolling,
|
||||||
loadMessages,
|
loadLaterMessages,
|
||||||
loadLaterMessages
|
triggerTopEdgeHistoryLoad
|
||||||
])
|
])
|
||||||
|
|
||||||
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
|
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
|
||||||
if (!atTop) {
|
if (!atTop) {
|
||||||
topRangeLoadLockRef.current = false
|
topRangeLoadLockRef.current = false
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}, [])
|
// 支持拖动右侧滚动条到顶部时直接触发加载,不依赖滚轮事件。
|
||||||
|
triggerTopEdgeHistoryLoad()
|
||||||
|
}, [triggerTopEdgeHistoryLoad])
|
||||||
|
|
||||||
|
|
||||||
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
|
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
|
||||||
@@ -4791,6 +4958,10 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
window.clearTimeout(messageListScrollTimeoutRef.current)
|
window.clearTimeout(messageListScrollTimeoutRef.current)
|
||||||
messageListScrollTimeoutRef.current = null
|
messageListScrollTimeoutRef.current = null
|
||||||
}
|
}
|
||||||
|
if (messageMediaPreloadTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(messageMediaPreloadTimerRef.current)
|
||||||
|
messageMediaPreloadTimerRef.current = null
|
||||||
|
}
|
||||||
isMessageListScrollingRef.current = false
|
isMessageListScrollingRef.current = false
|
||||||
contactUpdateQueueRef.current.clear()
|
contactUpdateQueueRef.current.clear()
|
||||||
pendingSessionContactEnrichRef.current.clear()
|
pendingSessionContactEnrichRef.current.clear()
|
||||||
@@ -4861,9 +5032,18 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [currentSessionId])
|
}, [currentSessionId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (messageMediaPreloadTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(messageMediaPreloadTimerRef.current)
|
||||||
|
messageMediaPreloadTimerRef.current = null
|
||||||
|
}
|
||||||
if (!currentSessionId || messages.length === 0) return
|
if (!currentSessionId || messages.length === 0) return
|
||||||
const preloadEdgeCount = 40
|
|
||||||
const maxPreload = 30
|
messageMediaPreloadTimerRef.current = window.setTimeout(() => {
|
||||||
|
messageMediaPreloadTimerRef.current = null
|
||||||
|
scheduleWhenIdle(() => {
|
||||||
|
if (isMessageListScrollingRef.current) return
|
||||||
|
const preloadEdgeCount = 20
|
||||||
|
const maxPreload = 12
|
||||||
const head = messages.slice(0, preloadEdgeCount)
|
const head = messages.slice(0, preloadEdgeCount)
|
||||||
const tail = messages.slice(-preloadEdgeCount)
|
const tail = messages.slice(-preloadEdgeCount)
|
||||||
const candidates = [...head, ...tail]
|
const candidates = [...head, ...tail]
|
||||||
@@ -4892,6 +5072,15 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
allowCacheIndex: false
|
allowCacheIndex: false
|
||||||
}).catch(() => { })
|
}).catch(() => { })
|
||||||
}
|
}
|
||||||
|
}, { timeout: 1400, fallbackDelay: 120 })
|
||||||
|
}, 120)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (messageMediaPreloadTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(messageMediaPreloadTimerRef.current)
|
||||||
|
messageMediaPreloadTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [currentSessionId, messages])
|
}, [currentSessionId, messages])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -4987,7 +5176,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
void loadMessages(currentSessionId, 0, 0, 0, false, {
|
void loadMessages(currentSessionId, 0, 0, 0, false, {
|
||||||
preferLatestPath: true,
|
preferLatestPath: true,
|
||||||
deferGroupSenderWarmup: true,
|
deferGroupSenderWarmup: true,
|
||||||
forceInitialLimit: 30
|
forceInitialLimit: MESSAGE_HISTORY_INITIAL_LIMIT
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
}, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
||||||
@@ -5120,6 +5309,18 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSessionSortTime = (session: Pick<ChatSession, 'sortTimestamp' | 'lastTimestamp'>) =>
|
||||||
|
Number(session.sortTimestamp || session.lastTimestamp || 0)
|
||||||
|
const insertSessionByTimeDesc = (list: ChatSession[], entry: ChatSession) => {
|
||||||
|
const entryTime = getSessionSortTime(entry)
|
||||||
|
const insertIndex = list.findIndex(s => getSessionSortTime(s) < entryTime)
|
||||||
|
if (insertIndex === -1) {
|
||||||
|
list.push(entry)
|
||||||
|
} else {
|
||||||
|
list.splice(insertIndex, 0, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const officialSessions = sessions.filter(s => s.username.startsWith('gh_'))
|
const officialSessions = sessions.filter(s => s.username.startsWith('gh_'))
|
||||||
|
|
||||||
// 检查是否有折叠的群聊
|
// 检查是否有折叠的群聊
|
||||||
@@ -5134,11 +5335,12 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
const latestOfficial = officialSessions.reduce<ChatSession | null>((latest, current) => {
|
const latestOfficial = officialSessions.reduce<ChatSession | null>((latest, current) => {
|
||||||
if (!latest) return current
|
if (!latest) return current
|
||||||
const latestTime = latest.sortTimestamp || latest.lastTimestamp
|
const latestTime = getSessionSortTime(latest)
|
||||||
const currentTime = current.sortTimestamp || current.lastTimestamp
|
const currentTime = getSessionSortTime(current)
|
||||||
return currentTime > latestTime ? current : latest
|
return currentTime > latestTime ? current : latest
|
||||||
}, null)
|
}, null)
|
||||||
const officialUnreadCount = officialSessions.reduce((sum, s) => sum + (s.unreadCount || 0), 0)
|
const officialUnreadCount = officialSessions.reduce((sum, s) => sum + (s.unreadCount || 0), 0)
|
||||||
|
const officialLatestTime = latestOfficial ? getSessionSortTime(latestOfficial) : 0
|
||||||
|
|
||||||
const bizEntry: ChatSession = {
|
const bizEntry: ChatSession = {
|
||||||
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
|
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
|
||||||
@@ -5147,8 +5349,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}`
|
? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}`
|
||||||
: '查看公众号历史消息',
|
: '查看公众号历史消息',
|
||||||
type: 0,
|
type: 0,
|
||||||
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
|
sortTimestamp: officialLatestTime,
|
||||||
lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0,
|
lastTimestamp: officialLatestTime,
|
||||||
lastMsgType: latestOfficial?.lastMsgType || 0,
|
lastMsgType: latestOfficial?.lastMsgType || 0,
|
||||||
unreadCount: officialUnreadCount,
|
unreadCount: officialUnreadCount,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
@@ -5156,7 +5358,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!visible.some(s => s.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)) {
|
if (!visible.some(s => s.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)) {
|
||||||
visible.unshift(bizEntry)
|
insertSessionByTimeDesc(visible, bizEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
|
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
|
||||||
@@ -5180,17 +5382,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
isFolded: false
|
isFolded: false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按时间戳插入到正确位置
|
insertSessionByTimeDesc(visible, foldEntry)
|
||||||
const foldTime = foldEntry.sortTimestamp || foldEntry.lastTimestamp
|
|
||||||
const insertIndex = visible.findIndex(s => {
|
|
||||||
const sTime = s.sortTimestamp || s.lastTimestamp
|
|
||||||
return sTime < foldTime
|
|
||||||
})
|
|
||||||
if (insertIndex === -1) {
|
|
||||||
visible.push(foldEntry)
|
|
||||||
} else {
|
|
||||||
visible.splice(insertIndex, 0, foldEntry)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!searchKeyword.trim()) {
|
if (!searchKeyword.trim()) {
|
||||||
@@ -7078,7 +7270,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
className="message-virtuoso"
|
className="message-virtuoso"
|
||||||
customScrollParent={messageListScrollParent ?? undefined}
|
customScrollParent={messageListScrollParent ?? undefined}
|
||||||
data={messages}
|
data={messages}
|
||||||
overscan={220}
|
overscan={MESSAGE_VIRTUAL_OVERSCAN_PX}
|
||||||
followOutput={(atBottom) => (
|
followOutput={(atBottom) => (
|
||||||
prependingHistoryRef.current
|
prependingHistoryRef.current
|
||||||
? false
|
? false
|
||||||
@@ -8022,10 +8214,26 @@ const globalVoiceManager = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 前端表情包缓存
|
// 前端表情包缓存
|
||||||
const emojiDataUrlCache = new Map<string, string>()
|
const emojiDataUrlCache = createBoundedCache<string>({
|
||||||
const imageDataUrlCache = new Map<string, string>()
|
maxEntries: EMOJI_CACHE_MAX_ENTRIES,
|
||||||
const voiceDataUrlCache = new Map<string, string>()
|
maxBytes: EMOJI_CACHE_MAX_BYTES,
|
||||||
const voiceTranscriptCache = new Map<string, string>()
|
estimate: estimateStringBytes
|
||||||
|
})
|
||||||
|
const imageDataUrlCache = createBoundedCache<string>({
|
||||||
|
maxEntries: IMAGE_CACHE_MAX_ENTRIES,
|
||||||
|
maxBytes: IMAGE_CACHE_MAX_BYTES,
|
||||||
|
estimate: estimateStringBytes
|
||||||
|
})
|
||||||
|
const voiceDataUrlCache = createBoundedCache<string>({
|
||||||
|
maxEntries: VOICE_CACHE_MAX_ENTRIES,
|
||||||
|
maxBytes: VOICE_CACHE_MAX_BYTES,
|
||||||
|
estimate: estimateStringBytes
|
||||||
|
})
|
||||||
|
const voiceTranscriptCache = createBoundedCache<string>({
|
||||||
|
maxEntries: VOICE_TRANSCRIPT_CACHE_MAX_ENTRIES,
|
||||||
|
maxBytes: VOICE_TRANSCRIPT_CACHE_MAX_BYTES,
|
||||||
|
estimate: estimateStringBytes
|
||||||
|
})
|
||||||
type SharedImageDecryptResult = {
|
type SharedImageDecryptResult = {
|
||||||
success: boolean
|
success: boolean
|
||||||
localPath?: string
|
localPath?: string
|
||||||
@@ -8034,7 +8242,9 @@ type SharedImageDecryptResult = {
|
|||||||
failureKind?: 'not_found' | 'decrypt_failed'
|
failureKind?: 'not_found' | 'decrypt_failed'
|
||||||
}
|
}
|
||||||
const imageDecryptInFlight = new Map<string, Promise<SharedImageDecryptResult>>()
|
const imageDecryptInFlight = new Map<string, Promise<SharedImageDecryptResult>>()
|
||||||
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?: string }>({
|
||||||
|
maxEntries: SENDER_AVATAR_CACHE_MAX_ENTRIES
|
||||||
|
})
|
||||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
||||||
|
|
||||||
function getSharedImageDecryptTask(
|
function getSharedImageDecryptTask(
|
||||||
@@ -8088,7 +8298,7 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
|
|||||||
|
|
||||||
if (error || (!loading && !localPath)) return <span className="quoted-type-label">[动画表情]</span>
|
if (error || (!loading && !localPath)) return <span className="quoted-type-label">[动画表情]</span>
|
||||||
if (loading) return <span className="quoted-type-label">[动画表情]</span>
|
if (loading) return <span className="quoted-type-label">[动画表情]</span>
|
||||||
return <img src={localPath} alt="动画表情" className="quoted-emoji-image" />
|
return <img src={localPath} alt="动画表情" className="quoted-emoji-image" loading="lazy" decoding="async" />
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息气泡组件
|
// 消息气泡组件
|
||||||
@@ -8191,7 +8401,10 @@ function MessageBubble({
|
|||||||
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0)
|
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0)
|
||||||
const [voiceDuration, setVoiceDuration] = useState(0)
|
const [voiceDuration, setVoiceDuration] = useState(0)
|
||||||
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
||||||
|
const [voiceWaveformRequested, setVoiceWaveformRequested] = useState(false)
|
||||||
const voiceAutoDecryptTriggered = useRef(false)
|
const voiceAutoDecryptTriggered = useRef(false)
|
||||||
|
const pendingScrollerDeltaRef = useRef(0)
|
||||||
|
const pendingScrollerDeltaRafRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
|
||||||
const [systemAlert, setSystemAlert] = useState<{
|
const [systemAlert, setSystemAlert] = useState<{
|
||||||
@@ -8282,7 +8495,7 @@ function MessageBubble({
|
|||||||
|
|
||||||
const stabilizeScrollerByDelta = useCallback((host: HTMLElement | null, delta: number) => {
|
const stabilizeScrollerByDelta = useCallback((host: HTMLElement | null, delta: number) => {
|
||||||
if (!host) return
|
if (!host) return
|
||||||
if (!Number.isFinite(delta) || Math.abs(delta) < 1) return
|
if (!Number.isFinite(delta) || Math.abs(delta) < 1.5) return
|
||||||
const scroller = host.closest('.message-list') as HTMLDivElement | null
|
const scroller = host.closest('.message-list') as HTMLDivElement | null
|
||||||
if (!scroller) return
|
if (!scroller) return
|
||||||
|
|
||||||
@@ -8295,7 +8508,17 @@ function MessageBubble({
|
|||||||
const viewportBottom = scroller.scrollTop + scroller.clientHeight
|
const viewportBottom = scroller.scrollTop + scroller.clientHeight
|
||||||
if (hostTopInScroller > viewportBottom + 24) return
|
if (hostTopInScroller > viewportBottom + 24) return
|
||||||
|
|
||||||
scroller.scrollTop += delta
|
pendingScrollerDeltaRef.current += delta
|
||||||
|
if (pendingScrollerDeltaRafRef.current !== null) return
|
||||||
|
pendingScrollerDeltaRafRef.current = window.requestAnimationFrame(() => {
|
||||||
|
pendingScrollerDeltaRafRef.current = null
|
||||||
|
const applyDelta = pendingScrollerDeltaRef.current
|
||||||
|
pendingScrollerDeltaRef.current = 0
|
||||||
|
if (!Number.isFinite(applyDelta) || Math.abs(applyDelta) < 1.5) return
|
||||||
|
const nextScroller = host.closest('.message-list') as HTMLDivElement | null
|
||||||
|
if (!nextScroller) return
|
||||||
|
nextScroller.scrollTop += applyDelta
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const bindResizeObserverForHost = useCallback((
|
const bindResizeObserverForHost = useCallback((
|
||||||
@@ -8386,12 +8609,12 @@ function MessageBubble({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isImage) return
|
if (!isImage) return
|
||||||
return bindResizeObserverForHost(imageContainerRef.current, imageObservedHeightRef, imageResizeBaselineRef)
|
return bindResizeObserverForHost(imageContainerRef.current, imageObservedHeightRef, imageResizeBaselineRef)
|
||||||
}, [isImage, imageLocalPath, imageLoading, imageError, bindResizeObserverForHost])
|
}, [isImage, bindResizeObserverForHost])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEmoji) return
|
if (!isEmoji) return
|
||||||
return bindResizeObserverForHost(emojiContainerRef.current, emojiObservedHeightRef, emojiResizeBaselineRef)
|
return bindResizeObserverForHost(emojiContainerRef.current, emojiObservedHeightRef, emojiResizeBaselineRef)
|
||||||
}, [isEmoji, emojiLocalPath, emojiLoading, emojiError, bindResizeObserverForHost])
|
}, [isEmoji, bindResizeObserverForHost])
|
||||||
|
|
||||||
// 下载表情包
|
// 下载表情包
|
||||||
const downloadEmoji = () => {
|
const downloadEmoji = () => {
|
||||||
@@ -8572,13 +8795,13 @@ function MessageBubble({
|
|||||||
return { success: false }
|
return { success: false }
|
||||||
}, [isImage, message.imageMd5, message.imageDatName, message.createTime, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
|
}, [isImage, message.imageMd5, message.imageDatName, message.createTime, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
|
||||||
|
|
||||||
const triggerForceHd = useCallback(() => {
|
const triggerForceHd = useCallback(async (): Promise<void> => {
|
||||||
if (!message.imageMd5 && !message.imageDatName) return
|
if (!message.imageMd5 && !message.imageDatName) return
|
||||||
if (imageForceHdAttempted.current === imageCacheKey) return
|
if (imageForceHdAttempted.current === imageCacheKey) return
|
||||||
if (imageForceHdPending.current) return
|
if (imageForceHdPending.current) return
|
||||||
imageForceHdAttempted.current = imageCacheKey
|
imageForceHdAttempted.current = imageCacheKey
|
||||||
imageForceHdPending.current = true
|
imageForceHdPending.current = true
|
||||||
requestImageDecrypt(true, true).finally(() => {
|
await requestImageDecrypt(true, true).finally(() => {
|
||||||
imageForceHdPending.current = false
|
imageForceHdPending.current = false
|
||||||
})
|
})
|
||||||
}, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt])
|
}, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt])
|
||||||
@@ -8666,6 +8889,11 @@ function MessageBubble({
|
|||||||
if (imageClickTimerRef.current) {
|
if (imageClickTimerRef.current) {
|
||||||
window.clearTimeout(imageClickTimerRef.current)
|
window.clearTimeout(imageClickTimerRef.current)
|
||||||
}
|
}
|
||||||
|
if (pendingScrollerDeltaRafRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(pendingScrollerDeltaRafRef.current)
|
||||||
|
pendingScrollerDeltaRafRef.current = null
|
||||||
|
}
|
||||||
|
pendingScrollerDeltaRef.current = 0
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -8799,14 +9027,16 @@ function MessageBubble({
|
|||||||
if (!message.imageMd5 && !message.imageDatName) return
|
if (!message.imageMd5 && !message.imageDatName) return
|
||||||
if (imageAutoDecryptTriggered.current) return
|
if (imageAutoDecryptTriggered.current) return
|
||||||
imageAutoDecryptTriggered.current = true
|
imageAutoDecryptTriggered.current = true
|
||||||
void requestImageDecrypt()
|
void enqueueAutoMediaTask(async () => requestImageDecrypt()).catch(() => { })
|
||||||
}, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, requestImageDecrypt])
|
}, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, requestImageDecrypt])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isImage || !imageHasUpdate || !imageInView) return
|
if (!isImage || !imageHasUpdate || !imageInView) return
|
||||||
if (imageAutoHdTriggered.current === imageCacheKey) return
|
if (imageAutoHdTriggered.current === imageCacheKey) return
|
||||||
imageAutoHdTriggered.current = imageCacheKey
|
imageAutoHdTriggered.current = imageCacheKey
|
||||||
triggerForceHd()
|
void enqueueAutoMediaTask(async () => {
|
||||||
|
await triggerForceHd()
|
||||||
|
}).catch(() => { })
|
||||||
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
|
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
|
||||||
|
|
||||||
|
|
||||||
@@ -8848,30 +9078,36 @@ function MessageBubble({
|
|||||||
|
|
||||||
// 生成波形数据
|
// 生成波形数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!voiceDataUrl) {
|
if (!voiceDataUrl || !voiceWaveformRequested) {
|
||||||
setVoiceWaveform([])
|
setVoiceWaveform([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let audioCtx: AudioContext | null = null
|
||||||
|
|
||||||
const generateWaveform = async () => {
|
const generateWaveform = async () => {
|
||||||
try {
|
try {
|
||||||
// 从 data:audio/wav;base64,... 提取 base64
|
// 从 data:audio/wav;base64,... 提取 base64
|
||||||
const base64 = voiceDataUrl.split(',')[1]
|
const base64 = voiceDataUrl.split(',')[1]
|
||||||
|
if (!base64) return
|
||||||
const binaryString = window.atob(base64)
|
const binaryString = window.atob(base64)
|
||||||
const bytes = new Uint8Array(binaryString.length)
|
const bytes = new Uint8Array(binaryString.length)
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
bytes[i] = binaryString.charCodeAt(i)
|
bytes[i] = binaryString.charCodeAt(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||||
const audioBuffer = await audioCtx.decodeAudioData(bytes.buffer)
|
const audioBuffer = await audioCtx.decodeAudioData(bytes.buffer)
|
||||||
|
if (cancelled) return
|
||||||
const rawData = audioBuffer.getChannelData(0) // 获取单声道数据
|
const rawData = audioBuffer.getChannelData(0) // 获取单声道数据
|
||||||
const samples = 35 // 波形柱子数量
|
const samples = 24 // 波形柱子数量(降低解码计算成本)
|
||||||
const blockSize = Math.floor(rawData.length / samples)
|
const blockSize = Math.floor(rawData.length / samples)
|
||||||
|
if (blockSize <= 0) return
|
||||||
const filteredData: number[] = []
|
const filteredData: number[] = []
|
||||||
|
|
||||||
for (let i = 0; i < samples; i++) {
|
for (let i = 0; i < samples; i++) {
|
||||||
let blockStart = blockSize * i
|
const blockStart = blockSize * i
|
||||||
let sum = 0
|
let sum = 0
|
||||||
for (let j = 0; j < blockSize; j++) {
|
for (let j = 0; j < blockSize; j++) {
|
||||||
sum = sum + Math.abs(rawData[blockStart + j])
|
sum = sum + Math.abs(rawData[blockStart + j])
|
||||||
@@ -8880,19 +9116,39 @@ function MessageBubble({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 归一化
|
// 归一化
|
||||||
const multiplier = Math.pow(Math.max(...filteredData), -1)
|
const peak = Math.max(...filteredData)
|
||||||
|
if (!Number.isFinite(peak) || peak <= 0) return
|
||||||
|
const multiplier = Math.pow(peak, -1)
|
||||||
const normalizedData = filteredData.map(n => n * multiplier)
|
const normalizedData = filteredData.map(n => n * multiplier)
|
||||||
|
if (!cancelled) {
|
||||||
setVoiceWaveform(normalizedData)
|
setVoiceWaveform(normalizedData)
|
||||||
void audioCtx.close()
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to generate waveform:', e)
|
console.error('Failed to generate waveform:', e)
|
||||||
// 降级:生成随机但平滑的波形
|
// 降级:生成随机但平滑的波形
|
||||||
setVoiceWaveform(Array.from({ length: 35 }, () => 0.2 + Math.random() * 0.8))
|
if (!cancelled) {
|
||||||
|
setVoiceWaveform(Array.from({ length: 24 }, () => 0.2 + Math.random() * 0.8))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (audioCtx) {
|
||||||
|
void audioCtx.close().catch(() => { })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleWhenIdle(() => {
|
||||||
|
if (cancelled) return
|
||||||
void generateWaveform()
|
void generateWaveform()
|
||||||
}, [voiceDataUrl])
|
}, { timeout: 900, fallbackDelay: 80 })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (audioCtx) {
|
||||||
|
void audioCtx.close().catch(() => { })
|
||||||
|
audioCtx = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [voiceDataUrl, voiceWaveformRequested])
|
||||||
|
|
||||||
// 消息加载时自动检测语音缓存
|
// 消息加载时自动检测语音缓存
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -9076,7 +9332,9 @@ function MessageBubble({
|
|||||||
if (videoAutoLoadTriggered.current) return
|
if (videoAutoLoadTriggered.current) return
|
||||||
|
|
||||||
videoAutoLoadTriggered.current = true
|
videoAutoLoadTriggered.current = true
|
||||||
void requestVideoInfo()
|
void enqueueAutoMediaTask(async () => requestVideoInfo()).catch(() => {
|
||||||
|
videoAutoLoadTriggered.current = false
|
||||||
|
})
|
||||||
}, [isVideo, isVideoVisible, videoInfo, requestVideoInfo])
|
}, [isVideo, isVideoVisible, videoInfo, requestVideoInfo])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -9395,6 +9653,8 @@ function MessageBubble({
|
|||||||
src={imageLocalPath}
|
src={imageLocalPath}
|
||||||
alt="图片"
|
alt="图片"
|
||||||
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
|
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
onClick={() => { void handleOpenImageViewer() }}
|
onClick={() => { void handleOpenImageViewer() }}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
setImageLoaded(true)
|
setImageLoaded(true)
|
||||||
@@ -9473,7 +9733,7 @@ function MessageBubble({
|
|||||||
return (
|
return (
|
||||||
<div className="video-thumb-wrapper" ref={videoContainerRef as React.RefObject<HTMLDivElement>} onClick={handlePlayVideo}>
|
<div className="video-thumb-wrapper" ref={videoContainerRef as React.RefObject<HTMLDivElement>} onClick={handlePlayVideo}>
|
||||||
{thumbSrc ? (
|
{thumbSrc ? (
|
||||||
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" />
|
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" loading="lazy" decoding="async" />
|
||||||
) : (
|
) : (
|
||||||
<div className="video-thumb-placeholder">
|
<div className="video-thumb-placeholder">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
@@ -9493,6 +9753,9 @@ function MessageBubble({
|
|||||||
const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : ''
|
const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : ''
|
||||||
const handleToggle = async () => {
|
const handleToggle = async () => {
|
||||||
if (voiceLoading) return
|
if (voiceLoading) return
|
||||||
|
if (!voiceWaveformRequested) {
|
||||||
|
setVoiceWaveformRequested(true)
|
||||||
|
}
|
||||||
const audio = voiceAudioRef.current || new Audio()
|
const audio = voiceAudioRef.current || new Audio()
|
||||||
if (!voiceAudioRef.current) {
|
if (!voiceAudioRef.current) {
|
||||||
voiceAudioRef.current = audio
|
voiceAudioRef.current = audio
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import type { ChatSession, Message, Contact } from '../types/models'
|
|||||||
|
|
||||||
const messageAliasIndex = new Set<string>()
|
const messageAliasIndex = new Set<string>()
|
||||||
|
|
||||||
function buildPrimaryMessageKey(message: Message): string {
|
function buildPrimaryMessageKey(message: Message, sourceScope?: string): string {
|
||||||
if (message.messageKey) return String(message.messageKey)
|
if (message.messageKey) return String(message.messageKey)
|
||||||
return `fallback:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}`
|
const normalizedSourceScope = sourceScope ?? String(message._db_path || '').trim()
|
||||||
|
return `fallback:${normalizedSourceScope}:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessageAliasKeys(message: Message): string[] {
|
function buildMessageAliasKeys(message: Message): string[] {
|
||||||
const keys = [buildPrimaryMessageKey(message)]
|
const sourceScope = String(message._db_path || '').trim()
|
||||||
|
const keys = [buildPrimaryMessageKey(message, sourceScope)]
|
||||||
const localId = Math.max(0, Number(message.localId || 0))
|
const localId = Math.max(0, Number(message.localId || 0))
|
||||||
const serverId = Math.max(0, Number(message.serverId || 0))
|
const serverId = Math.max(0, Number(message.serverId || 0))
|
||||||
const createTime = Math.max(0, Number(message.createTime || 0))
|
const createTime = Math.max(0, Number(message.createTime || 0))
|
||||||
@@ -18,15 +20,26 @@ function buildMessageAliasKeys(message: Message): string[] {
|
|||||||
const isSend = Number(message.isSend ?? -1)
|
const isSend = Number(message.isSend ?? -1)
|
||||||
|
|
||||||
if (localId > 0) {
|
if (localId > 0) {
|
||||||
keys.push(`lid:${localId}`)
|
// 跨 message_*.db 时 local_id 可能重复,必须带分库上下文避免误去重。
|
||||||
|
if (sourceScope) {
|
||||||
|
keys.push(`lid:${sourceScope}:${localId}`)
|
||||||
|
} else {
|
||||||
|
// 缺库信息时使用更保守组合,尽量避免把不同消息误判成重复。
|
||||||
|
keys.push(`lid_fallback:${localId}:${createTime}:${sender}:${localType}:${serverId}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (serverId > 0) {
|
if (serverId > 0) {
|
||||||
keys.push(`sid:${serverId}`)
|
// server_id 在跨库场景并非绝对全局唯一;必须带来源作用域避免误去重。
|
||||||
|
if (sourceScope) {
|
||||||
|
keys.push(`sid:${sourceScope}:${serverId}`)
|
||||||
|
} else {
|
||||||
|
keys.push(`sid_fallback:${serverId}:${createTime}:${sender}:${localType}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (localType === 3) {
|
if (localType === 3) {
|
||||||
const imageIdentity = String(message.imageMd5 || message.imageDatName || '').trim()
|
const imageIdentity = String(message.imageMd5 || message.imageDatName || '').trim()
|
||||||
if (imageIdentity) {
|
if (imageIdentity) {
|
||||||
keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
keys.push(`img:${sourceScope}:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +50,9 @@ function rebuildMessageAliasIndex(messages: Message[]): void {
|
|||||||
messageAliasIndex.clear()
|
messageAliasIndex.clear()
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
const aliasKeys = buildMessageAliasKeys(message)
|
const aliasKeys = buildMessageAliasKeys(message)
|
||||||
aliasKeys.forEach((key) => messageAliasIndex.add(key))
|
for (const key of aliasKeys) {
|
||||||
|
messageAliasIndex.add(key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +151,18 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const filtered: Message[] = []
|
const filtered: Message[] = []
|
||||||
newMessages.forEach((msg) => {
|
newMessages.forEach((msg) => {
|
||||||
const aliasKeys = buildMessageAliasKeys(msg)
|
const aliasKeys = buildMessageAliasKeys(msg)
|
||||||
const exists = aliasKeys.some((key) => messageAliasIndex.has(key))
|
let exists = false
|
||||||
|
for (const key of aliasKeys) {
|
||||||
|
if (messageAliasIndex.has(key)) {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if (exists) return
|
if (exists) return
|
||||||
filtered.push(msg)
|
filtered.push(msg)
|
||||||
aliasKeys.forEach((key) => messageAliasIndex.add(key))
|
for (const key of aliasKeys) {
|
||||||
|
messageAliasIndex.add(key)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (filtered.length === 0) return state
|
if (filtered.length === 0) return state
|
||||||
|
|||||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -1221,4 +1221,3 @@ declare global {
|
|||||||
|
|
||||||
export { }
|
export { }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user