diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index 73f17a41..2e7c2354 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -19,6 +19,30 @@ export interface TeamMessagesFilter { showNoise: boolean; } +interface CachedMessageFilterData { + readonly messageKind: InboxMessage['messageKind']; + readonly source: InboxMessage['source']; + readonly from: InboxMessage['from']; + readonly to: InboxMessage['to']; + readonly messageId: InboxMessage['messageId']; + readonly text: InboxMessage['text']; + readonly trimmedMessageId: string; + readonly trimmedFrom: string; + readonly trimmedTo: string; + readonly normalizedFrom: string; + readonly normalizedTo: string; + readonly normalizedText: string; + readonly isTaskCommentNotification: boolean; + readonly isTaskStallRemediation: boolean; + readonly isMemberWorkSyncNudge: boolean; + readonly isReviewPickupEscalation: boolean; + readonly isInternalControlEnvelope: boolean; + readonly isNoiseMessage: boolean; + readonly keepIdleWhenNoiseHidden: boolean; +} + +const messageFilterDataCache = new WeakMap(); + function normalizeMessageText(value: string | undefined): string { return (value ?? '') .trim() @@ -41,6 +65,47 @@ function normalizeLeadNames(values: Iterable | undefined): Set { return normalized; } +function getMessageFilterData(message: InboxMessage): CachedMessageFilterData { + const cached = messageFilterDataCache.get(message); + if ( + cached && + cached.messageKind === message.messageKind && + cached.source === message.source && + cached.from === message.from && + cached.to === message.to && + cached.messageId === message.messageId && + cached.text === message.text + ) { + return cached; + } + + const text = typeof message.text === 'string' ? message.text : ''; + const isNoiseMessage = isInboxNoiseMessage(text); + const next: CachedMessageFilterData = { + messageKind: message.messageKind, + source: message.source, + from: message.from, + to: message.to, + messageId: message.messageId, + text: message.text, + trimmedMessageId: typeof message.messageId === 'string' ? message.messageId.trim() : '', + trimmedFrom: typeof message.from === 'string' ? message.from.trim() : '', + trimmedTo: typeof message.to === 'string' ? message.to.trim() : '', + normalizedFrom: normalizeParticipant(message.from), + normalizedTo: normalizeParticipant(message.to), + normalizedText: normalizeMessageText(message.text), + isTaskCommentNotification: message.messageKind === 'task_comment_notification', + isTaskStallRemediation: isTaskStallRemediationMessage(message), + isMemberWorkSyncNudge: isMemberWorkSyncNudgeMessage(message), + isReviewPickupEscalation: isReviewPickupEscalationMessage(message), + isInternalControlEnvelope: isTeamInternalControlMessageEnvelope(message), + isNoiseMessage, + keepIdleWhenNoiseHidden: isNoiseMessage && shouldKeepIdleMessageInActivityWhenNoiseHidden(text), + }; + messageFilterDataCache.set(message, next); + return next; +} + function isLeadAlias(value: string | undefined): boolean { const normalized = normalizeParticipant(value).replace(/[\s_]+/g, '-'); return ( @@ -65,25 +130,27 @@ function isRelayDuplicateOfVisibleMessage( return false; } - if (isInboxNoiseMessage(message.text)) { + const messageData = getMessageFilterData(message); + if (messageData.isNoiseMessage) { return true; } + const originalData = getMessageFilterData(original); const isInternalLeadRelayDelivery = (message.source === 'runtime_delivery' || message.source === 'lead_process') && original.source === 'user_sent' && - normalizeParticipant(original.from) === 'user' && + originalData.normalizedFrom === 'user' && isLeadParticipant(original.to, leadNames) && isLeadParticipant(message.from, leadNames) && - normalizeParticipant(message.to) !== 'user'; + messageData.normalizedTo !== 'user'; if (isInternalLeadRelayDelivery) { return true; } const sameDirection = - normalizeParticipant(message.from) === normalizeParticipant(original.from) && - normalizeParticipant(message.to) === normalizeParticipant(original.to); + messageData.normalizedFrom === originalData.normalizedFrom && + messageData.normalizedTo === originalData.normalizedTo; if (!sameDirection) { return false; @@ -93,7 +160,7 @@ function isRelayDuplicateOfVisibleMessage( return true; } - return normalizeMessageText(message.text) === normalizeMessageText(original.text); + return messageData.normalizedText === originalData.normalizedText; } function getRuntimeDeliveryRelayDuplicateKey( @@ -103,9 +170,10 @@ function getRuntimeDeliveryRelayDuplicateKey( if (message.source !== 'runtime_delivery') { return null; } - const from = normalizeParticipant(message.from); - const to = normalizeParticipant(message.to); - const text = normalizeMessageText(message.text); + const data = getMessageFilterData(message); + const from = data.normalizedFrom; + const to = data.normalizedTo; + const text = data.normalizedText; if (!from || !to || !text) { return null; } @@ -135,14 +203,16 @@ export function filterTeamMessages( } = options; const leadNames = normalizeLeadNames(rawLeadNames); - let list = messages.filter( - (m) => - m.messageKind !== 'task_comment_notification' && - (includeAutomationEvents || !isTaskStallRemediationMessage(m)) && - (includeMemberWorkSyncNudges || !isMemberWorkSyncNudgeMessage(m)) && - !isReviewPickupEscalationMessage(m) && - !isTeamInternalControlMessageEnvelope(m) - ); + let list = messages.filter((m) => { + const data = getMessageFilterData(m); + return ( + !data.isTaskCommentNotification && + (includeAutomationEvents || !data.isTaskStallRemediation) && + (includeMemberWorkSyncNudges || !data.isMemberWorkSyncNudge) && + !data.isReviewPickupEscalation && + !data.isInternalControlEnvelope + ); + }); if (timeWindow) { list = list.filter((m) => { const ts = new Date(m.timestamp).getTime(); @@ -151,12 +221,9 @@ export function filterTeamMessages( } if (!filter.showNoise) { list = list.filter((m) => { - const text = typeof m.text === 'string' ? m.text : ''; - if (!isInboxNoiseMessage(text)) return true; - return ( - includePassiveIdlePeerSummariesWhenNoiseHidden && - shouldKeepIdleMessageInActivityWhenNoiseHidden(text) - ); + const data = getMessageFilterData(m); + if (!data.isNoiseMessage) return true; + return includePassiveIdlePeerSummariesWhenNoiseHidden && data.keepIdleWhenNoiseHidden; }); } @@ -164,14 +231,16 @@ export function filterTeamMessages( const hasTo = filter.to.size > 0; if (hasFrom && hasTo) { list = list.filter((m) => { - const fromMatch = Boolean(m.from?.trim() && filter.from.has(m.from.trim())); - const toMatch = Boolean(m.to?.trim() && filter.to.has(m.to.trim())); + const data = getMessageFilterData(m); + const fromMatch = Boolean(data.trimmedFrom && filter.from.has(data.trimmedFrom)); + const toMatch = Boolean(data.trimmedTo && filter.to.has(data.trimmedTo)); return fromMatch && toMatch; }); } else if (hasFrom || hasTo) { list = list.filter((m) => { - if (hasFrom) return Boolean(m.from?.trim() && filter.from.has(m.from.trim())); - if (hasTo) return Boolean(m.to?.trim() && filter.to.has(m.to.trim())); + const data = getMessageFilterData(m); + if (hasFrom) return Boolean(data.trimmedFrom && filter.from.has(data.trimmedFrom)); + if (hasTo) return Boolean(data.trimmedTo && filter.to.has(data.trimmedTo)); return true; }); } @@ -190,7 +259,7 @@ export function filterTeamMessages( const visibleMessagesById = new Map( list .map((m) => { - const id = typeof m.messageId === 'string' ? m.messageId.trim() : ''; + const id = getMessageFilterData(m).trimmedMessageId; return id ? ([id, m] as const) : null; }) .filter((entry): entry is readonly [string, InboxMessage] => entry !== null) @@ -204,8 +273,8 @@ export function filterTeamMessages( if (!relayOfMessageId) { return true; } - const ownMessageId = typeof m.messageId === 'string' ? m.messageId.trim() : ''; - if (relayOfMessageId === ownMessageId) { + const data = getMessageFilterData(m); + if (relayOfMessageId === data.trimmedMessageId) { return true; } const runtimeDuplicateKey = getRuntimeDeliveryRelayDuplicateKey(m, relayOfMessageId); diff --git a/test/renderer/utils/teamMessageFiltering.test.ts b/test/renderer/utils/teamMessageFiltering.test.ts index a3aad132..26314aa7 100644 --- a/test/renderer/utils/teamMessageFiltering.test.ts +++ b/test/renderer/utils/teamMessageFiltering.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it } from 'vitest'; - import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; +import { describe, expect, it } from 'vitest'; import type { InboxMessage } from '@shared/types'; @@ -500,6 +499,26 @@ Messages: expect(result[0].messageId).toBe('msg-2'); }); + it('recomputes cached message classification when mutable message fields change', () => { + const message = makeMessage({ + messageId: 'mutable-message', + text: '{"type":"idle_notification","idleReason":"available"}', + }); + const options = { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: false }, + searchQuery: '', + }; + + expect(filterTeamMessages([message], options)).toEqual([]); + + message.text = 'Real visible message'; + + expect(filterTeamMessages([message], options).map((item) => item.messageId)).toEqual([ + 'mutable-message', + ]); + }); + it('can preserve passive peer-summary idle rows in the activity sink while keeping pure heartbeat hidden even after read', () => { const messages = [ makeMessage({