From cef1ad493cf1509b67e7aaf984541cbb2c506f28 Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Sat, 12 Sep 2026 23:49:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E6=97=B6=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E6=A1=A3=E6=A1=88=E4=BC=9A=E8=AF=9D=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收到新消息后增量更新会话摘要和时间 自动按最新消息重新排序并保留当前选择状态 --- src/renderer/src/App.tsx | 91 +++++++++++++++++++++++++++-- tests/e2e/app.spec.ts | 63 ++++++++++++++++++++ tests/e2e/support/electron-main.cjs | 39 +++++++++++++ tests/e2e/support/electron.ts | 2 + 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c13e289..19e3ae6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -85,6 +85,13 @@ interface SelfInfo { 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 FIRST_USE_WELCOME_SEEN_KEY = 'wxe_first_use_welcome_seen' const MESSAGE_MONITOR_DEBOUNCE_MS = 350 @@ -440,6 +447,11 @@ function App(): React.ReactElement { }, []) const selectedContactMd5Ref = React.useRef('') const contactAvatarHydrationRunRef = React.useRef(0) + const contactLoadRequestRef = React.useRef(0) + const contactSearchTermRef = React.useRef('') + const loadContactsRef = React.useRef<((options?: ContactLoadOptions) => Promise) | null>( + null + ) const reportGeneration = useGroupReportGeneration({ sourceContact: reportSourceContact, summaryDateRange, @@ -600,18 +612,21 @@ function App(): React.ReactElement { } } - const loadContacts = async (options?: { - waitForAvatars?: boolean - onProgress?: (message: string, percent?: number) => void - filterKeyword?: string - }): Promise => { + const loadContacts = async (options?: ContactLoadOptions): Promise => { + const requestId = ++contactLoadRequestRef.current options?.onProgress?.('正在加载联系人...', 35) const list = await window.api.getContacts() + if (requestId !== contactLoadRequestRef.current) return setContacts(list) setFilteredContacts( filterContactSearchIndex(buildContactSearchIndex(list), options?.filterKeyword || '') ) + setSelectedContact((current) => { + if (!current) return current + return list.find((contact) => contact.md5 === current.md5) || current + }) const runId = ++contactAvatarHydrationRunRef.current + if (options?.skipAvatarHydration) return const hydrate = (): Promise => hydrateContactAvatars(list, runId, options?.onProgress) if (options?.waitForAvatars) { await hydrate() @@ -622,6 +637,8 @@ function App(): React.ReactElement { } } + loadContactsRef.current = loadContacts + const hydrateContactAvatars = async ( list: Contact[], runId: number, @@ -1137,6 +1154,9 @@ function App(): React.ReactElement { } const handleReturnToLogin = (): void => { + contactLoadRequestRef.current += 1 + contactAvatarHydrationRunRef.current += 1 + contactSearchTermRef.current = '' setIsAuthenticated(false) setIsDatabaseConnected(false) setIsDatabaseConnecting(false) @@ -1159,6 +1179,9 @@ function App(): React.ReactElement { const handleSwitchAccount = async (account: WechatAccountCandidate): Promise => { connectionOperationRef.current += 1 + contactLoadRequestRef.current += 1 + contactAvatarHydrationRunRef.current += 1 + contactSearchTermRef.current = '' await window.api.disconnectDb({ closeNative: true }) setIsAuthenticated(false) setIsDatabaseConnected(false) @@ -1483,11 +1506,69 @@ function App(): React.ReactElement { 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 => { + 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 => { + contactSearchTermRef.current = keyword setFilteredContacts(filterContactList(contactSearchIndex, keyword)) } const handleRefreshContacts = async (filterKeyword: string): Promise => { + contactSearchTermRef.current = filterKeyword try { await loadContacts({ waitForAvatars: false, filterKeyword }) setReportNotice('会话列表已刷新') diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index c070bc4..1bd02c4 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -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.skip(process.platform !== 'darwin', 'Personal WeChat sending is currently macOS-only') const fixture = await launchTestApp() diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index 913616f..98264f5 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -230,12 +230,41 @@ for (let index = 0; index < extraContacts; index += 1) { 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 handle = (channel, fn) => { handlers.set(channel, fn) ipcMain.handle(channel, async (event, ...args) => fn(...args)) } +let contactReadCount = 0 const groupExitEvent = { id: 'fixture-exit-event', @@ -658,11 +687,13 @@ handle('db:getBootstrapCache', () => process.env.WXE_E2E_CORRUPT_CACHE === '1' ? null : startupCache() ) handle('db:getContacts', (filter) => { + contactReadCount += 1 const query = String(filter || '').toLowerCase() return query ? contacts.filter((contact) => contact.m_nsNickName.toLowerCase().includes(query)) : contacts }) +handle('test:getContactReadCount', () => contactReadCount) handle('db:getContactAvatars', (usernames) => Object.fromEntries( contacts @@ -690,6 +721,14 @@ handle('test:messageChange', (payload = {}) => { if (md5 && message && Array.isArray(fixture.messages[md5])) { 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 json = typeof event === 'string' ? event : JSON.stringify(event) for (const window of BrowserWindow.getAllWindows()) { diff --git a/tests/e2e/support/electron.ts b/tests/e2e/support/electron.ts index 4e1ebd5..40c823c 100644 --- a/tests/e2e/support/electron.ts +++ b/tests/e2e/support/electron.ts @@ -22,6 +22,7 @@ export async function launchTestApp( aiFailure?: string updateSimulation?: boolean unsignedMacUpdate?: boolean + realtimeReorder?: boolean now?: number appearanceTheme?: 'light' | 'dark' stableUserData?: string @@ -52,6 +53,7 @@ export async function launchTestApp( WXE_E2E_AI_FAILURE: options.aiFailure || '', WXE_E2E_UPDATE_SIMULATION: options.updateSimulation ? '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_APPEARANCE_THEME: options.appearanceTheme || 'light' }