mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
feat: 优化聊天记录缓存、虚拟分页与群聊媒体展示
- 使用持久化缓存加速启动并异步读取 WCDB 消息 - 修复空群缓存、头像和群成员名称丢失问题 - 修复引用图片缩略图、虚拟卸载缓存和图片预览 - 修正引用消息发送者显示为群 ID 的问题
This commit is contained in:
+67
-21
@@ -17,7 +17,7 @@ import { extname } from 'path'
|
|||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
import { WechatDb } from './wechat-db'
|
import { WechatDb } from './wechat-db'
|
||||||
import { bootstrapWcdbNative, Wcdb4Client } from './wcdb4-client'
|
import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
|
||||||
import { VoiceService } from './voice-service'
|
import { VoiceService } from './voice-service'
|
||||||
import { StickerService } from './sticker-service'
|
import { StickerService } from './sticker-service'
|
||||||
import { parseMessageContent } from './message-parser'
|
import { parseMessageContent } from './message-parser'
|
||||||
@@ -61,12 +61,15 @@ import {
|
|||||||
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
||||||
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
|
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
|
||||||
import {
|
import {
|
||||||
|
flushBootstrapCacheWritesSync,
|
||||||
getBootstrapCache,
|
getBootstrapCache,
|
||||||
|
getCachedMessagePage,
|
||||||
getCachedMessages,
|
getCachedMessages,
|
||||||
mergeBootstrapAvatars,
|
mergeBootstrapAvatars,
|
||||||
mergeCachedContactAvatars,
|
mergeCachedContactAvatars,
|
||||||
saveBootstrapContacts,
|
saveBootstrapContacts,
|
||||||
saveBootstrapSelf,
|
saveBootstrapSelf,
|
||||||
|
saveCachedGroupSnapshot,
|
||||||
saveCachedMessages
|
saveCachedMessages
|
||||||
} from './services/bootstrap-cache'
|
} from './services/bootstrap-cache'
|
||||||
import { installSafeConsole } from './safe-log'
|
import { installSafeConsole } from './safe-log'
|
||||||
@@ -95,16 +98,23 @@ const keyServiceWin = new KeyServiceWin()
|
|||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||||
let recallProtectionGeneration = 0
|
let recallProtectionGeneration = 0
|
||||||
|
let recallJournalTimer: NodeJS.Timeout | null = null
|
||||||
|
let wcdbBootstrapPromise: Promise<unknown> | null = null
|
||||||
|
|
||||||
function configureRecallProtection(
|
function configureRecallProtection(
|
||||||
wcdb4Client: Wcdb4Client,
|
wcdb4Client: Wcdb4Client,
|
||||||
accountRoot: string,
|
accountRoot: string,
|
||||||
enabled: boolean
|
enabled: boolean,
|
||||||
|
installJournal = false
|
||||||
): void {
|
): void {
|
||||||
recallProtectionGeneration += 1
|
recallProtectionGeneration += 1
|
||||||
const generation = recallProtectionGeneration
|
const generation = recallProtectionGeneration
|
||||||
recallArchiveMonitor?.stop()
|
recallArchiveMonitor?.stop()
|
||||||
recallArchiveMonitor = null
|
recallArchiveMonitor = null
|
||||||
|
if (recallJournalTimer) {
|
||||||
|
clearTimeout(recallJournalTimer)
|
||||||
|
recallJournalTimer = null
|
||||||
|
}
|
||||||
configureRecallArchive(enabled ? accountRoot : '')
|
configureRecallArchive(enabled ? accountRoot : '')
|
||||||
if (!enabled) return
|
if (!enabled) return
|
||||||
|
|
||||||
@@ -128,8 +138,19 @@ function configureRecallProtection(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
recallArchiveMonitor = monitor
|
recallArchiveMonitor = monitor
|
||||||
|
// Session indexing is not required to open the UI. Defer it so large databases
|
||||||
|
// do not make db:init wait for every conversation to be enumerated.
|
||||||
|
setImmediate(() => {
|
||||||
|
if (generation === recallProtectionGeneration && recallArchiveMonitor === monitor) {
|
||||||
monitor.seedAll()
|
monitor.seedAll()
|
||||||
setTimeout(() => {
|
}
|
||||||
|
})
|
||||||
|
if (!installJournal) return
|
||||||
|
// Creating recall triggers scans every message table and runs synchronously in
|
||||||
|
// the main process. Only do this after the user explicitly enables protection;
|
||||||
|
// existing installations remain active without repeating the scan at startup.
|
||||||
|
recallJournalTimer = setTimeout(() => {
|
||||||
|
recallJournalTimer = null
|
||||||
if (generation !== recallProtectionGeneration || recallArchiveMonitor !== monitor) return
|
if (generation !== recallProtectionGeneration || recallArchiveMonitor !== monitor) return
|
||||||
const result = wcdb4Client.installRecallJournal(
|
const result = wcdb4Client.installRecallJournal(
|
||||||
wcdb4Client.getSessions().map((session) => session.username)
|
wcdb4Client.getSessions().map((session) => session.username)
|
||||||
@@ -137,7 +158,7 @@ function configureRecallProtection(
|
|||||||
console.log(
|
console.log(
|
||||||
`[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}`
|
`[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}`
|
||||||
)
|
)
|
||||||
}, 0)
|
}, 30_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
||||||
@@ -305,15 +326,11 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
|
// Create the renderer before native WCDB bootstrap so startup progress is visible immediately.
|
||||||
// per process. Bootstrap native once here so any later Wcdb4Client instance
|
createWindow()
|
||||||
// reuses the already-initialized library and skips wcdb_init.
|
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
|
||||||
try {
|
console.log('[WCDB4] async bootstrap complete')
|
||||||
bootstrapWcdbNative()
|
})
|
||||||
console.log('[WCDB4] bootstrap complete at whenReady top')
|
|
||||||
} catch (bootstrapError) {
|
|
||||||
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置应用程序用户模型 ID
|
// 设置应用程序用户模型 ID
|
||||||
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
||||||
@@ -338,6 +355,7 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
dbInitInFlight = (async () => {
|
dbInitInFlight = (async () => {
|
||||||
try {
|
try {
|
||||||
|
if (wcdbBootstrapPromise) await wcdbBootstrapPromise
|
||||||
const trimmedKey = String(key || '').trim()
|
const trimmedKey = String(key || '').trim()
|
||||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
@@ -372,6 +390,13 @@ app.whenReady().then(async () => {
|
|||||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
setImmediate(() => {
|
||||||
|
const recentSession = wcdb4Client.getSessions()[0]
|
||||||
|
if (!recentSession?.username) return
|
||||||
|
void wcdb4Client
|
||||||
|
.getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 })
|
||||||
|
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
|
||||||
|
})
|
||||||
imageDecryptService = null
|
imageDecryptService = null
|
||||||
return { success: true, monitoring }
|
return { success: true, monitoring }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -512,11 +537,26 @@ app.whenReady().then(async () => {
|
|||||||
return getBootstrapCache(chat.getCurrentAccountRoot())
|
return getBootstrapCache(chat.getCurrentAccountRoot())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('db:getStartupCache', () => {
|
||||||
|
const settings = loadSettings()
|
||||||
|
return settings.dbRoot ? getBootstrapCache(settings.dbRoot) : null
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'db:getCachedMessages',
|
'db:getCachedMessages',
|
||||||
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||||
if (!chat.isReady()) return []
|
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
|
||||||
return getCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime)
|
return accountRoot ? getCachedMessages(accountRoot, userMd5, startTime, endTime) : []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'db:getCachedMessagePage',
|
||||||
|
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||||
|
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
|
||||||
|
return accountRoot
|
||||||
|
? getCachedMessagePage(accountRoot, userMd5, startTime, endTime)
|
||||||
|
: { hit: false, messages: [] }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -539,8 +579,8 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'db:getMessages',
|
'db:getMessages',
|
||||||
(_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
|
async (_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
|
||||||
const messages = chat.listMessages(userMd5, startTime, endTime, options)
|
const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
|
||||||
if (chat.isReady()) {
|
if (chat.isReady()) {
|
||||||
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
||||||
}
|
}
|
||||||
@@ -548,7 +588,13 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
|
ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => {
|
||||||
|
const snapshot = await chat.getGroupSnapshotAsync(userMd5)
|
||||||
|
if (snapshot && chat.isReady()) {
|
||||||
|
saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||||
|
|
||||||
@@ -824,7 +870,8 @@ app.whenReady().then(async () => {
|
|||||||
configureRecallProtection(
|
configureRecallProtection(
|
||||||
currentDb.getWcdb4Client(),
|
currentDb.getWcdb4Client(),
|
||||||
chat.getCurrentAccountRoot(),
|
chat.getCurrentAccountRoot(),
|
||||||
nextSettings.recallProtectionEnabled
|
nextSettings.recallProtectionEnabled,
|
||||||
|
nextSettings.recallProtectionEnabled && !before.recallProtectionEnabled
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -938,8 +985,6 @@ app.whenReady().then(async () => {
|
|||||||
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
||||||
})
|
})
|
||||||
|
|
||||||
createWindow()
|
|
||||||
|
|
||||||
// 启动本地 HTTP API(由 settings.apiEnabled 控制)
|
// 启动本地 HTTP API(由 settings.apiEnabled 控制)
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
if (settings.apiEnabled) {
|
if (settings.apiEnabled) {
|
||||||
@@ -972,6 +1017,7 @@ app.on('window-all-closed', () => {
|
|||||||
|
|
||||||
app.on('before-quit', async () => {
|
app.on('before-quit', async () => {
|
||||||
agentHubService.stop()
|
agentHubService.stop()
|
||||||
|
flushBootstrapCacheWritesSync()
|
||||||
chat.setChatDb(null)
|
chat.setChatDb(null)
|
||||||
await apiServer.stop().catch(() => undefined)
|
await apiServer.stop().catch(() => undefined)
|
||||||
if (tray) {
|
if (tray) {
|
||||||
|
|||||||
@@ -11,6 +11,19 @@ export interface CachedSelfInfo {
|
|||||||
accountRoot: string
|
accountRoot: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CachedGroupSnapshot {
|
||||||
|
roomId: string
|
||||||
|
memberCount: number
|
||||||
|
members: {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
interface CachedMessageBucket {
|
interface CachedMessageBucket {
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
startTime?: number
|
startTime?: number
|
||||||
@@ -26,11 +39,12 @@ interface BootstrapCacheFile {
|
|||||||
self?: CachedSelfInfo
|
self?: CachedSelfInfo
|
||||||
contacts?: Contact[]
|
contacts?: Contact[]
|
||||||
messages?: Record<string, CachedMessageBucket>
|
messages?: Record<string, CachedMessageBucket>
|
||||||
|
groupSnapshots?: Record<string, { updatedAt: number; snapshot: CachedGroupSnapshot }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_VERSION = 1
|
const CACHE_VERSION = 1
|
||||||
const MAX_MESSAGE_BUCKETS = 24
|
const MAX_MESSAGE_BUCKETS = 768
|
||||||
const MAX_MESSAGES_PER_BUCKET = 1200
|
const MAX_MESSAGES_PER_BUCKET = 120
|
||||||
const WRITE_DEBOUNCE_MS = 300
|
const WRITE_DEBOUNCE_MS = 300
|
||||||
const memoryCache = new Map<string, BootstrapCacheFile>()
|
const memoryCache = new Map<string, BootstrapCacheFile>()
|
||||||
const writeTimers = new Map<string, NodeJS.Timeout>()
|
const writeTimers = new Map<string, NodeJS.Timeout>()
|
||||||
@@ -73,7 +87,9 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
|||||||
updatedAt: Number(raw.updatedAt) || 0,
|
updatedAt: Number(raw.updatedAt) || 0,
|
||||||
self: raw.self,
|
self: raw.self,
|
||||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
||||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
|
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {},
|
||||||
|
groupSnapshots:
|
||||||
|
raw.groupSnapshots && typeof raw.groupSnapshots === 'object' ? raw.groupSnapshots : {}
|
||||||
}
|
}
|
||||||
memoryCache.set(file, result)
|
memoryCache.set(file, result)
|
||||||
return result
|
return result
|
||||||
@@ -122,7 +138,8 @@ function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
|
|||||||
accountRoot: normalizedRoot,
|
accountRoot: normalizedRoot,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
contacts: [],
|
contacts: [],
|
||||||
messages: {}
|
messages: {},
|
||||||
|
groupSnapshots: {}
|
||||||
}
|
}
|
||||||
memoryCache.set(getCacheFile(normalizedRoot), created)
|
memoryCache.set(getCacheFile(normalizedRoot), created)
|
||||||
return created
|
return created
|
||||||
@@ -132,6 +149,12 @@ function messageBucketKey(userMd5: string, startTime?: number, endTime?: number)
|
|||||||
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
|
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cachedMessageIdentity(message: Message): string {
|
||||||
|
if (message.localId) return `local:${message.localId}`
|
||||||
|
if (message.serverId) return `server:${message.serverId}`
|
||||||
|
return `id:${message.id}`
|
||||||
|
}
|
||||||
|
|
||||||
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
||||||
const entries = Object.entries(messages)
|
const entries = Object.entries(messages)
|
||||||
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
||||||
@@ -260,6 +283,71 @@ export function getCachedMessages(
|
|||||||
return bucket?.items || []
|
return bucket?.items || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCachedMessagePage(
|
||||||
|
accountRoot: string,
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } {
|
||||||
|
const cache = readCacheFile(accountRoot)
|
||||||
|
const key = messageBucketKey(userMd5, startTime, endTime)
|
||||||
|
let bucket = cache?.messages?.[key]
|
||||||
|
if (!bucket && cache?.messages && startTime === undefined && endTime === undefined) {
|
||||||
|
const merged = new Map<string, Message>()
|
||||||
|
for (const [cachedKey, candidate] of Object.entries(cache.messages)) {
|
||||||
|
if (!cachedKey.startsWith(`${userMd5}:`)) continue
|
||||||
|
for (const message of candidate.items || []) {
|
||||||
|
merged.set(cachedMessageIdentity(message), message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const migratedMessages = Array.from(merged.values())
|
||||||
|
.sort((left, right) => (left.createTime || 0) - (right.createTime || 0))
|
||||||
|
.slice(-MAX_MESSAGES_PER_BUCKET)
|
||||||
|
if (migratedMessages.length > 0) {
|
||||||
|
bucket = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
items: migratedMessages
|
||||||
|
}
|
||||||
|
cache.messages[key] = bucket
|
||||||
|
cache.updatedAt = Date.now()
|
||||||
|
pruneMessageBuckets(cache.messages)
|
||||||
|
writeCacheFile(cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hit: Boolean(bucket),
|
||||||
|
messages: bucket?.items || [],
|
||||||
|
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCachedGroupSnapshot(
|
||||||
|
accountRoot: string,
|
||||||
|
userMd5: string,
|
||||||
|
snapshot: CachedGroupSnapshot
|
||||||
|
): void {
|
||||||
|
const cache = loadOrCreate(accountRoot)
|
||||||
|
if (!cache) return
|
||||||
|
cache.groupSnapshots ||= {}
|
||||||
|
cache.groupSnapshots[userMd5] = { updatedAt: Date.now(), snapshot }
|
||||||
|
cache.updatedAt = Date.now()
|
||||||
|
writeCacheFile(cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flushBootstrapCacheWritesSync(): void {
|
||||||
|
for (const [file, cache] of memoryCache) {
|
||||||
|
const timer = writeTimers.get(file)
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
writeTimers.delete(file)
|
||||||
|
try {
|
||||||
|
fs.ensureDirSync(path.dirname(file))
|
||||||
|
fs.writeFileSync(file, JSON.stringify(cache), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[BootstrapCache] flush failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function saveCachedMessages(
|
export function saveCachedMessages(
|
||||||
accountRoot: string,
|
accountRoot: string,
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ export function listContacts(filter?: string): FormattedContact[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The session list already covers normal conversations. Only scan Chat_*
|
||||||
|
// tables as a recovery fallback when the session query returned nothing.
|
||||||
|
if (userList.length === 0) {
|
||||||
const chatTables = dbRef.getAllChatTables()
|
const chatTables = dbRef.getAllChatTables()
|
||||||
for (const table of chatTables) {
|
for (const table of chatTables) {
|
||||||
if (!table.name.startsWith('Chat_')) continue
|
if (!table.name.startsWith('Chat_')) continue
|
||||||
@@ -164,6 +167,7 @@ export function listContacts(filter?: string): FormattedContact[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return contacts
|
return contacts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +184,8 @@ function listSourceMessages(
|
|||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number,
|
endTime?: number,
|
||||||
options?: { limit?: number }
|
options?: { limit?: number },
|
||||||
|
rawMessagesOverride?: WechatMessage[]
|
||||||
): FormattedMessage[] {
|
): FormattedMessage[] {
|
||||||
if (!dbRef) return []
|
if (!dbRef) return []
|
||||||
|
|
||||||
@@ -191,7 +196,8 @@ function listSourceMessages(
|
|||||||
console.log(
|
console.log(
|
||||||
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
|
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
|
||||||
)
|
)
|
||||||
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime, options)
|
const rawMessages =
|
||||||
|
rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options)
|
||||||
console.log(
|
console.log(
|
||||||
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
||||||
)
|
)
|
||||||
@@ -350,6 +356,26 @@ export function listMessages(
|
|||||||
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listMessagesAsync(
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: { limit?: number }
|
||||||
|
): Promise<FormattedMessage[]> {
|
||||||
|
if (!dbRef) return []
|
||||||
|
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
|
||||||
|
const sourceMessages = listSourceMessages(
|
||||||
|
userMd5,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
options,
|
||||||
|
rawMessages
|
||||||
|
)
|
||||||
|
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||||
|
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||||
|
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||||
|
}
|
||||||
|
|
||||||
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||||
if (!dbRef) return null
|
if (!dbRef) return null
|
||||||
const wcdb4Client = dbRef.getWcdb4Client()
|
const wcdb4Client = dbRef.getWcdb4Client()
|
||||||
@@ -371,6 +397,25 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
|||||||
return { roomId, memberCount: members.length, members }
|
return { roomId, memberCount: members.length, members }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> {
|
||||||
|
if (!dbRef) return null
|
||||||
|
const wcdb4Client = dbRef.getWcdb4Client()
|
||||||
|
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
|
||||||
|
if (!roomId || !roomId.endsWith('@chatroom')) return null
|
||||||
|
|
||||||
|
const members = (await wcdb4Client.getGroupMembersAsync(roomId))
|
||||||
|
.filter((member) => member?.m_nsUsrName)
|
||||||
|
.map((member) => ({
|
||||||
|
wxid: member.m_nsUsrName,
|
||||||
|
nickname: member.nickname || '',
|
||||||
|
groupNickname: member.groupNickname || '',
|
||||||
|
wechatNickname: member.wechatNickname || '',
|
||||||
|
remark: member.remark || '',
|
||||||
|
avatar: member.m_nsHeadImgUrl || ''
|
||||||
|
}))
|
||||||
|
return { roomId, memberCount: members.length, members }
|
||||||
|
}
|
||||||
|
|
||||||
export function searchMessages(keyword: string): string | null {
|
export function searchMessages(keyword: string): string | null {
|
||||||
if (!dbRef) return null
|
if (!dbRef) return null
|
||||||
return dbRef.searchAllMessages(keyword)
|
return dbRef.searchAllMessages(keyword)
|
||||||
|
|||||||
+361
-2
@@ -68,6 +68,8 @@ type KoffiModule = {
|
|||||||
// library reference for every Wcdb4Client instance.
|
// library reference for every Wcdb4Client instance.
|
||||||
let wcdbBootstrapLib: KoffiLibrary | null = null
|
let wcdbBootstrapLib: KoffiLibrary | null = null
|
||||||
|
|
||||||
|
let wcdbBootstrapAsyncPromise: Promise<KoffiLibrary> | null = null
|
||||||
|
|
||||||
export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary {
|
export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary {
|
||||||
if (wcdbBootstrapLib) return wcdbBootstrapLib
|
if (wcdbBootstrapLib) return wcdbBootstrapLib
|
||||||
|
|
||||||
@@ -141,8 +143,82 @@ export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string):
|
|||||||
return lib
|
return lib
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function bootstrapWcdbNativeAsync(
|
||||||
|
libPath?: string,
|
||||||
|
libDirOverride?: string
|
||||||
|
): Promise<KoffiLibrary> {
|
||||||
|
if (wcdbBootstrapLib) return Promise.resolve(wcdbBootstrapLib)
|
||||||
|
if (wcdbBootstrapAsyncPromise) return wcdbBootstrapAsyncPromise
|
||||||
|
|
||||||
|
wcdbBootstrapAsyncPromise = (async () => {
|
||||||
|
const koffi = nodeRequire('koffi') as KoffiModule
|
||||||
|
const resolvedLibPath = libPath || Wcdb4Client.resolveNativeLibrary()
|
||||||
|
const libDir = libDirOverride || path.dirname(resolvedLibPath)
|
||||||
|
for (const name of process.platform === 'win32'
|
||||||
|
? ['WCDB.dll', 'SDL2.dll']
|
||||||
|
: process.platform === 'darwin'
|
||||||
|
? ['libWCDB.dylib']
|
||||||
|
: []) {
|
||||||
|
const preloadPath = path.join(libDir, name)
|
||||||
|
if (!fs.existsSync(preloadPath)) continue
|
||||||
|
try {
|
||||||
|
koffi.load(preloadPath)
|
||||||
|
} catch {
|
||||||
|
// The main library may still resolve its dependencies through the loader.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lib = koffi.load(resolvedLibPath)
|
||||||
|
const initProtection = lib.func('int32 InitProtection(const char* resourcePath)') as (
|
||||||
|
resourcePath: string
|
||||||
|
) => number
|
||||||
|
const resourceRoots = Array.from(
|
||||||
|
new Set(
|
||||||
|
[libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots()].filter(
|
||||||
|
Boolean
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let initOk = false
|
||||||
|
for (const resourceRoot of resourceRoots) {
|
||||||
|
try {
|
||||||
|
if (Number(initProtection(resourceRoot)) === 0) {
|
||||||
|
initOk = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next resource root.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (initOk) {
|
||||||
|
const wcdbInit = lib.func('int32 wcdb_init()') as KoffiAsyncFunction
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
wcdbInit.async((error: unknown, result: unknown) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Number(result) !== 0) {
|
||||||
|
console.warn(`[WCDB4] async wcdb_init rc=${Number(result)}`)
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wcdbBootstrapLib = lib
|
||||||
|
return lib
|
||||||
|
})().catch((error) => {
|
||||||
|
wcdbBootstrapAsyncPromise = null
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
return wcdbBootstrapAsyncPromise
|
||||||
|
}
|
||||||
|
|
||||||
type KoffiLibrary = {
|
type KoffiLibrary = {
|
||||||
func: (signature: string) => (...args: unknown[]) => unknown
|
func: (signature: string) => KoffiAsyncFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & {
|
||||||
|
async: (...args: unknown[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type WcdbVoidOut = [unknown]
|
type WcdbVoidOut = [unknown]
|
||||||
@@ -170,6 +246,7 @@ export class Wcdb4Client {
|
|||||||
private wcdbOpenAccount:
|
private wcdbOpenAccount:
|
||||||
| ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number)
|
| ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
|
private wcdbOpenAccountAsync: KoffiAsyncFunction | null = null
|
||||||
private wcdbSetMyWxid: ((handle: number, wxid: string) => number) | null = null
|
private wcdbSetMyWxid: ((handle: number, wxid: string) => number) | null = null
|
||||||
private wcdbFreeString: ((ptr: unknown) => void) | null = null
|
private wcdbFreeString: ((ptr: unknown) => void) | null = null
|
||||||
private wcdbGetSessions: ((handle: number, outJson: WcdbVoidOut) => number) | null = null
|
private wcdbGetSessions: ((handle: number, outJson: WcdbVoidOut) => number) | null = null
|
||||||
@@ -428,6 +505,39 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openAsync(): Promise<void> {
|
||||||
|
this.loadNativeLibrary()
|
||||||
|
if (!this.wcdbOpenAccountAsync) {
|
||||||
|
throw new Error('WCDB 4.0 native open async interface unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOut: WcdbHandleOut = [0]
|
||||||
|
const openResult = await new Promise<number>((resolve, reject) => {
|
||||||
|
this.wcdbOpenAccountAsync!.async(
|
||||||
|
this.sessionDbPath,
|
||||||
|
this.key,
|
||||||
|
handleOut,
|
||||||
|
(error: unknown, result: unknown) => {
|
||||||
|
if (error) reject(error)
|
||||||
|
else resolve(Number(result))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (openResult !== 0 || handleOut[0] <= 0) {
|
||||||
|
throw new Error(
|
||||||
|
`wcdb_open_account failed, code=${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.handle = handleOut[0]
|
||||||
|
if (this.wcdbSetMyWxid) {
|
||||||
|
try {
|
||||||
|
this.wcdbSetMyWxid(this.handle, this.wxid)
|
||||||
|
} catch {
|
||||||
|
// Optional helper. Failure does not block message reads.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.stopMonitor()
|
this.stopMonitor()
|
||||||
if (this.handle === null || !this.wcdbShutdown) return
|
if (this.handle === null || !this.wcdbShutdown) return
|
||||||
@@ -699,6 +809,21 @@ export class Wcdb4Client {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessagesAsync(
|
||||||
|
username: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options: Wcdb4MessageQueryOptions = {}
|
||||||
|
): Promise<Wcdb4Message[]> {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const maxRows = this.normalizeMessageLimit(options.limit)
|
||||||
|
const messages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
|
||||||
|
console.log(
|
||||||
|
`[WCDB4] getMessages async username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms`
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
private readSessionRows(): Record<string, unknown>[] {
|
private readSessionRows(): Record<string, unknown>[] {
|
||||||
if (!this.wcdbGetSessions) return []
|
if (!this.wcdbGetSessions) return []
|
||||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
@@ -1139,6 +1264,140 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getMessagesByCursorAsync(
|
||||||
|
username: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
limit?: number
|
||||||
|
): Promise<Wcdb4Message[]> {
|
||||||
|
if (!this.wcdbOpenMessageCursor || !this.wcdbFetchMessageBatch) return []
|
||||||
|
|
||||||
|
const handle = this.ensureHandle()
|
||||||
|
const batchSize = limit ? Math.min(500, limit) : 1000
|
||||||
|
const cursorOut: WcdbHandleOut = [0]
|
||||||
|
const begin = this.normalizeTimestamp(startTime || 0)
|
||||||
|
const end = this.normalizeTimestamp(endTime || 0)
|
||||||
|
const ascending = limit ? 0 : 1
|
||||||
|
const openResult = await this.callAsyncCode(
|
||||||
|
this.wcdbOpenMessageCursor as unknown as KoffiAsyncFunction,
|
||||||
|
handle,
|
||||||
|
username,
|
||||||
|
batchSize,
|
||||||
|
ascending,
|
||||||
|
begin,
|
||||||
|
end,
|
||||||
|
cursorOut
|
||||||
|
)
|
||||||
|
if (openResult !== 0 || cursorOut[0] <= 0) return []
|
||||||
|
|
||||||
|
const cursor = cursorOut[0]
|
||||||
|
const allRows: Record<string, unknown>[] = []
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const outJson: WcdbVoidOut = [null]
|
||||||
|
const outHasMore: [number] = [0]
|
||||||
|
const fetchResult = await this.callAsyncCode(
|
||||||
|
this.wcdbFetchMessageBatch as unknown as KoffiAsyncFunction,
|
||||||
|
handle,
|
||||||
|
cursor,
|
||||||
|
outJson,
|
||||||
|
outHasMore
|
||||||
|
)
|
||||||
|
if (fetchResult !== 0 || !outJson[0]) break
|
||||||
|
try {
|
||||||
|
const json = this.koffi!.decode(outJson[0], 'char', -1)
|
||||||
|
const batch = JSON.parse(json) as Record<string, unknown>[]
|
||||||
|
if (Array.isArray(batch)) allRows.push(...batch)
|
||||||
|
} finally {
|
||||||
|
this.wcdbFreeString?.(outJson[0])
|
||||||
|
}
|
||||||
|
if (!outHasMore[0] || (limit && allRows.length >= limit)) break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
this.wcdbCloseMessageCursor?.(handle, cursor)
|
||||||
|
} catch {
|
||||||
|
// Best-effort cursor cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupMembersAsync(chatroomId: string): Promise<Wcdb4GroupMember[]> {
|
||||||
|
if (!this.wcdbGetGroupMembers || !chatroomId) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupNicknames = await this.getGroupNicknamesAsync(chatroomId)
|
||||||
|
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||||
|
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
|
||||||
|
chatroomId
|
||||||
|
)
|
||||||
|
const members = (Array.isArray(rows) ? rows : []).map((row) => {
|
||||||
|
const username = this.pickString(row, [
|
||||||
|
'username',
|
||||||
|
'userName',
|
||||||
|
'user_name',
|
||||||
|
'member_username',
|
||||||
|
'm_nsUsrName'
|
||||||
|
])
|
||||||
|
const wechatNickname = this.pickString(row, [
|
||||||
|
'nickname',
|
||||||
|
'nickName',
|
||||||
|
'wechatNickname',
|
||||||
|
'wechat_nickname',
|
||||||
|
'm_nsNickName'
|
||||||
|
])
|
||||||
|
const remark = this.pickString(row, [
|
||||||
|
'remark',
|
||||||
|
'remarkName',
|
||||||
|
'remark_name',
|
||||||
|
'contactRemark',
|
||||||
|
'contact_remark'
|
||||||
|
])
|
||||||
|
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
|
||||||
|
const avatar = this.pickString(row, [
|
||||||
|
'avatarUrl',
|
||||||
|
'avatar_url',
|
||||||
|
'headImgUrl',
|
||||||
|
'm_nsHeadImgUrl'
|
||||||
|
])
|
||||||
|
if (username && avatar) this.avatarCache.set(username, avatar)
|
||||||
|
return {
|
||||||
|
m_nsUsrName: username,
|
||||||
|
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||||
|
groupNickname: groupNicknames.get(username) || '',
|
||||||
|
wechatNickname: wechatNickname || memberNickname,
|
||||||
|
remark,
|
||||||
|
m_nsHeadImgUrl: avatar
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const missingNames = members
|
||||||
|
.filter((member) => !member.nickname)
|
||||||
|
.map((member) => member.m_nsUsrName)
|
||||||
|
.filter(Boolean)
|
||||||
|
const missingAvatars = members
|
||||||
|
.filter((member) => !member.m_nsHeadImgUrl)
|
||||||
|
.map((member) => member.m_nsUsrName)
|
||||||
|
.filter(Boolean)
|
||||||
|
await Promise.all([
|
||||||
|
this.hydrateDisplayNamesAsync(missingNames),
|
||||||
|
this.hydrateAvatarUrlsAsync(missingAvatars)
|
||||||
|
])
|
||||||
|
return members.map((member) => ({
|
||||||
|
...member,
|
||||||
|
nickname:
|
||||||
|
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
||||||
|
wechatNickname:
|
||||||
|
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
|
||||||
|
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getGroupNicknames(chatroomId: string): Map<string, string> {
|
getGroupNicknames(chatroomId: string): Map<string, string> {
|
||||||
const cached = this.groupNicknameCache.get(chatroomId)
|
const cached = this.groupNicknameCache.get(chatroomId)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
@@ -1167,6 +1426,28 @@ export class Wcdb4Client {
|
|||||||
return nicknames
|
return nicknames
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getGroupNicknamesAsync(chatroomId: string): Promise<Map<string, string>> {
|
||||||
|
const cached = this.groupNicknameCache.get(chatroomId)
|
||||||
|
if (cached) return cached
|
||||||
|
const nicknames = new Map<string, string>()
|
||||||
|
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
|
||||||
|
|
||||||
|
const rows = await this.callJsonAsync<
|
||||||
|
Record<string, string> | Record<string, unknown>[]
|
||||||
|
>(this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, chatroomId)
|
||||||
|
this.readStringMap(rows, [
|
||||||
|
'nickname',
|
||||||
|
'nickName',
|
||||||
|
'displayName',
|
||||||
|
'display_name',
|
||||||
|
'groupNickname',
|
||||||
|
'group_nickname',
|
||||||
|
'name'
|
||||||
|
]).forEach((nickname, username) => nicknames.set(username, nickname))
|
||||||
|
this.groupNicknameCache.set(chatroomId, nicknames)
|
||||||
|
return nicknames
|
||||||
|
}
|
||||||
|
|
||||||
async getVoiceData(
|
async getVoiceData(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
createTime: number,
|
createTime: number,
|
||||||
@@ -1354,9 +1635,11 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number
|
this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number
|
||||||
this.wcdbOpenAccount = lib.func(
|
const openAccount = lib.func(
|
||||||
'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)'
|
'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)'
|
||||||
) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number
|
) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number
|
||||||
|
this.wcdbOpenAccount = openAccount
|
||||||
|
this.wcdbOpenAccountAsync = openAccount as unknown as KoffiAsyncFunction
|
||||||
this.wcdbFreeString = lib.func('void wcdb_free_string(void* ptr)') as (ptr: unknown) => void
|
this.wcdbFreeString = lib.func('void wcdb_free_string(void* ptr)') as (ptr: unknown) => void
|
||||||
this.wcdbGetSessions = lib.func(
|
this.wcdbGetSessions = lib.func(
|
||||||
'int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)'
|
'int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)'
|
||||||
@@ -1646,6 +1929,41 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private callJsonAsync<T>(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<T> {
|
||||||
|
const handle = this.ensureHandle()
|
||||||
|
const outJson: WcdbVoidOut = [null]
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
fn.async(handle, ...args, outJson, (error: unknown, result: unknown) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const code = Number(result)
|
||||||
|
if (code !== 0 || !outJson[0]) {
|
||||||
|
reject(new Error(`WCDB async call failed, code: ${code}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const json = this.koffi!.decode(outJson[0], 'char', -1)
|
||||||
|
resolve(JSON.parse(json) as T)
|
||||||
|
} catch (decodeError) {
|
||||||
|
reject(decodeError)
|
||||||
|
} finally {
|
||||||
|
this.wcdbFreeString?.(outJson[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private callAsyncCode(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<number> {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
fn.async(...args, (error: unknown, result: unknown) => {
|
||||||
|
if (error) reject(error)
|
||||||
|
else resolve(Number(result))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private ensureHandle(): number {
|
private ensureHandle(): number {
|
||||||
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
|
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
|
||||||
return this.handle
|
return this.handle
|
||||||
@@ -1810,6 +2128,26 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async hydrateDisplayNamesAsync(usernames: string[]): Promise<void> {
|
||||||
|
if (!this.wcdbGetDisplayNames) return
|
||||||
|
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
|
||||||
|
if (missing.length === 0) return
|
||||||
|
try {
|
||||||
|
const rows = await this.callJsonAsync<
|
||||||
|
Record<string, string> | Record<string, unknown>[]
|
||||||
|
>(this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
||||||
|
this.readStringMap(rows, [
|
||||||
|
'nickname',
|
||||||
|
'displayName',
|
||||||
|
'display_name',
|
||||||
|
'remark',
|
||||||
|
'name'
|
||||||
|
]).forEach((name, username) => this.displayNameCache.set(username, name))
|
||||||
|
} catch {
|
||||||
|
// Names are optional; usernames remain usable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private hydrateAvatarUrls(usernames: string[]): void {
|
private hydrateAvatarUrls(usernames: string[]): void {
|
||||||
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||||
if (missing.length === 0) return
|
if (missing.length === 0) return
|
||||||
@@ -1844,6 +2182,27 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async hydrateAvatarUrlsAsync(usernames: string[]): Promise<void> {
|
||||||
|
if (!this.wcdbGetAvatarUrls) return
|
||||||
|
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||||
|
if (missing.length === 0) return
|
||||||
|
try {
|
||||||
|
const rows = await this.callJsonAsync<
|
||||||
|
Record<string, string> | Record<string, unknown>[]
|
||||||
|
>(this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
||||||
|
this.readStringMap(rows, [
|
||||||
|
'avatarUrl',
|
||||||
|
'avatar_url',
|
||||||
|
'headImgUrl',
|
||||||
|
'm_nsHeadImgUrl',
|
||||||
|
'big_head_img_url',
|
||||||
|
'small_head_img_url'
|
||||||
|
]).forEach((avatar, username) => this.avatarCache.set(username, avatar))
|
||||||
|
} catch {
|
||||||
|
// Avatars are optional.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
|
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
|
||||||
const result = new Map<string, string>()
|
const result = new Map<string, string>()
|
||||||
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
||||||
|
|||||||
+35
-12
@@ -35,20 +35,12 @@ export interface GroupMemberInfo {
|
|||||||
export class WechatDb {
|
export class WechatDb {
|
||||||
private wcdb4Client: Wcdb4Client
|
private wcdb4Client: Wcdb4Client
|
||||||
private chatMd5ToUsername = new Map<string, string>()
|
private chatMd5ToUsername = new Map<string, string>()
|
||||||
|
private chatTableMappingLoaded = false
|
||||||
|
|
||||||
static async create(rawKey: string, accountRoot?: string): Promise<WechatDb> {
|
static async create(rawKey: string, accountRoot?: string): Promise<WechatDb> {
|
||||||
// WCDB native init must run on the Electron main process; worker threads
|
|
||||||
// get -1006 from wcdb_init. Initialize the client synchronously here
|
|
||||||
// (and keep create() async for callers that already await it).
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const client = new Wcdb4Client(rawKey, accountRoot)
|
const client = new Wcdb4Client(rawKey, accountRoot)
|
||||||
client.open()
|
await client.openAsync()
|
||||||
resolve(new WechatDb(rawKey, accountRoot, client))
|
return new WechatDb(rawKey, accountRoot, client)
|
||||||
} catch (error) {
|
|
||||||
reject(error instanceof Error ? error : new Error(String(error)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -61,11 +53,22 @@ export class WechatDb {
|
|||||||
const client = clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
const client = clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
||||||
if (!clientOverride) client.open()
|
if (!clientOverride) client.open()
|
||||||
this.wcdb4Client = client
|
this.wcdb4Client = client
|
||||||
for (const table of initialChatTables || client.getChatTables()) {
|
for (const table of initialChatTables || []) {
|
||||||
if (table.name.startsWith('Chat_')) {
|
if (table.name.startsWith('Chat_')) {
|
||||||
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.chatTableMappingLoaded = Boolean(initialChatTables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureChatTableMapping(): void {
|
||||||
|
if (this.chatTableMappingLoaded) return
|
||||||
|
for (const table of this.wcdb4Client.getChatTables()) {
|
||||||
|
if (table.name.startsWith('Chat_')) {
|
||||||
|
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.chatTableMappingLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
public getUserList(nicknameFilter?: string): UserContact[] {
|
public getUserList(nicknameFilter?: string): UserContact[] {
|
||||||
@@ -112,6 +115,7 @@ export class WechatDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getGroupMembersForChat(userMd5: string): Record<string, string> {
|
public getGroupMembersForChat(userMd5: string): Record<string, string> {
|
||||||
|
this.ensureChatTableMapping()
|
||||||
const username = this.chatMd5ToUsername.get(userMd5)
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
if (!username || !username.endsWith('@chatroom')) return {}
|
if (!username || !username.endsWith('@chatroom')) return {}
|
||||||
|
|
||||||
@@ -154,6 +158,7 @@ export class WechatDb {
|
|||||||
endTime?: number,
|
endTime?: number,
|
||||||
options?: Wcdb4MessageQueryOptions
|
options?: Wcdb4MessageQueryOptions
|
||||||
): WechatMessage[] {
|
): WechatMessage[] {
|
||||||
|
this.ensureChatTableMapping()
|
||||||
const username = this.chatMd5ToUsername.get(userMd5)
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
if (!username) return []
|
if (!username) return []
|
||||||
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
||||||
@@ -162,6 +167,24 @@ export class WechatDb {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getUserMessagesAsync(
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: Wcdb4MessageQueryOptions
|
||||||
|
): Promise<WechatMessage[]> {
|
||||||
|
this.ensureChatTableMapping()
|
||||||
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
|
if (!username) return []
|
||||||
|
const messages = await this.wcdb4Client.getMessagesAsync(
|
||||||
|
username,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
return messages.map((message) => ({ ...message, ...message.raw }))
|
||||||
|
}
|
||||||
|
|
||||||
public searchAllMessages(keyword: string): string | null {
|
public searchAllMessages(keyword: string): string | null {
|
||||||
const lowerKeyword = keyword.trim().toLowerCase()
|
const lowerKeyword = keyword.trim().toLowerCase()
|
||||||
if (!lowerKeyword) return null
|
if (!lowerKeyword) return null
|
||||||
|
|||||||
Vendored
+25
@@ -93,6 +93,11 @@ declare global {
|
|||||||
contacts: Contact[]
|
contacts: Contact[]
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
} | null>
|
} | null>
|
||||||
|
getStartupCache: () => Promise<{
|
||||||
|
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||||
|
contacts: Contact[]
|
||||||
|
updatedAt: number
|
||||||
|
} | null>
|
||||||
getContacts: (filter?: string) => Promise<Contact[]>
|
getContacts: (filter?: string) => Promise<Contact[]>
|
||||||
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
||||||
getCachedMessages: (
|
getCachedMessages: (
|
||||||
@@ -100,6 +105,26 @@ declare global {
|
|||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number
|
endTime?: number
|
||||||
) => Promise<Message[]>
|
) => Promise<Message[]>
|
||||||
|
getCachedMessagePage: (
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
) => Promise<{
|
||||||
|
hit: boolean
|
||||||
|
messages: Message[]
|
||||||
|
groupSnapshot?: {
|
||||||
|
roomId: string
|
||||||
|
memberCount: number
|
||||||
|
members: {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}>
|
||||||
getMessages: (
|
getMessages: (
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ const api = {
|
|||||||
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
|
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
|
||||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||||
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||||
|
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
|
||||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||||
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
||||||
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
|
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
|
||||||
|
getCachedMessagePage: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||||
|
ipcRenderer.invoke('db:getCachedMessagePage', userMd5, startTime, endTime),
|
||||||
getMessages: (
|
getMessages: (
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
|
|||||||
+270
-116
@@ -40,7 +40,9 @@ interface SelfInfo {
|
|||||||
|
|
||||||
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
||||||
|
const INITIAL_MESSAGE_COUNT = 20
|
||||||
const MESSAGE_PAGE_SIZE = 100
|
const MESSAGE_PAGE_SIZE = 100
|
||||||
|
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
|
||||||
const EXPORT_PREVIEW_LIMIT = 20
|
const EXPORT_PREVIEW_LIMIT = 20
|
||||||
const getMessageIdentity = (message: Message): string => {
|
const getMessageIdentity = (message: Message): string => {
|
||||||
if (message.localId) return `local:${message.localId}`
|
if (message.localId) return `local:${message.localId}`
|
||||||
@@ -48,6 +50,70 @@ const getMessageIdentity = (message: Message): string => {
|
|||||||
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeQuotedText = (value: string | undefined): string =>
|
||||||
|
String(value || '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
const isInternalReferenceSender = (value: string | undefined): boolean => {
|
||||||
|
const sender = String(value || '').trim()
|
||||||
|
return (
|
||||||
|
!sender ||
|
||||||
|
sender.endsWith('@chatroom') ||
|
||||||
|
sender.startsWith('wxid_') ||
|
||||||
|
/^[a-z0-9_@.-]{12,}$/i.test(sender)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[]): Message[] => {
|
||||||
|
const imageDatNameByMd5 = new Map<string, string>()
|
||||||
|
const messagesByContent = new Map<string, Message[]>()
|
||||||
|
|
||||||
|
for (const message of referenceMessages) {
|
||||||
|
if (
|
||||||
|
message.contentData?.type === 'image' &&
|
||||||
|
message.contentData.md5 &&
|
||||||
|
message.contentData.datName
|
||||||
|
) {
|
||||||
|
imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName)
|
||||||
|
}
|
||||||
|
const content = normalizeQuotedText(message.content)
|
||||||
|
if (!content) continue
|
||||||
|
const candidates = messagesByContent.get(content) || []
|
||||||
|
candidates.push(message)
|
||||||
|
messagesByContent.set(content, candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.map((message) => {
|
||||||
|
if (message.contentData?.type !== 'quote') return message
|
||||||
|
const quote = message.contentData
|
||||||
|
let quotedImageDatName = quote.quotedImageDatName
|
||||||
|
if (!quotedImageDatName && quote.quotedImageMd5) {
|
||||||
|
quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5)
|
||||||
|
}
|
||||||
|
|
||||||
|
let quotedSender = quote.quotedSender
|
||||||
|
if (isInternalReferenceSender(quotedSender)) {
|
||||||
|
const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || []
|
||||||
|
const source = candidates
|
||||||
|
.filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity))
|
||||||
|
.sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0]
|
||||||
|
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
quotedSender === quote.quotedSender &&
|
||||||
|
quotedImageDatName === quote.quotedImageDatName
|
||||||
|
) {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
contentData: { ...quote, quotedSender, quotedImageDatName }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
||||||
if (left === right) return true
|
if (left === right) return true
|
||||||
if (left.length !== right.length) return false
|
if (left.length !== right.length) return false
|
||||||
@@ -79,7 +145,7 @@ type StartupProgress = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
|
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
|
||||||
member.nickname || member.wxid
|
member.groupNickname || member.nickname || member.remark || member.wechatNickname || member.wxid
|
||||||
|
|
||||||
const buildSyntheticGroupMessages = (
|
const buildSyntheticGroupMessages = (
|
||||||
previous: GroupSnapshot | null,
|
previous: GroupSnapshot | null,
|
||||||
@@ -150,7 +216,6 @@ function App(): React.ReactElement {
|
|||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
|
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
|
||||||
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
||||||
const [dateRange, setDateRange] = useState('today') // 默认今天
|
|
||||||
const [contentFilter, setContentFilter] = useState('')
|
const [contentFilter, setContentFilter] = useState('')
|
||||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||||
@@ -195,6 +260,10 @@ function App(): React.ReactElement {
|
|||||||
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
||||||
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
||||||
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
|
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
|
||||||
|
const messageHistoryRef = React.useRef<Message[]>([])
|
||||||
|
const messagesRef = React.useRef<Message[]>([])
|
||||||
|
const messagePrefetchRef = React.useRef<Promise<void> | null>(null)
|
||||||
|
messagesRef.current = messages
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!reportNotice) return
|
if (!reportNotice) return
|
||||||
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
||||||
@@ -486,7 +555,32 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
if (!autoLoginEnabled) return
|
if (!autoLoginEnabled) return
|
||||||
try {
|
try {
|
||||||
const result = await window.api.initDb(key)
|
const startupCacheReady = await loadStartupCache()
|
||||||
|
const initPromise = window.api.initDb(key)
|
||||||
|
if (startupCacheReady) {
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
setIsDatabaseConnected(false)
|
||||||
|
setBootState('login')
|
||||||
|
void initPromise.then(async (result) => {
|
||||||
|
const success = typeof result === 'boolean' ? result : result.success
|
||||||
|
if (!success) {
|
||||||
|
const error = typeof result === 'boolean' ? '' : result.error
|
||||||
|
setDbKeyStatus(`后台连接失败${error ? `: ${error}` : ''}`)
|
||||||
|
setDbKeyStatusKind('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||||
|
setIsDatabaseConnected(true)
|
||||||
|
setDbKeyStatus('已连接数据库')
|
||||||
|
// Cached contacts/self info are enough for startup. Native refresh is
|
||||||
|
// intentionally user-triggered so it cannot freeze the first session.
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn('[Startup] background database init failed:', error)
|
||||||
|
setDbKeyStatusKind('error')
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await initPromise
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const success = typeof result === 'boolean' ? result : result.success
|
const success = typeof result === 'boolean' ? result : result.success
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -497,9 +591,13 @@ function App(): React.ReactElement {
|
|||||||
setIsDatabaseConnected(true)
|
setIsDatabaseConnected(true)
|
||||||
setDbKeyStatus('已自动连接')
|
setDbKeyStatus('已自动连接')
|
||||||
setDbKeyStatusKind('success')
|
setDbKeyStatusKind('success')
|
||||||
await loadContacts()
|
const hasBootstrap = await loadBootstrapCache()
|
||||||
await refreshSelfInfo(3)
|
if (hasBootstrap) {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
|
} else {
|
||||||
|
await Promise.all([loadContacts(), refreshSelfInfo(3)])
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const error = typeof result === 'boolean' ? '' : result.error
|
const error = typeof result === 'boolean' ? '' : result.error
|
||||||
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
|
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
|
||||||
@@ -569,7 +667,7 @@ function App(): React.ReactElement {
|
|||||||
detail: '正在读取本地缓存',
|
detail: '正在读取本地缓存',
|
||||||
percent: 25
|
percent: 25
|
||||||
})
|
})
|
||||||
await loadBootstrapCache()
|
const hasBootstrap = await loadBootstrapCache()
|
||||||
// 持久化手动输入的密钥,供下次启动继续使用
|
// 持久化手动输入的密钥,供下次启动继续使用
|
||||||
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
||||||
void window.api.getSettings().then((current) => {
|
void window.api.getSettings().then((current) => {
|
||||||
@@ -585,15 +683,28 @@ function App(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
|
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
|
||||||
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
|
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
|
||||||
await loadContacts({ waitForAvatars: false })
|
if (hasBootstrap) {
|
||||||
await refreshSelfInfo(3)
|
// Cached contacts are sufficient for the first paint. Refresh native data in the background.
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
void Promise.all([
|
||||||
|
loadContacts({ waitForAvatars: false }),
|
||||||
|
refreshSelfInfo(3)
|
||||||
|
]).catch((error) => {
|
||||||
|
console.warn('[Startup] background refresh failed:', error)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await Promise.all([
|
||||||
|
loadContacts({ waitForAvatars: false }),
|
||||||
|
refreshSelfInfo(3)
|
||||||
|
])
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
}
|
||||||
setStartupProgress({
|
setStartupProgress({
|
||||||
title: '加载完成',
|
title: '加载完成',
|
||||||
subtitle: '正在进入主页面',
|
subtitle: '正在进入主页面',
|
||||||
detail: '联系人和头像已准备好',
|
detail: '联系人和头像已准备好',
|
||||||
percent: 100
|
percent: 100
|
||||||
})
|
})
|
||||||
setIsAuthenticated(true)
|
|
||||||
setIsDatabaseConnected(true)
|
setIsDatabaseConnected(true)
|
||||||
setBootState('login')
|
setBootState('login')
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@@ -646,10 +757,14 @@ function App(): React.ReactElement {
|
|||||||
const applyGroupMemberMeta = React.useCallback(
|
const applyGroupMemberMeta = React.useCallback(
|
||||||
(contact: Contact | null, baseMessages: Message[]): Message[] => {
|
(contact: Contact | null, baseMessages: Message[]): Message[] => {
|
||||||
if (!contact || contact.type !== 'group') return baseMessages
|
if (!contact || contact.type !== 'group') return baseMessages
|
||||||
|
const enrichedMessages = enrichQuotedMessages(baseMessages, [
|
||||||
|
...messageHistoryRef.current,
|
||||||
|
...baseMessages
|
||||||
|
])
|
||||||
const memberMap = groupMemberMetaRef.current[contact.md5]
|
const memberMap = groupMemberMetaRef.current[contact.md5]
|
||||||
if (!memberMap || memberMap.size === 0) return baseMessages
|
if (!memberMap || memberMap.size === 0) return enrichedMessages
|
||||||
|
|
||||||
return baseMessages.map((message) => {
|
return enrichedMessages.map((message) => {
|
||||||
const senderId = String(message.senderId || message.name || '').trim()
|
const senderId = String(message.senderId || message.name || '').trim()
|
||||||
if (!senderId) return message
|
if (!senderId) return message
|
||||||
const member = memberMap.get(senderId)
|
const member = memberMap.get(senderId)
|
||||||
@@ -672,23 +787,57 @@ function App(): React.ReactElement {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const storeGroupMemberMeta = React.useCallback(
|
||||||
|
(contact: Contact, snapshot: GroupSnapshot): void => {
|
||||||
|
currentGroupSnapshotRef.current = snapshot
|
||||||
|
groupMemberMetaRef.current[contact.md5] = new Map(
|
||||||
|
snapshot.members.map((member) => [
|
||||||
|
member.wxid,
|
||||||
|
{ nickname: formatGroupMemberName(member), avatar: member.avatar || '' }
|
||||||
|
])
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const loadGroupMemberMeta = React.useCallback(
|
const loadGroupMemberMeta = React.useCallback(
|
||||||
async (contact: Contact | null): Promise<GroupSnapshot | null> => {
|
async (contact: Contact | null): Promise<GroupSnapshot | null> => {
|
||||||
if (!contact || contact.type !== 'group') return null
|
if (!contact || contact.type !== 'group') return null
|
||||||
const snapshot = await logGroupSnapshot(contact, 'load-member-meta')
|
const snapshot = await logGroupSnapshot(contact, 'load-member-meta')
|
||||||
if (!snapshot) return null
|
if (!snapshot) return null
|
||||||
currentGroupSnapshotRef.current = snapshot
|
storeGroupMemberMeta(contact, snapshot)
|
||||||
groupMemberMetaRef.current[contact.md5] = new Map(
|
|
||||||
snapshot.members.map((member) => [
|
|
||||||
member.wxid,
|
|
||||||
{ nickname: member.nickname || member.wxid, avatar: member.avatar || '' }
|
|
||||||
])
|
|
||||||
)
|
|
||||||
return snapshot
|
return snapshot
|
||||||
},
|
},
|
||||||
[logGroupSnapshot]
|
[logGroupSnapshot, storeGroupMemberMeta]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleReloadCurrentAvatars = React.useCallback(async (): Promise<void> => {
|
||||||
|
const contact = selectedContact
|
||||||
|
if (!contact) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const avatars = await window.api.getContactAvatars([contact.m_nsUsrName])
|
||||||
|
const avatar = avatars[contact.m_nsUsrName]
|
||||||
|
if (avatar) {
|
||||||
|
const updateContact = (item: Contact): Contact =>
|
||||||
|
item.md5 === contact.md5 ? { ...item, avatar } : item
|
||||||
|
setContacts((current) => current.map(updateContact))
|
||||||
|
setFilteredContacts((current) => current.map(updateContact))
|
||||||
|
setSelectedContact((current) =>
|
||||||
|
current?.md5 === contact.md5 ? updateContact(current) : current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Contacts] current avatar reload failed:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await loadGroupMemberMeta(contact)
|
||||||
|
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
setMessages((current) =>
|
||||||
|
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId))
|
||||||
|
)
|
||||||
|
}, [applyGroupMemberMeta, loadGroupMemberMeta, mergeSyntheticMessages, selectedContact])
|
||||||
|
|
||||||
const handleAutoGetDbKey = async (): Promise<void> => {
|
const handleAutoGetDbKey = async (): Promise<void> => {
|
||||||
if (isFetchingDbKey) return
|
if (isFetchingDbKey) return
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
@@ -761,65 +910,57 @@ function App(): React.ReactElement {
|
|||||||
setStartupProgress(null)
|
setStartupProgress(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDateRangeParams = (
|
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
|
||||||
range: string
|
|
||||||
): { startTime: number | undefined; endTime: number | undefined } => {
|
|
||||||
const now = new Date()
|
|
||||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
|
||||||
|
|
||||||
let startTime: number | undefined
|
|
||||||
let endTime: number | undefined
|
|
||||||
|
|
||||||
switch (range) {
|
|
||||||
case 'today':
|
|
||||||
startTime = startOfToday
|
|
||||||
break
|
|
||||||
case 'yesterday':
|
|
||||||
startTime = startOfToday - 86400
|
|
||||||
endTime = startOfToday - 1 // 昨天结束
|
|
||||||
break
|
|
||||||
case '7':
|
|
||||||
startTime = Math.floor(Date.now() / 1000) - 7 * 86400
|
|
||||||
break
|
|
||||||
case '30':
|
|
||||||
startTime = Math.floor(Date.now() / 1000) - 30 * 86400
|
|
||||||
break
|
|
||||||
case 'all':
|
|
||||||
startTime = undefined
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
startTime = startOfToday
|
|
||||||
}
|
|
||||||
return { startTime, endTime }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelectContact = async (contact: Contact): Promise<void> => {
|
|
||||||
setSelectedContact(contact)
|
setSelectedContact(contact)
|
||||||
selectedContactMd5Ref.current = contact.md5
|
selectedContactMd5Ref.current = contact.md5
|
||||||
currentGroupSnapshotRef.current = null
|
currentGroupSnapshotRef.current = null
|
||||||
setIsMessagesLoading(true)
|
setIsMessagesLoading(true)
|
||||||
const { startTime, endTime } = getDateRangeParams(dateRange)
|
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
|
||||||
const cachedMsgs = await window.api.getCachedMessages(contact.md5, startTime, endTime)
|
const cachedMsgs = cachedPage.messages
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
if (cachedMsgs.length) {
|
if (cachedPage.groupSnapshot) {
|
||||||
|
storeGroupMemberMeta(contact, cachedPage.groupSnapshot)
|
||||||
|
}
|
||||||
|
messageHistoryRef.current = cachedMsgs
|
||||||
setMessages(
|
setMessages(
|
||||||
applyGroupMemberMeta(
|
applyGroupMemberMeta(
|
||||||
contact,
|
contact,
|
||||||
mergeSyntheticMessages(contact, cachedMsgs.slice(-MESSAGE_PAGE_SIZE))
|
mergeSyntheticMessages(contact, cachedMsgs.slice(-INITIAL_MESSAGE_COUNT))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
const needsLivePage =
|
||||||
setMessages([])
|
forceLive || (isDatabaseConnected && cachedMsgs.length < MESSAGE_PREFETCH_COUNT)
|
||||||
|
if (!needsLivePage) {
|
||||||
|
setIsMessagesLoading(false)
|
||||||
|
if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
void loadGroupMemberMeta(contact).then((snapshot) => {
|
||||||
|
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
setMessages((current) =>
|
||||||
|
applyGroupMemberMeta(
|
||||||
|
contact,
|
||||||
|
mergeSyntheticMessages(contact, current, snapshot.roomId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (cachedMsgs.length >= INITIAL_MESSAGE_COUNT) setIsMessagesLoading(false)
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
try {
|
const loadLivePage = async (): Promise<void> => {
|
||||||
const msgs = await window.api.getMessages(contact.md5, startTime, endTime, {
|
const msgs = await window.api.getMessages(contact.md5, undefined, undefined, {
|
||||||
limit: MESSAGE_PAGE_SIZE
|
limit: MESSAGE_PREFETCH_COUNT
|
||||||
})
|
})
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
|
messageHistoryRef.current = msgs
|
||||||
setMessages(cachedMessages)
|
const visibleMessages = applyGroupMemberMeta(
|
||||||
if (contact.type === 'group') {
|
contact,
|
||||||
|
mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT))
|
||||||
|
)
|
||||||
|
setMessages(visibleMessages)
|
||||||
|
if (contact.type === 'group' && visibleMessages.length > 0) {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
void loadGroupMemberMeta(contact).then((snapshot) => {
|
void loadGroupMemberMeta(contact).then((snapshot) => {
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
@@ -833,28 +974,79 @@ function App(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
}, 120)
|
}, 120)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
const prefetchPromise = loadLivePage()
|
||||||
|
messagePrefetchRef.current = prefetchPromise
|
||||||
|
try {
|
||||||
|
await prefetchPromise
|
||||||
} finally {
|
} finally {
|
||||||
|
if (messagePrefetchRef.current === prefetchPromise) messagePrefetchRef.current = null
|
||||||
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isDatabaseConnected || !selectedContact || messages.length > 0) return
|
||||||
|
// The first live page uses async native cursors, so a cache miss can be filled
|
||||||
|
// without blocking the Electron main thread.
|
||||||
|
void handleSelectContact(selectedContact)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isDatabaseConnected])
|
||||||
|
|
||||||
|
const loadStartupCache = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const cache = await window.api.getStartupCache()
|
||||||
|
if (!cache?.contacts.length) return false
|
||||||
|
setContacts(cache.contacts)
|
||||||
|
setFilteredContacts(cache.contacts)
|
||||||
|
if (cache.self) setSelfInfo(cache.self)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[StartupCache] load failed:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleLoadOlderMessages = async (): Promise<void> => {
|
const handleLoadOlderMessages = async (): Promise<void> => {
|
||||||
const contact = selectedContact
|
const contact = selectedContact
|
||||||
if (!contact || isMessagesLoading || messages.length === 0) return
|
if (!contact || messagesRef.current.length === 0) return
|
||||||
const oldestTime = messages[0]?.createTime
|
if (messagePrefetchRef.current) await messagePrefetchRef.current
|
||||||
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
const currentMessages = messagesRef.current
|
||||||
|
const historyMessages = messageHistoryRef.current
|
||||||
|
const firstVisibleIndex = historyMessages.findIndex(
|
||||||
|
(message) => getMessageIdentity(message) === getMessageIdentity(currentMessages[0])
|
||||||
|
)
|
||||||
|
if (firstVisibleIndex > 0) {
|
||||||
|
const inMemoryOlder = historyMessages.slice(
|
||||||
|
Math.max(0, firstVisibleIndex - MESSAGE_PAGE_SIZE),
|
||||||
|
firstVisibleIndex
|
||||||
|
)
|
||||||
|
setMessages((current) =>
|
||||||
|
applyGroupMemberMeta(
|
||||||
|
contact,
|
||||||
|
mergeSyntheticMessages(contact, mergeMessagePages(inMemoryOlder, current))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const oldestTime = currentMessages[0]?.createTime
|
||||||
if (!oldestTime) return
|
if (!oldestTime) return
|
||||||
const { startTime } = getDateRangeParams(dateRange)
|
|
||||||
if (startTime && oldestTime <= startTime) return
|
|
||||||
|
|
||||||
setIsMessagesLoading(true)
|
setIsMessagesLoading(true)
|
||||||
try {
|
try {
|
||||||
const olderMessages = await window.api.getMessages(
|
const cachedPage = await window.api.getCachedMessagePage(
|
||||||
contact.md5,
|
contact.md5,
|
||||||
startTime,
|
undefined,
|
||||||
oldestTime - 1,
|
oldestTime - 1
|
||||||
{ limit: MESSAGE_PAGE_SIZE }
|
|
||||||
)
|
)
|
||||||
|
const olderMessages = cachedPage.hit
|
||||||
|
? cachedPage.messages
|
||||||
|
: await window.api.getMessages(contact.md5, undefined, oldestTime - 1, {
|
||||||
|
limit: MESSAGE_PAGE_SIZE
|
||||||
|
})
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
|
||||||
setMessages((current) =>
|
setMessages((current) =>
|
||||||
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current)))
|
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current)))
|
||||||
)
|
)
|
||||||
@@ -878,40 +1070,6 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDateRangeChange = (range: string): void => {
|
|
||||||
setDateRange(range)
|
|
||||||
if (selectedContact) {
|
|
||||||
const { startTime, endTime } = getDateRangeParams(range)
|
|
||||||
window.api
|
|
||||||
.getCachedMessages(selectedContact.md5, startTime, endTime)
|
|
||||||
.then((cachedMessages) => {
|
|
||||||
if (!cachedMessages.length) return
|
|
||||||
setMessages(
|
|
||||||
applyGroupMemberMeta(
|
|
||||||
selectedContact,
|
|
||||||
mergeSyntheticMessages(selectedContact, cachedMessages.slice(-MESSAGE_PAGE_SIZE))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
setIsMessagesLoading(true)
|
|
||||||
window.api
|
|
||||||
.getMessages(selectedContact.md5, startTime, endTime, { limit: MESSAGE_PAGE_SIZE })
|
|
||||||
.then((nextMessages) => {
|
|
||||||
setMessages(
|
|
||||||
applyGroupMemberMeta(
|
|
||||||
selectedContact,
|
|
||||||
mergeSyntheticMessages(selectedContact, nextMessages)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
setIsMessagesLoading(false)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.warn('[Messages] date range load failed:', error)
|
|
||||||
setIsMessagesLoading(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isAuthenticated || !selectedContact || !isNativeMonitorActive) return
|
if (!isAuthenticated || !selectedContact || !isNativeMonitorActive) return
|
||||||
|
|
||||||
@@ -928,12 +1086,11 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
refreshInFlight = true
|
refreshInFlight = true
|
||||||
try {
|
try {
|
||||||
const range = getDateRangeParams(dateRange)
|
|
||||||
const latestMessages = await window.api.getMessages(
|
const latestMessages = await window.api.getMessages(
|
||||||
contactMd5,
|
contactMd5,
|
||||||
range.startTime,
|
undefined,
|
||||||
range.endTime,
|
undefined,
|
||||||
{ limit: MESSAGE_PAGE_SIZE }
|
{ limit: INITIAL_MESSAGE_COUNT }
|
||||||
)
|
)
|
||||||
const nextMessages = applyGroupMemberMeta(
|
const nextMessages = applyGroupMemberMeta(
|
||||||
selectedContact,
|
selectedContact,
|
||||||
@@ -975,7 +1132,6 @@ function App(): React.ReactElement {
|
|||||||
unsubscribe()
|
unsubscribe()
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
dateRange,
|
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isNativeMonitorActive,
|
isNativeMonitorActive,
|
||||||
selectedContact,
|
selectedContact,
|
||||||
@@ -1201,8 +1357,6 @@ function App(): React.ReactElement {
|
|||||||
onSearch={handleSearchContacts}
|
onSearch={handleSearchContacts}
|
||||||
onContentFilter={setContentFilter}
|
onContentFilter={setContentFilter}
|
||||||
width={sidebarWidth}
|
width={sidebarWidth}
|
||||||
dateRange={dateRange}
|
|
||||||
onDateRangeChange={handleDateRangeChange}
|
|
||||||
selfInfo={selfInfo}
|
selfInfo={selfInfo}
|
||||||
dbReady={isDatabaseConnected}
|
dbReady={isDatabaseConnected}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
@@ -1214,11 +1368,11 @@ function App(): React.ReactElement {
|
|||||||
messages={messages}
|
messages={messages}
|
||||||
isLoadingMessages={isMessagesLoading}
|
isLoadingMessages={isMessagesLoading}
|
||||||
contentFilter={contentFilter}
|
contentFilter={contentFilter}
|
||||||
dateRange={dateRange}
|
|
||||||
onContentFilterChange={setContentFilter}
|
onContentFilterChange={setContentFilter}
|
||||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
|
||||||
onRefreshData={loadContacts}
|
onRefreshData={loadContacts}
|
||||||
onLoadOlderMessages={() => void handleLoadOlderMessages()}
|
onReloadAvatars={handleReloadCurrentAvatars}
|
||||||
|
onLoadOlderMessages={handleLoadOlderMessages}
|
||||||
onCreateGroupReport={handleOpenReportWorkspace}
|
onCreateGroupReport={handleOpenReportWorkspace}
|
||||||
isAiLoading={reportGeneration.isGenerating}
|
isAiLoading={reportGeneration.isGenerating}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1803,6 +1803,41 @@ body {
|
|||||||
accent-color: var(--wxex-brand);
|
accent-color: var(--wxex-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-avatar-note {
|
||||||
|
max-width: 320px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-avatar-reload {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-avatar-reload svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-avatar-reload svg.is-spinning {
|
||||||
|
animation: chat-avatar-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes chat-avatar-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat-status-separator {
|
.chat-status-separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
@@ -1827,6 +1862,12 @@ body {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.chat-avatar-note {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.empty-conversation-state {
|
.empty-conversation-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -11,62 +11,24 @@ interface ChatWindowProps {
|
|||||||
messages: Message[]
|
messages: Message[]
|
||||||
isLoadingMessages?: boolean
|
isLoadingMessages?: boolean
|
||||||
contentFilter?: string
|
contentFilter?: string
|
||||||
dateRange?: string
|
|
||||||
onContentFilterChange?: (keyword: string) => void
|
onContentFilterChange?: (keyword: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onRefreshData?: () => void
|
onRefreshData?: () => void
|
||||||
onLoadOlderMessages?: () => void
|
onReloadAvatars?: () => Promise<void>
|
||||||
|
onLoadOlderMessages?: () => Promise<void>
|
||||||
onCreateGroupReport?: () => void
|
onCreateGroupReport?: () => void
|
||||||
isAiLoading?: boolean
|
isAiLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATE_RANGE_LABELS: Record<string, string> = {
|
|
||||||
today: '今天',
|
|
||||||
yesterday: '昨日',
|
|
||||||
'7': '7 天',
|
|
||||||
'30': '30 天',
|
|
||||||
all: '全部'
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatClock = (date: Date): string =>
|
|
||||||
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
|
||||||
|
|
||||||
const formatRangeDate = (date: Date, now: Date): string => {
|
|
||||||
const clock = formatClock(date)
|
|
||||||
if (date.getFullYear() === now.getFullYear()) {
|
|
||||||
return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
|
||||||
}
|
|
||||||
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getChatHeaderRangeLabel = (range: string): string => {
|
|
||||||
const now = new Date()
|
|
||||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
||||||
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
|
|
||||||
|
|
||||||
if (range === 'today') return `今天 00:00—现在`
|
|
||||||
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
|
|
||||||
if (range === '7') {
|
|
||||||
const start = new Date(Date.now() - 7 * 86400000)
|
|
||||||
return `${formatRangeDate(start, now)}—现在`
|
|
||||||
}
|
|
||||||
if (range === '30') {
|
|
||||||
const start = new Date(Date.now() - 30 * 86400000)
|
|
||||||
return `${formatRangeDate(start, now)}—现在`
|
|
||||||
}
|
|
||||||
if (range === 'all') return '全部记录'
|
|
||||||
return DATE_RANGE_LABELS[range] || '当前范围'
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||||
contact,
|
contact,
|
||||||
messages,
|
messages,
|
||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
contentFilter,
|
contentFilter,
|
||||||
dateRange = 'today',
|
|
||||||
onContentFilterChange,
|
onContentFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData,
|
onRefreshData,
|
||||||
|
onReloadAvatars,
|
||||||
onLoadOlderMessages,
|
onLoadOlderMessages,
|
||||||
onCreateGroupReport,
|
onCreateGroupReport,
|
||||||
isAiLoading = false
|
isAiLoading = false
|
||||||
@@ -86,6 +48,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
)
|
)
|
||||||
const [showAvatar, setShowAvatar] = useState(true)
|
const [showAvatar, setShowAvatar] = useState(true)
|
||||||
const [isAtLatest, setIsAtLatest] = useState(true)
|
const [isAtLatest, setIsAtLatest] = useState(true)
|
||||||
|
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
|
||||||
|
const previousScrollTopRef = useRef(0)
|
||||||
|
|
||||||
const scrollToBottom = useCallback((): void => {
|
const scrollToBottom = useCallback((): void => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||||
@@ -95,15 +59,34 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
||||||
const target = event.currentTarget
|
const target = event.currentTarget
|
||||||
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
||||||
setIsAtLatest(distanceToBottom <= 24)
|
if (distanceToBottom <= 24) {
|
||||||
|
setIsAtLatest(true)
|
||||||
|
} else if (target.scrollTop < previousScrollTopRef.current - 1) {
|
||||||
|
setIsAtLatest(false)
|
||||||
|
}
|
||||||
|
previousScrollTopRef.current = target.scrollTop
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
previousScrollTopRef.current = 0
|
||||||
|
setIsAtLatest(true)
|
||||||
|
}, [contact?.md5])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAtLatest) return
|
if (!isAtLatest) return
|
||||||
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
||||||
return () => window.cancelAnimationFrame(frame)
|
return () => window.cancelAnimationFrame(frame)
|
||||||
}, [isAtLatest, messages, scrollToBottom])
|
}, [isAtLatest, messages, scrollToBottom])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAtLatest) return
|
||||||
|
const content = messageListRef.current?.querySelector('.virtual-message-list')
|
||||||
|
if (!content) return
|
||||||
|
const observer = new ResizeObserver(() => scrollToBottom())
|
||||||
|
observer.observe(content)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [contact?.md5, isAtLatest, scrollToBottom])
|
||||||
|
|
||||||
const openImagePreview = (imageUrl: string): void => {
|
const openImagePreview = (imageUrl: string): void => {
|
||||||
setPreviewImage(imageUrl)
|
setPreviewImage(imageUrl)
|
||||||
setImageScale(1)
|
setImageScale(1)
|
||||||
@@ -155,6 +138,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
imageDragRef.current = null
|
imageDragRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReloadAvatars = async (): Promise<void> => {
|
||||||
|
if (!onReloadAvatars || isReloadingAvatars) return
|
||||||
|
setIsReloadingAvatars(true)
|
||||||
|
try {
|
||||||
|
await onReloadAvatars()
|
||||||
|
} finally {
|
||||||
|
setIsReloadingAvatars(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!previewImage) return
|
if (!previewImage) return
|
||||||
|
|
||||||
@@ -186,14 +179,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}, [messages, contentFilter])
|
}, [messages, contentFilter])
|
||||||
if (!contact) return <EmptyConversationState />
|
if (!contact) return <EmptyConversationState />
|
||||||
|
|
||||||
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-window">
|
<div className="chat-window">
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
contact={contact}
|
contact={contact}
|
||||||
isGroupChat={isGroupChat}
|
isGroupChat={isGroupChat}
|
||||||
dateRangeLabel={dateRangeLabel}
|
|
||||||
loadedCount={messages.length}
|
loadedCount={messages.length}
|
||||||
filteredCount={filteredMessages.length}
|
filteredCount={filteredMessages.length}
|
||||||
contentFilter={contentFilter || ''}
|
contentFilter={contentFilter || ''}
|
||||||
@@ -221,7 +211,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
count={filteredMessages.length}
|
count={filteredMessages.length}
|
||||||
showAvatar={showAvatar}
|
showAvatar={showAvatar}
|
||||||
isAtLatest={isAtLatest}
|
isAtLatest={isAtLatest}
|
||||||
|
isReloadingAvatars={isReloadingAvatars}
|
||||||
onShowAvatarChange={setShowAvatar}
|
onShowAvatarChange={setShowAvatar}
|
||||||
|
onReloadAvatars={() => void handleReloadAvatars()}
|
||||||
onJumpToLatest={scrollToBottom}
|
onJumpToLatest={scrollToBottom}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,45 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import type { JSX, MouseEvent } from 'react'
|
import type { JSX, MouseEvent } from 'react'
|
||||||
|
|
||||||
|
type CachedImage = { data: string; isThumbnail: boolean }
|
||||||
|
|
||||||
|
const MAX_IMAGE_CACHE_ENTRIES = 80
|
||||||
|
const imageDataUrlCache = new Map<string, CachedImage>()
|
||||||
|
|
||||||
|
function imageCacheKeys(imageMd5?: string, imageDatName?: string): string[] {
|
||||||
|
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
|
||||||
|
Boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedImage(imageMd5?: string, imageDatName?: string): CachedImage | undefined {
|
||||||
|
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
|
||||||
|
const cached = imageDataUrlCache.get(key)
|
||||||
|
if (cached) {
|
||||||
|
imageDataUrlCache.delete(key)
|
||||||
|
imageDataUrlCache.set(key, cached)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheImage(
|
||||||
|
imageMd5: string | undefined,
|
||||||
|
imageDatName: string | undefined,
|
||||||
|
cached: CachedImage
|
||||||
|
): void {
|
||||||
|
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
|
||||||
|
imageDataUrlCache.delete(key)
|
||||||
|
imageDataUrlCache.set(key, cached)
|
||||||
|
}
|
||||||
|
while (imageDataUrlCache.size > MAX_IMAGE_CACHE_ENTRIES) {
|
||||||
|
const oldestKey = imageDataUrlCache.keys().next().value
|
||||||
|
if (!oldestKey) break
|
||||||
|
imageDataUrlCache.delete(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ImageBubbleProps {
|
interface ImageBubbleProps {
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
@@ -16,11 +55,12 @@ export function ImageBubble({
|
|||||||
isThumb = false,
|
isThumb = false,
|
||||||
onImageClick
|
onImageClick
|
||||||
}: ImageBubbleProps): JSX.Element {
|
}: ImageBubbleProps): JSX.Element {
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
const initialCachedImage = getCachedImage(imageMd5, imageDatName)
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [upgrading, setUpgrading] = useState(false)
|
const [upgrading, setUpgrading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isThumbnail, setIsThumbnail] = useState(false)
|
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const loadImage = useCallback(async () => {
|
const loadImage = useCallback(async () => {
|
||||||
@@ -34,6 +74,10 @@ export function ImageBubble({
|
|||||||
try {
|
try {
|
||||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
||||||
if (result.success && result.data?.startsWith('data:image/')) {
|
if (result.success && result.data?.startsWith('data:image/')) {
|
||||||
|
cacheImage(imageMd5, imageDatName, {
|
||||||
|
data: result.data,
|
||||||
|
isThumbnail: Boolean(result.isThumb)
|
||||||
|
})
|
||||||
setImageUrl(result.data)
|
setImageUrl(result.data)
|
||||||
setIsThumbnail(Boolean(result.isThumb))
|
setIsThumbnail(Boolean(result.isThumb))
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -92,6 +136,10 @@ export function ImageBubble({
|
|||||||
force: true
|
force: true
|
||||||
})
|
})
|
||||||
if (result.success && result.data?.startsWith('data:image/')) {
|
if (result.success && result.data?.startsWith('data:image/')) {
|
||||||
|
cacheImage(imageMd5, imageDatName, {
|
||||||
|
data: result.data,
|
||||||
|
isThumbnail: Boolean(result.isThumb)
|
||||||
|
})
|
||||||
setImageUrl(result.data)
|
setImageUrl(result.data)
|
||||||
setIsThumbnail(Boolean(result.isThumb))
|
setIsThumbnail(Boolean(result.isThumb))
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
|
|||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
contact: Contact
|
contact: Contact
|
||||||
isGroupChat: boolean
|
isGroupChat: boolean
|
||||||
dateRangeLabel: string
|
|
||||||
loadedCount: number
|
loadedCount: number
|
||||||
filteredCount: number
|
filteredCount: number
|
||||||
contentFilter: string
|
contentFilter: string
|
||||||
@@ -20,7 +19,6 @@ interface ChatHeaderProps {
|
|||||||
export function ChatHeader({
|
export function ChatHeader({
|
||||||
contact,
|
contact,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
dateRangeLabel,
|
|
||||||
loadedCount,
|
loadedCount,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
contentFilter,
|
contentFilter,
|
||||||
@@ -65,7 +63,6 @@ export function ChatHeader({
|
|||||||
<h2>{displayName}</h2>
|
<h2>{displayName}</h2>
|
||||||
<div className="chat-title-meta">
|
<div className="chat-title-meta">
|
||||||
<span>{typeLabel}</span>
|
<span>{typeLabel}</span>
|
||||||
<span>{dateRangeLabel}</span>
|
|
||||||
<span>{visibleCount} 条消息</span>
|
<span>{visibleCount} 条消息</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { RefreshIcon } from './icons'
|
||||||
|
|
||||||
interface ChatStatusBarProps {
|
interface ChatStatusBarProps {
|
||||||
count: number
|
count: number
|
||||||
showAvatar: boolean
|
showAvatar: boolean
|
||||||
isAtLatest: boolean
|
isAtLatest: boolean
|
||||||
|
isReloadingAvatars: boolean
|
||||||
onShowAvatarChange: (show: boolean) => void
|
onShowAvatarChange: (show: boolean) => void
|
||||||
|
onReloadAvatars: () => void
|
||||||
onJumpToLatest: () => void
|
onJumpToLatest: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,7 +15,9 @@ export function ChatStatusBar({
|
|||||||
count,
|
count,
|
||||||
showAvatar,
|
showAvatar,
|
||||||
isAtLatest,
|
isAtLatest,
|
||||||
|
isReloadingAvatars,
|
||||||
onShowAvatarChange,
|
onShowAvatarChange,
|
||||||
|
onReloadAvatars,
|
||||||
onJumpToLatest
|
onJumpToLatest
|
||||||
}: ChatStatusBarProps): React.ReactElement {
|
}: ChatStatusBarProps): React.ReactElement {
|
||||||
const jumpDisabled = isAtLatest || count === 0
|
const jumpDisabled = isAtLatest || count === 0
|
||||||
@@ -29,6 +34,22 @@ export function ChatStatusBar({
|
|||||||
/>
|
/>
|
||||||
<span>显示头像</span>
|
<span>显示头像</span>
|
||||||
</label>
|
</label>
|
||||||
|
<span
|
||||||
|
className="chat-avatar-note"
|
||||||
|
title="头像或名称缺失时,请先在微信中进入该聊天,再尝试重新加载"
|
||||||
|
>
|
||||||
|
头像或名称缺失时,请先在微信中进入该聊天
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-avatar-reload"
|
||||||
|
onClick={onReloadAvatars}
|
||||||
|
disabled={isReloadingAvatars}
|
||||||
|
title="尝试重新加载当前聊天的头像和名称"
|
||||||
|
>
|
||||||
|
<RefreshIcon className={isReloadingAvatars ? 'is-spinning' : ''} />
|
||||||
|
<span>{isReloadingAvatars ? '加载中' : '重新加载头像'}</span>
|
||||||
|
</button>
|
||||||
<span className="chat-status-separator" aria-hidden />
|
<span className="chat-status-separator" aria-hidden />
|
||||||
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
|
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
|
||||||
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
|
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function MessageGroup({
|
|||||||
)}
|
)}
|
||||||
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
||||||
<div className="message-stack">
|
<div className="message-stack">
|
||||||
{!isMine && isGroupChat && shouldShowAvatar && (
|
{!isMine && isGroupChat && (
|
||||||
<div className="message-sender-name">{displayName}</div>
|
<div className="message-sender-name">{displayName}</div>
|
||||||
)}
|
)}
|
||||||
{group.messages.map((message, index) => (
|
{group.messages.map((message, index) => (
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface MessageListProps {
|
|||||||
listRef: React.RefObject<HTMLDivElement | null>
|
listRef: React.RefObject<HTMLDivElement | null>
|
||||||
bottomRef: React.RefObject<HTMLDivElement | null>
|
bottomRef: React.RefObject<HTMLDivElement | null>
|
||||||
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
||||||
onReachTop?: () => void
|
onReachTop?: () => Promise<void>
|
||||||
onImageClick: (imageUrl: string) => void
|
onImageClick: (imageUrl: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +32,9 @@ export function MessageList({
|
|||||||
onImageClick
|
onImageClick
|
||||||
}: MessageListProps): React.ReactElement {
|
}: MessageListProps): React.ReactElement {
|
||||||
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
||||||
|
const groupsRef = React.useRef(groups)
|
||||||
|
const loadingOlderRef = React.useRef(false)
|
||||||
|
groupsRef.current = groups
|
||||||
// TanStack Virtual intentionally exposes mutable measurement methods.
|
// TanStack Virtual intentionally exposes mutable measurement methods.
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
@@ -45,7 +48,54 @@ export function MessageList({
|
|||||||
|
|
||||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
|
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
|
||||||
onScroll(event)
|
onScroll(event)
|
||||||
if (event.currentTarget.scrollTop < 120) onReachTop?.()
|
const scrollElement = event.currentTarget
|
||||||
|
if (
|
||||||
|
scrollElement.scrollTop >= 48 ||
|
||||||
|
loadingOlderRef.current ||
|
||||||
|
isLoadingMessages ||
|
||||||
|
!onReachTop
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingOlderRef.current = true
|
||||||
|
const previousGroupCount = groups.length
|
||||||
|
const previousScrollTop = scrollElement.scrollTop
|
||||||
|
const previousScrollHeight = scrollElement.scrollHeight
|
||||||
|
const anchorMessageId = groups[0]?.messages[0]?.id
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await onReachTop()
|
||||||
|
for (let frame = 0; frame < 8; frame += 1) {
|
||||||
|
if (groupsRef.current.length > previousGroupCount) break
|
||||||
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||||
|
}
|
||||||
|
if (!anchorMessageId) return
|
||||||
|
const anchorIndex = groupsRef.current.findIndex((group) =>
|
||||||
|
group.messages.some((message) => message.id === anchorMessageId)
|
||||||
|
)
|
||||||
|
if (anchorIndex <= 0) return
|
||||||
|
virtualizer.measure()
|
||||||
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||||
|
const addedHeight = scrollElement.scrollHeight - previousScrollHeight
|
||||||
|
if (addedHeight > 0) {
|
||||||
|
scrollElement.scrollTop = previousScrollTop + addedHeight
|
||||||
|
} else {
|
||||||
|
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable-height groups can finish measuring one frame later. Preserve
|
||||||
|
// the same message anchor after that final measurement as well.
|
||||||
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||||
|
if (scrollElement.scrollTop < 48) {
|
||||||
|
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
loadingOlderRef.current = false
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ export interface ConversationSidebarProps {
|
|||||||
onSearch: (keyword: string) => void
|
onSearch: (keyword: string) => void
|
||||||
onContentFilter: (keyword: string) => void
|
onContentFilter: (keyword: string) => void
|
||||||
width: number
|
width: number
|
||||||
dateRange: string
|
|
||||||
onDateRangeChange: (range: string) => void
|
|
||||||
selfInfo: SelfInfo | null
|
selfInfo: SelfInfo | null
|
||||||
dbReady: boolean
|
dbReady: boolean
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
@@ -37,8 +35,6 @@ export function ConversationSidebar({
|
|||||||
onSelectContact,
|
onSelectContact,
|
||||||
onSearch,
|
onSearch,
|
||||||
width,
|
width,
|
||||||
dateRange,
|
|
||||||
onDateRangeChange,
|
|
||||||
selfInfo,
|
selfInfo,
|
||||||
dbReady,
|
dbReady,
|
||||||
onOpenSettings
|
onOpenSettings
|
||||||
@@ -83,9 +79,7 @@ export function ConversationSidebar({
|
|||||||
<ConversationSidebarHeader
|
<ConversationSidebarHeader
|
||||||
totalCount={contacts.length}
|
totalCount={contacts.length}
|
||||||
searchValue={searchTerm}
|
searchValue={searchTerm}
|
||||||
dateRange={dateRange}
|
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
onDateRangeChange={onDateRangeChange}
|
|
||||||
/>
|
/>
|
||||||
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
||||||
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { ConversationSearch } from './ConversationSearch'
|
import { ConversationSearch } from './ConversationSearch'
|
||||||
import { DateRangeSelector } from './DateRangeSelector'
|
|
||||||
|
|
||||||
interface ConversationSidebarHeaderProps {
|
interface ConversationSidebarHeaderProps {
|
||||||
totalCount: number
|
totalCount: number
|
||||||
searchValue: string
|
searchValue: string
|
||||||
dateRange: string
|
|
||||||
onSearchChange: (value: string) => void
|
onSearchChange: (value: string) => void
|
||||||
onDateRangeChange: (range: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationSidebarHeader({
|
export function ConversationSidebarHeader({
|
||||||
totalCount,
|
totalCount,
|
||||||
searchValue,
|
searchValue,
|
||||||
dateRange,
|
onSearchChange
|
||||||
onSearchChange,
|
|
||||||
onDateRangeChange
|
|
||||||
}: ConversationSidebarHeaderProps): React.ReactElement {
|
}: ConversationSidebarHeaderProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="conversation-sidebar-header">
|
<div className="conversation-sidebar-header">
|
||||||
@@ -24,7 +19,6 @@ export function ConversationSidebarHeader({
|
|||||||
<span>{totalCount} 个会话</span>
|
<span>{totalCount} 个会话</span>
|
||||||
</div>
|
</div>
|
||||||
<ConversationSearch value={searchValue} onChange={onSearchChange} />
|
<ConversationSearch value={searchValue} onChange={onSearchChange} />
|
||||||
<DateRangeSelector value={dateRange} onChange={onDateRangeChange} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
interface DateRangeSelectorProps {
|
|
||||||
value: string
|
|
||||||
onChange: (range: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const DATE_RANGE_OPTIONS = [
|
|
||||||
{ key: 'today', label: '今天' },
|
|
||||||
{ key: 'yesterday', label: '昨日' },
|
|
||||||
{ key: '7', label: '7 天' },
|
|
||||||
{ key: '30', label: '30 天' },
|
|
||||||
{ key: 'all', label: '全部' }
|
|
||||||
]
|
|
||||||
|
|
||||||
export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="conversation-date-range" aria-label="时间范围">
|
|
||||||
{DATE_RANGE_OPTIONS.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
type="button"
|
|
||||||
className={`conversation-date-range-button ${value === item.key ? 'active' : ''}`}
|
|
||||||
onClick={() => onChange(item.key)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user