mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-07 06:17:07 +08:00
feat: 实时刷新档案会话列表
收到新消息后增量更新会话摘要和时间 自动按最新消息重新排序并保留当前选择状态
This commit is contained in:
@@ -85,6 +85,13 @@ interface SelfInfo {
|
|||||||
accountRoot: string
|
accountRoot: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ContactLoadOptions {
|
||||||
|
waitForAvatars?: boolean
|
||||||
|
onProgress?: (message: string, percent?: number) => void
|
||||||
|
filterKeyword?: string
|
||||||
|
skipAvatarHydration?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const MAC_KEY_FAQ_URL = 'https://github.com/Wxw-Gu/TraceMemo/blob/main/docs/mac-disable-sip.md'
|
const MAC_KEY_FAQ_URL = 'https://github.com/Wxw-Gu/TraceMemo/blob/main/docs/mac-disable-sip.md'
|
||||||
const FIRST_USE_WELCOME_SEEN_KEY = 'wxe_first_use_welcome_seen'
|
const FIRST_USE_WELCOME_SEEN_KEY = 'wxe_first_use_welcome_seen'
|
||||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 350
|
const MESSAGE_MONITOR_DEBOUNCE_MS = 350
|
||||||
@@ -440,6 +447,11 @@ function App(): React.ReactElement {
|
|||||||
}, [])
|
}, [])
|
||||||
const selectedContactMd5Ref = React.useRef<string>('')
|
const selectedContactMd5Ref = React.useRef<string>('')
|
||||||
const contactAvatarHydrationRunRef = React.useRef(0)
|
const contactAvatarHydrationRunRef = React.useRef(0)
|
||||||
|
const contactLoadRequestRef = React.useRef(0)
|
||||||
|
const contactSearchTermRef = React.useRef('')
|
||||||
|
const loadContactsRef = React.useRef<((options?: ContactLoadOptions) => Promise<void>) | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
const reportGeneration = useGroupReportGeneration({
|
const reportGeneration = useGroupReportGeneration({
|
||||||
sourceContact: reportSourceContact,
|
sourceContact: reportSourceContact,
|
||||||
summaryDateRange,
|
summaryDateRange,
|
||||||
@@ -600,18 +612,21 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadContacts = async (options?: {
|
const loadContacts = async (options?: ContactLoadOptions): Promise<void> => {
|
||||||
waitForAvatars?: boolean
|
const requestId = ++contactLoadRequestRef.current
|
||||||
onProgress?: (message: string, percent?: number) => void
|
|
||||||
filterKeyword?: string
|
|
||||||
}): Promise<void> => {
|
|
||||||
options?.onProgress?.('正在加载联系人...', 35)
|
options?.onProgress?.('正在加载联系人...', 35)
|
||||||
const list = await window.api.getContacts()
|
const list = await window.api.getContacts()
|
||||||
|
if (requestId !== contactLoadRequestRef.current) return
|
||||||
setContacts(list)
|
setContacts(list)
|
||||||
setFilteredContacts(
|
setFilteredContacts(
|
||||||
filterContactSearchIndex(buildContactSearchIndex(list), options?.filterKeyword || '')
|
filterContactSearchIndex(buildContactSearchIndex(list), options?.filterKeyword || '')
|
||||||
)
|
)
|
||||||
|
setSelectedContact((current) => {
|
||||||
|
if (!current) return current
|
||||||
|
return list.find((contact) => contact.md5 === current.md5) || current
|
||||||
|
})
|
||||||
const runId = ++contactAvatarHydrationRunRef.current
|
const runId = ++contactAvatarHydrationRunRef.current
|
||||||
|
if (options?.skipAvatarHydration) return
|
||||||
const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress)
|
const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress)
|
||||||
if (options?.waitForAvatars) {
|
if (options?.waitForAvatars) {
|
||||||
await hydrate()
|
await hydrate()
|
||||||
@@ -622,6 +637,8 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadContactsRef.current = loadContacts
|
||||||
|
|
||||||
const hydrateContactAvatars = async (
|
const hydrateContactAvatars = async (
|
||||||
list: Contact[],
|
list: Contact[],
|
||||||
runId: number,
|
runId: number,
|
||||||
@@ -1137,6 +1154,9 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleReturnToLogin = (): void => {
|
const handleReturnToLogin = (): void => {
|
||||||
|
contactLoadRequestRef.current += 1
|
||||||
|
contactAvatarHydrationRunRef.current += 1
|
||||||
|
contactSearchTermRef.current = ''
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
setIsDatabaseConnected(false)
|
setIsDatabaseConnected(false)
|
||||||
setIsDatabaseConnecting(false)
|
setIsDatabaseConnecting(false)
|
||||||
@@ -1159,6 +1179,9 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
const handleSwitchAccount = async (account: WechatAccountCandidate): Promise<void> => {
|
const handleSwitchAccount = async (account: WechatAccountCandidate): Promise<void> => {
|
||||||
connectionOperationRef.current += 1
|
connectionOperationRef.current += 1
|
||||||
|
contactLoadRequestRef.current += 1
|
||||||
|
contactAvatarHydrationRunRef.current += 1
|
||||||
|
contactSearchTermRef.current = ''
|
||||||
await window.api.disconnectDb({ closeNative: true })
|
await window.api.disconnectDb({ closeNative: true })
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
setIsDatabaseConnected(false)
|
setIsDatabaseConnected(false)
|
||||||
@@ -1483,11 +1506,69 @@ function App(): React.ReactElement {
|
|||||||
mergeSyntheticMessages
|
mergeSyntheticMessages
|
||||||
])
|
])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isAuthenticated || !isNativeMonitorActive) return
|
||||||
|
|
||||||
|
let disposed = false
|
||||||
|
let refreshTimer: number | null = null
|
||||||
|
let refreshInFlight = false
|
||||||
|
let refreshQueued = false
|
||||||
|
|
||||||
|
const refreshContactList = async (): Promise<void> => {
|
||||||
|
if (disposed) return
|
||||||
|
if (refreshInFlight) {
|
||||||
|
refreshQueued = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshInFlight = true
|
||||||
|
try {
|
||||||
|
await loadContactsRef.current?.({
|
||||||
|
filterKeyword: contactSearchTermRef.current,
|
||||||
|
skipAvatarHydration: true
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (!disposed) console.warn('[ContactMonitor] 刷新会话列表失败:', error)
|
||||||
|
} finally {
|
||||||
|
refreshInFlight = false
|
||||||
|
if (refreshQueued && !disposed) {
|
||||||
|
refreshQueued = false
|
||||||
|
if (refreshTimer) window.clearTimeout(refreshTimer)
|
||||||
|
refreshTimer = window.setTimeout(() => {
|
||||||
|
refreshTimer = null
|
||||||
|
void refreshContactList()
|
||||||
|
}, MESSAGE_MONITOR_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleContactRefresh = (): void => {
|
||||||
|
if (disposed) return
|
||||||
|
if (refreshTimer) window.clearTimeout(refreshTimer)
|
||||||
|
refreshTimer = window.setTimeout(() => {
|
||||||
|
refreshTimer = null
|
||||||
|
void refreshContactList()
|
||||||
|
}, MESSAGE_MONITOR_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = window.api.onWcdbChange(({ json }) => {
|
||||||
|
if (!isRelevantMessageMonitorEvent(json)) return
|
||||||
|
scheduleContactRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
if (refreshTimer) window.clearTimeout(refreshTimer)
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isNativeMonitorActive])
|
||||||
|
|
||||||
const handleSearchContacts = (keyword: string): void => {
|
const handleSearchContacts = (keyword: string): void => {
|
||||||
|
contactSearchTermRef.current = keyword
|
||||||
setFilteredContacts(filterContactList(contactSearchIndex, keyword))
|
setFilteredContacts(filterContactList(contactSearchIndex, keyword))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRefreshContacts = async (filterKeyword: string): Promise<void> => {
|
const handleRefreshContacts = async (filterKeyword: string): Promise<void> => {
|
||||||
|
contactSearchTermRef.current = filterKeyword
|
||||||
try {
|
try {
|
||||||
await loadContacts({ waitForAvatars: false, filterKeyword })
|
await loadContacts({ waitForAvatars: false, filterKeyword })
|
||||||
setReportNotice('会话列表已刷新')
|
setReportNotice('会话列表已刷新')
|
||||||
|
|||||||
@@ -276,6 +276,69 @@ test('CHAT-REALTIME-01 archive refreshes from a native message-shard event', asy
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('CHAT-REALTIME-02 refreshes and reorders conversations without losing selection or filter', async () => {
|
||||||
|
const fixture = await launchTestApp({ realtimeReorder: true })
|
||||||
|
try {
|
||||||
|
const names = fixture.page.locator('.conversation-item-name')
|
||||||
|
await expect(names).toHaveText(['产品测试群', '自动刷新 B', '自动刷新 A'])
|
||||||
|
|
||||||
|
await fixture.page.getByRole('button', { name: /自动刷新 A/ }).click()
|
||||||
|
await fixture.page.getByRole('searchbox', { name: '搜索会话' }).fill('自动刷新')
|
||||||
|
await expect(fixture.page.getByRole('searchbox', { name: '搜索会话' })).toHaveValue('自动刷新')
|
||||||
|
await expect(fixture.page.locator('.conversation-item-name')).toHaveText([
|
||||||
|
'自动刷新 B',
|
||||||
|
'自动刷新 A',
|
||||||
|
'自动刷新 D',
|
||||||
|
'自动刷新 C'
|
||||||
|
])
|
||||||
|
|
||||||
|
const readsBeforeMessage = await fixture.page.evaluate(() =>
|
||||||
|
window.electron.ipcRenderer.invoke('test:getContactReadCount')
|
||||||
|
)
|
||||||
|
await fixture.page.evaluate(() =>
|
||||||
|
window.electron.ipcRenderer.invoke('test:messageChange', {
|
||||||
|
reorderContactMd5: 'realtime-group-a-md5',
|
||||||
|
event: { db: 'message_0.db', table: 'message', action: 'insert' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await expect
|
||||||
|
.poll(async () => fixture.page.locator('.conversation-item-name').allTextContents())
|
||||||
|
.toEqual(['自动刷新 A', '自动刷新 B', '自动刷新 D', '自动刷新 C'])
|
||||||
|
expect(await fixture.page.getByRole('searchbox', { name: '搜索会话' }).inputValue()).toBe(
|
||||||
|
'自动刷新'
|
||||||
|
)
|
||||||
|
await expect(
|
||||||
|
fixture.page.locator('.conversation-item.active .conversation-item-name')
|
||||||
|
).toHaveText('自动刷新 A')
|
||||||
|
|
||||||
|
const readsAfterSingleMessage = await fixture.page.evaluate(() =>
|
||||||
|
window.electron.ipcRenderer.invoke('test:getContactReadCount')
|
||||||
|
)
|
||||||
|
expect(readsAfterSingleMessage - readsBeforeMessage).toBe(1)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 50 }, () =>
|
||||||
|
fixture.page.evaluate(() =>
|
||||||
|
window.electron.ipcRenderer.invoke('test:messageChange', {
|
||||||
|
reorderContactMd5: 'realtime-direct-c-md5',
|
||||||
|
event: { db: 'message_0.db', table: 'message', action: 'insert' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await expect
|
||||||
|
.poll(async () => fixture.page.locator('.conversation-item-name').allTextContents())
|
||||||
|
.toEqual(['自动刷新 A', '自动刷新 B', '自动刷新 C', '自动刷新 D'])
|
||||||
|
const readsAfterBurst = await fixture.page.evaluate(() =>
|
||||||
|
window.electron.ipcRenderer.invoke('test:getContactReadCount')
|
||||||
|
)
|
||||||
|
expect(readsAfterBurst - readsAfterSingleMessage).toBe(1)
|
||||||
|
await expect(fixture.page.locator('.conversation-item-name')).toHaveCount(4)
|
||||||
|
} finally {
|
||||||
|
await fixture.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport', async () => {
|
test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport', async () => {
|
||||||
test.skip(process.platform !== 'darwin', 'Personal WeChat sending is currently macOS-only')
|
test.skip(process.platform !== 'darwin', 'Personal WeChat sending is currently macOS-only')
|
||||||
const fixture = await launchTestApp()
|
const fixture = await launchTestApp()
|
||||||
|
|||||||
@@ -230,12 +230,41 @@ for (let index = 0; index < extraContacts; index += 1) {
|
|||||||
type: index % 5 === 0 ? 'group' : 'user'
|
type: index % 5 === 0 ? 'group' : 'user'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (process.env.WXE_E2E_REALTIME_REORDER === '1') {
|
||||||
|
contacts.push(
|
||||||
|
{
|
||||||
|
m_nsUsrName: 'realtime_group_b@chatroom',
|
||||||
|
m_nsNickName: '自动刷新 B',
|
||||||
|
md5: 'realtime-group-b-md5',
|
||||||
|
type: 'group'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
m_nsUsrName: 'realtime_group_a@chatroom',
|
||||||
|
m_nsNickName: '自动刷新 A',
|
||||||
|
md5: 'realtime-group-a-md5',
|
||||||
|
type: 'group'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
m_nsUsrName: 'realtime_direct_d',
|
||||||
|
m_nsNickName: '自动刷新 D',
|
||||||
|
md5: 'realtime-direct-d-md5',
|
||||||
|
type: 'user'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
m_nsUsrName: 'realtime_direct_c',
|
||||||
|
m_nsNickName: '自动刷新 C',
|
||||||
|
md5: 'realtime-direct-c-md5',
|
||||||
|
type: 'user'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const handlers = new Map()
|
const handlers = new Map()
|
||||||
const handle = (channel, fn) => {
|
const handle = (channel, fn) => {
|
||||||
handlers.set(channel, fn)
|
handlers.set(channel, fn)
|
||||||
ipcMain.handle(channel, async (event, ...args) => fn(...args))
|
ipcMain.handle(channel, async (event, ...args) => fn(...args))
|
||||||
}
|
}
|
||||||
|
let contactReadCount = 0
|
||||||
|
|
||||||
const groupExitEvent = {
|
const groupExitEvent = {
|
||||||
id: 'fixture-exit-event',
|
id: 'fixture-exit-event',
|
||||||
@@ -658,11 +687,13 @@ handle('db:getBootstrapCache', () =>
|
|||||||
process.env.WXE_E2E_CORRUPT_CACHE === '1' ? null : startupCache()
|
process.env.WXE_E2E_CORRUPT_CACHE === '1' ? null : startupCache()
|
||||||
)
|
)
|
||||||
handle('db:getContacts', (filter) => {
|
handle('db:getContacts', (filter) => {
|
||||||
|
contactReadCount += 1
|
||||||
const query = String(filter || '').toLowerCase()
|
const query = String(filter || '').toLowerCase()
|
||||||
return query
|
return query
|
||||||
? contacts.filter((contact) => contact.m_nsNickName.toLowerCase().includes(query))
|
? contacts.filter((contact) => contact.m_nsNickName.toLowerCase().includes(query))
|
||||||
: contacts
|
: contacts
|
||||||
})
|
})
|
||||||
|
handle('test:getContactReadCount', () => contactReadCount)
|
||||||
handle('db:getContactAvatars', (usernames) =>
|
handle('db:getContactAvatars', (usernames) =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
contacts
|
contacts
|
||||||
@@ -690,6 +721,14 @@ handle('test:messageChange', (payload = {}) => {
|
|||||||
if (md5 && message && Array.isArray(fixture.messages[md5])) {
|
if (md5 && message && Array.isArray(fixture.messages[md5])) {
|
||||||
fixture.messages[md5].push(message)
|
fixture.messages[md5].push(message)
|
||||||
}
|
}
|
||||||
|
const reorderContactMd5 = String(payload.reorderContactMd5 || '')
|
||||||
|
if (reorderContactMd5) {
|
||||||
|
const index = contacts.findIndex((contact) => contact.md5 === reorderContactMd5)
|
||||||
|
if (index >= 0) {
|
||||||
|
const [contact] = contacts.splice(index, 1)
|
||||||
|
contacts.unshift(contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
const event = payload.event || { db: 'message_0.db', table: 'message', action: 'update' }
|
const event = payload.event || { db: 'message_0.db', table: 'message', action: 'update' }
|
||||||
const json = typeof event === 'string' ? event : JSON.stringify(event)
|
const json = typeof event === 'string' ? event : JSON.stringify(event)
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export async function launchTestApp(
|
|||||||
aiFailure?: string
|
aiFailure?: string
|
||||||
updateSimulation?: boolean
|
updateSimulation?: boolean
|
||||||
unsignedMacUpdate?: boolean
|
unsignedMacUpdate?: boolean
|
||||||
|
realtimeReorder?: boolean
|
||||||
now?: number
|
now?: number
|
||||||
appearanceTheme?: 'light' | 'dark'
|
appearanceTheme?: 'light' | 'dark'
|
||||||
stableUserData?: string
|
stableUserData?: string
|
||||||
@@ -52,6 +53,7 @@ export async function launchTestApp(
|
|||||||
WXE_E2E_AI_FAILURE: options.aiFailure || '',
|
WXE_E2E_AI_FAILURE: options.aiFailure || '',
|
||||||
WXE_E2E_UPDATE_SIMULATION: options.updateSimulation ? '1' : '0',
|
WXE_E2E_UPDATE_SIMULATION: options.updateSimulation ? '1' : '0',
|
||||||
WXE_E2E_UNSIGNED_MAC_UPDATE: options.unsignedMacUpdate ? '1' : '0',
|
WXE_E2E_UNSIGNED_MAC_UPDATE: options.unsignedMacUpdate ? '1' : '0',
|
||||||
|
WXE_E2E_REALTIME_REORDER: options.realtimeReorder ? '1' : '0',
|
||||||
WXE_E2E_NOW_MS: options.now ? String(options.now) : '',
|
WXE_E2E_NOW_MS: options.now ? String(options.now) : '',
|
||||||
WXE_E2E_APPEARANCE_THEME: options.appearanceTheme || 'light'
|
WXE_E2E_APPEARANCE_THEME: options.appearanceTheme || 'light'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user