diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 57517e0..bbd6f78 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -35,6 +35,7 @@ import { mergeMessagePages, sortMessagesChronologically } from './utils/message-pages' +import { isRelevantMessageMonitorEvent, parseWcdbMonitorEvent } from './utils/message-monitor' import { enrichQuotedMessages } from './utils/quoted-messages' import type { SelectableReportTemplateId } from '../../shared/report-templates' import { switchGeneratedReportTemplate } from './utils/report-template-switch' @@ -77,7 +78,7 @@ interface SelfInfo { 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 = 8000 +const MESSAGE_MONITOR_DEBOUNCE_MS = 350 const INITIAL_MESSAGE_COUNT = 20 const MESSAGE_PAGE_SIZE = 100 const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE @@ -1387,11 +1388,14 @@ function App(): React.ReactElement { const latestMessages = await window.api.getMessages(contactMd5, undefined, undefined, { limit: INITIAL_MESSAGE_COUNT }) + if (disposed || selectedContactMd5Ref.current !== contactMd5) return + const nextHistory = mergeMessagePages(messageHistoryRef.current, latestMessages) + messageHistoryRef.current = nextHistory const nextMessages = applyGroupMemberMeta( selectedContact, - mergeSyntheticMessages(selectedContact, latestMessages) + mergeSyntheticMessages(selectedContact, nextHistory.slice(-INITIAL_MESSAGE_COUNT)) ) - if (!disposed) { + if (!disposed && selectedContactMd5Ref.current === contactMd5) { setMessages((current) => areMessagesEquivalent(current, nextMessages) ? current : nextMessages ) @@ -1411,11 +1415,13 @@ function App(): React.ReactElement { } const unsubscribe = window.api.onWcdbChange(({ json }) => { - const eventText = String(json || '').toLowerCase() - const targetIds = [contactMd5, selectedContact.m_nsUsrName] - .filter(Boolean) - .map((value) => value.toLowerCase()) - if (!targetIds.some((targetId) => eventText.includes(targetId))) return + const payload = String(json || '') + const targetIds = [contactMd5, selectedContact.m_nsUsrName].filter(Boolean) + if (!isRelevantMessageMonitorEvent(payload, targetIds)) return + const event = parseWcdbMonitorEvent(payload) + if (event?.table || event?.db) { + console.debug(`event queued table=${event.table || 'unknown'} db=${event.db || 'unknown'}`) + } if (refreshTimer) window.clearTimeout(refreshTimer) refreshTimer = window.setTimeout(() => { refreshTimer = null diff --git a/src/renderer/src/utils/message-monitor.ts b/src/renderer/src/utils/message-monitor.ts new file mode 100644 index 0000000..6f83136 --- /dev/null +++ b/src/renderer/src/utils/message-monitor.ts @@ -0,0 +1,60 @@ +export interface WcdbMonitorEvent { + db?: string + table?: string + action?: string +} + +const normalizeMonitorValue = (value: unknown): string => + typeof value === 'string' ? value.trim() : '' + +const monitorBasename = (value: string): string => { + const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '') + const basename = normalized.slice(normalized.lastIndexOf('/') + 1) + return basename.replace(/-(?:wal|shm)$/i, '') +} + +export const parseWcdbMonitorEvent = (rawPayload: string): WcdbMonitorEvent | null => { + try { + const parsed: unknown = JSON.parse(rawPayload) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + const event = parsed as Record + const result: WcdbMonitorEvent = {} + const db = normalizeMonitorValue(event.db) + const table = normalizeMonitorValue(event.table) + const action = normalizeMonitorValue(event.action) + if (db) result.db = db + if (table) result.table = table + if (action) result.action = action + return result + } catch { + return null + } +} + +const isMessageStoreName = (value: string): boolean => { + const basename = monitorBasename(value).toLowerCase() + return ( + basename === 'message' || + basename.startsWith('message_') || + basename === 'msg' || + basename.startsWith('msg_') + ) +} + +export const isRelevantMessageMonitorEvent = ( + rawPayload: string, + targetIds: string[] = [] +): boolean => { + const event = parseWcdbMonitorEvent(rawPayload) + if (!event) return false + + const db = event.db || '' + const table = event.table || '' + if (isMessageStoreName(table) || isMessageStoreName(db)) return true + + if (!db && !table) return true + + const normalizedTargets = targetIds.map((value) => value.trim().toLowerCase()).filter(Boolean) + const eventText = `${db} ${table}`.toLowerCase() + return normalizedTargets.some((targetId) => eventText.includes(targetId)) +} diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index e43c800..ddde143 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -229,6 +229,44 @@ test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', asy } }) +test('CHAT-REALTIME-01 archive refreshes from a native message-shard event', async () => { + const fixture = await launchTestApp() + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + try { + await fixture.setWindowContentSize({ width: 820, height: 600 }) + await fixture.page.getByText('产品测试群', { exact: true }).click() + await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible() + + const result = await fixture.page.evaluate(() => + window.electron.ipcRenderer.invoke('test:messageChange', { + md5: 'group-regular-md5', + event: { db: 'message_0.db', table: 'message', action: 'update' }, + message: { + id: 'msg-monitor', + localId: 999, + from: 'user', + type: '普通文本', + datetime: '2026-08-31 12:00:00', + content: '消息', + isSender: false, + name: '测试成员', + senderId: 'wxid_fixture_member', + createTime: Math.floor(Date.now() / 1000), + contentData: { type: 'text', content: '消息' } + } + }) + ) + expect(result).toEqual({ success: true }) + await expect(fixture.page.getByText('消息', { exact: true })).toBeVisible({ + timeout: 3_000 + }) + expect(pageErrors).toEqual([]) + } 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 44da237..447b439 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -606,6 +606,19 @@ handle('db:getMessages', (md5, startTime, endTime, options) => { if (options && options.limit) messages = messages.slice(-options.limit) return messages }) +handle('test:messageChange', (payload = {}) => { + const md5 = String(payload.md5 || '') + const message = payload.message + if (md5 && message && Array.isArray(fixture.messages[md5])) { + fixture.messages[md5].push(message) + } + 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()) { + if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type: 'update', json }) + } + return { success: true } +}) handle('db:getGroupSnapshot', (md5) => md5.startsWith('group-') ? { diff --git a/tests/unit/message-monitor.test.ts b/tests/unit/message-monitor.test.ts new file mode 100644 index 0000000..3f6c112 --- /dev/null +++ b/tests/unit/message-monitor.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + isRelevantMessageMonitorEvent, + parseWcdbMonitorEvent +} from '../../src/renderer/src/utils/message-monitor' + +describe('message monitor events', () => { + it('parses structured native events and normalizes empty fields', () => { + expect( + parseWcdbMonitorEvent('{"db":"message_0.db","table":"message","action":"update"}') + ).toEqual({ + db: 'message_0.db', + table: 'message', + action: 'update' + }) + expect(parseWcdbMonitorEvent('{"table":" ","action":null}')).toEqual({}) + }) + + it('refreshes the active archive for message shard events without a session id', () => { + expect( + isRelevantMessageMonitorEvent('{"db":"message_0.db","table":"message","action":"update"}', [ + 'group-md5', + 'room@chatroom' + ]) + ).toBe(true) + expect( + isRelevantMessageMonitorEvent( + '{"db":"/tmp/db_storage/message/Msg_1.db-wal","table":"database"}', + ['group-md5'] + ) + ).toBe(true) + }) + + it('ignores unrelated session events but keeps legacy action-only compatibility', () => { + expect( + isRelevantMessageMonitorEvent('{"db":"session.db","table":"Session"}', [ + 'group-md5', + 'room@chatroom' + ]) + ).toBe(false) + expect(isRelevantMessageMonitorEvent('{"action":"update"}', ['group-md5'])).toBe(true) + expect(isRelevantMessageMonitorEvent('not-json', ['group-md5'])).toBe(false) + }) +})