diff --git a/src/app/utils/room.test.ts b/src/app/utils/room.test.ts index 4fb8ea19a..a8973c880 100644 --- a/src/app/utils/room.test.ts +++ b/src/app/utils/room.test.ts @@ -24,6 +24,7 @@ import { getOrphanParents, isMutedRule, findMutedRule, + getNotificationType, isNotificationEvent, isVerificationFlowEvent, unreadIsOnlyVerification, @@ -46,7 +47,7 @@ import { getMentionContent, getAllVersionsRoomCreator, } from './room'; -import { RoomType, StateEvent, RoomToParents } from '../../types/matrix/room'; +import { RoomType, StateEvent, RoomToParents, NotificationType } from '../../types/matrix/room'; // --- Mock helpers --------------------------------------------------------- @@ -341,6 +342,43 @@ test('findMutedRule finds rule by id that is also muted', () => { assert.equal(findMutedRule(rules, '!missing'), undefined); }); +// --- getNotificationType (memoization, #77) ------------------------------- + +test('getNotificationType memoizes per push-rules content object and room id', () => { + let scanCalls = 0; + const overrideRules = [{ rule_id: '!muted', actions: [], conditions: [{ kind: 'event_match' }] }]; + const pushRulesContent = { global: { override: overrideRules } }; + const mx = { + getRoomPushRule: () => { + scanCalls += 1; + return undefined; + }, + getAccountData: () => ({ getContent: () => pushRulesContent }), + } as never; + + assert.equal(getNotificationType(mx, '!muted'), NotificationType.Mute); + assert.equal(getNotificationType(mx, '!muted'), NotificationType.Mute); + assert.equal(getNotificationType(mx, '!muted'), NotificationType.Mute); + // Repeated calls with the same (content object, roomId) pair must hit the + // cache instead of re-running getRoomPushRule + the override scan. + assert.equal(scanCalls, 1); + + // A different room id under the same push-rules content is a separate cache + // entry and still gets computed. + assert.equal(getNotificationType(mx, '!other'), NotificationType.Default); + assert.equal(scanCalls, 2); + + // A new push-rules content object (simulating an `m.push_rules` account-data + // update) invalidates the cache and forces a recompute. + const newContent = { global: { override: overrideRules } }; + const mx2 = { + getRoomPushRule: mx.getRoomPushRule, + getAccountData: () => ({ getContent: () => newContent }), + } as never; + assert.equal(getNotificationType(mx2, '!muted'), NotificationType.Mute); + assert.equal(scanCalls, 3); +}); + // --- isNotificationEvent -------------------------------------------------- test('isNotificationEvent accepts message/sticker but rejects member, redacted, edits', () => { diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 535c2e3f7..d94fe4213 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -188,7 +188,11 @@ export const isMutedRule = (rule: IPushRule) => export const findMutedRule = (overrideRules: IPushRule[], roomId: string) => overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule)); -export const getNotificationType = (mx: MatrixClient, roomId: string): NotificationType => { +const computeNotificationType = ( + mx: MatrixClient, + roomId: string, + pushRulesContent: IPushRules | undefined, +): NotificationType => { let roomPushRule: IPushRule | undefined; try { roomPushRule = mx.getRoomPushRule('global', roomId); @@ -197,8 +201,7 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat } if (!roomPushRule) { - const overrideRules = mx.getAccountData(EventType.PushRules)?.getContent() - ?.global?.override; + const overrideRules = pushRulesContent?.global?.override; if (!overrideRules) return NotificationType.Default; return findMutedRule(overrideRules, roomId) ? NotificationType.Mute : NotificationType.Default; @@ -208,6 +211,37 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat return NotificationType.MentionsAndKeywords; }; +// #77 — `getNotificationType` is called synchronously for every live timeline +// event in every room, but its result only ever changes when the `m.push_rules` +// account-data event updates. matrix-js-sdk replaces the stored account-data +// `MatrixEvent`'s content wholesale on every `m.push_rules` update (see +// `store.storeAccountDataEvents`), so keying a cache off that content object's +// identity naturally invalidates itself on `ClientEvent.AccountData` without +// needing an explicit listener here. A bursty sync backlog across many rooms +// then hits this cache instead of re-running `getRoomPushRule` + a linear scan +// of the override rules per event. +const notificationTypeCache = new WeakMap>(); +// Stable key to use when there is no push-rules account data at all yet. +const NO_PUSH_RULES_KEY: object = {}; + +export const getNotificationType = (mx: MatrixClient, roomId: string): NotificationType => { + const pushRulesContent = mx.getAccountData(EventType.PushRules)?.getContent(); + const cacheKey = pushRulesContent ?? NO_PUSH_RULES_KEY; + + let roomCache = notificationTypeCache.get(cacheKey); + if (!roomCache) { + roomCache = new Map(); + notificationTypeCache.set(cacheKey, roomCache); + } + + const cached = roomCache.get(roomId); + if (cached !== undefined) return cached; + + const result = computeNotificationType(mx, roomId, pushRulesContent); + roomCache.set(roomId, result); + return result; +}; + const NOTIFICATION_EVENT_TYPES = [ 'm.room.create', 'm.room.message',