perf(notifications): memoize getNotificationType per push-rules object and room
Fixes #77 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
getOrphanParents,
|
getOrphanParents,
|
||||||
isMutedRule,
|
isMutedRule,
|
||||||
findMutedRule,
|
findMutedRule,
|
||||||
|
getNotificationType,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
isVerificationFlowEvent,
|
isVerificationFlowEvent,
|
||||||
unreadIsOnlyVerification,
|
unreadIsOnlyVerification,
|
||||||
@@ -46,7 +47,7 @@ import {
|
|||||||
getMentionContent,
|
getMentionContent,
|
||||||
getAllVersionsRoomCreator,
|
getAllVersionsRoomCreator,
|
||||||
} from './room';
|
} from './room';
|
||||||
import { RoomType, StateEvent, RoomToParents } from '../../types/matrix/room';
|
import { RoomType, StateEvent, RoomToParents, NotificationType } from '../../types/matrix/room';
|
||||||
|
|
||||||
// --- Mock helpers ---------------------------------------------------------
|
// --- Mock helpers ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -341,6 +342,43 @@ test('findMutedRule finds rule by id that is also muted', () => {
|
|||||||
assert.equal(findMutedRule(rules, '!missing'), undefined);
|
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 --------------------------------------------------
|
// --- isNotificationEvent --------------------------------------------------
|
||||||
|
|
||||||
test('isNotificationEvent accepts message/sticker but rejects member, redacted, edits', () => {
|
test('isNotificationEvent accepts message/sticker but rejects member, redacted, edits', () => {
|
||||||
|
|||||||
+37
-3
@@ -188,7 +188,11 @@ export const isMutedRule = (rule: IPushRule) =>
|
|||||||
export const findMutedRule = (overrideRules: IPushRule[], roomId: string) =>
|
export const findMutedRule = (overrideRules: IPushRule[], roomId: string) =>
|
||||||
overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule));
|
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;
|
let roomPushRule: IPushRule | undefined;
|
||||||
try {
|
try {
|
||||||
roomPushRule = mx.getRoomPushRule('global', roomId);
|
roomPushRule = mx.getRoomPushRule('global', roomId);
|
||||||
@@ -197,8 +201,7 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!roomPushRule) {
|
if (!roomPushRule) {
|
||||||
const overrideRules = mx.getAccountData(EventType.PushRules)?.getContent<IPushRules>()
|
const overrideRules = pushRulesContent?.global?.override;
|
||||||
?.global?.override;
|
|
||||||
if (!overrideRules) return NotificationType.Default;
|
if (!overrideRules) return NotificationType.Default;
|
||||||
|
|
||||||
return findMutedRule(overrideRules, roomId) ? NotificationType.Mute : 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;
|
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<object, Map<string, NotificationType>>();
|
||||||
|
// 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<IPushRules>();
|
||||||
|
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 = [
|
const NOTIFICATION_EVENT_TYPES = [
|
||||||
'm.room.create',
|
'm.room.create',
|
||||||
'm.room.message',
|
'm.room.message',
|
||||||
|
|||||||
Reference in New Issue
Block a user