diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index aa0d89df796..f386f50ada8 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -182,19 +182,44 @@ function findOverrideMuteRule(roomId: string): IPushRule | null { return null; } for (const rule of cli.pushRules.global.override) { - if (rule.enabled && isRuleForRoom(roomId, rule) && isMuteRule(rule)) { + if (rule.enabled && isRuleRoomMuteRuleForRoomId(roomId, rule)) { return rule; } } return null; } -function isRuleForRoom(roomId: string, rule: IPushRule): boolean { - if (rule.conditions?.length !== 1) { +/** + * Checks if a given rule is a room mute rule as implemented by EW + * - matches every event in one room (one condition that is an event match on roomId) + * - silences notifications (one action that is `DontNotify`) + * @param rule - push rule + * @returns {boolean} - true when rule mutes a room + */ +export function isRuleMaybeRoomMuteRule(rule: IPushRule): boolean { + return ( + // matches every event in one room + rule.conditions?.length === 1 && + rule.conditions[0].kind === ConditionKind.EventMatch && + rule.conditions[0].key === "room_id" && + // silences notifications + isMuteRule(rule) + ); +} + +/** + * Checks if a given rule is a room mute rule as implemented by EW + * @param roomId - id of room to match + * @param rule - push rule + * @returns {boolean} true when rule mutes the given room + */ +function isRuleRoomMuteRuleForRoomId(roomId: string, rule: IPushRule): boolean { + if (!isRuleMaybeRoomMuteRule(rule)) { return false; } - const cond = rule.conditions[0]; - return cond.kind === ConditionKind.EventMatch && cond.key === "room_id" && cond.pattern === roomId; + // isRuleMaybeRoomMuteRule checks this condition exists + const cond = rule.conditions![0]!; + return cond.pattern === roomId; } function isMuteRule(rule: IPushRule): boolean { diff --git a/src/actions/MatrixActionCreators.ts b/src/actions/MatrixActionCreators.ts index bb40a463baa..3cc1828a565 100644 --- a/src/actions/MatrixActionCreators.ts +++ b/src/actions/MatrixActionCreators.ts @@ -48,6 +48,7 @@ function createSyncAction(matrixClient: MatrixClient, state: string, prevState: * @property {MatrixEvent} event the MatrixEvent that triggered the dispatch. * @property {string} event_type the type of the MatrixEvent, e.g. "m.direct". * @property {Object} event_content the content of the MatrixEvent. + * @property {MatrixEvent} previousEvent the previous account data event of the same type, if present */ /** @@ -56,14 +57,20 @@ function createSyncAction(matrixClient: MatrixClient, state: string, prevState: * * @param {MatrixClient} matrixClient the matrix client. * @param {MatrixEvent} accountDataEvent the account data event. + * @param {MatrixEvent | undefined} previousAccountDataEvent the previous account data event of the same type, if present * @returns {AccountDataAction} an action of type MatrixActions.accountData. */ -function createAccountDataAction(matrixClient: MatrixClient, accountDataEvent: MatrixEvent): ActionPayload { +function createAccountDataAction( + matrixClient: MatrixClient, + accountDataEvent: MatrixEvent, + previousAccountDataEvent?: MatrixEvent, +): ActionPayload { return { action: "MatrixActions.accountData", event: accountDataEvent, event_type: accountDataEvent.getType(), event_content: accountDataEvent.getContent(), + previousEvent: previousAccountDataEvent, }; } diff --git a/src/stores/notifications/NotificationColor.ts b/src/stores/notifications/NotificationColor.ts index f89bb1728d1..ab7097265ed 100644 --- a/src/stores/notifications/NotificationColor.ts +++ b/src/stores/notifications/NotificationColor.ts @@ -17,6 +17,7 @@ limitations under the License. import { _t } from "../../languageHandler"; export enum NotificationColor { + Muted, // Inverted (None -> Red) because we do integer comparisons on this None, // nothing special // TODO: Remove bold with notifications: https://github.com/vector-im/element-web/issues/14227 diff --git a/src/stores/notifications/NotificationState.ts b/src/stores/notifications/NotificationState.ts index 2445ec6d361..b4db29c1354 100644 --- a/src/stores/notifications/NotificationState.ts +++ b/src/stores/notifications/NotificationState.ts @@ -24,6 +24,7 @@ export interface INotificationStateSnapshotParams { symbol: string | null; count: number; color: NotificationColor; + muted: boolean; } export enum NotificationStateEvents { @@ -42,6 +43,7 @@ export abstract class NotificationState protected _symbol: string | null = null; protected _count = 0; protected _color: NotificationColor = NotificationColor.None; + protected _muted = false; private watcherReferences: string[] = []; @@ -66,6 +68,10 @@ export abstract class NotificationState return this._color; } + public get muted(): boolean { + return this._muted; + } + public get isIdle(): boolean { return this.color <= NotificationColor.None; } @@ -110,16 +116,18 @@ export class NotificationStateSnapshot { private readonly symbol: string | null; private readonly count: number; private readonly color: NotificationColor; + private readonly muted: boolean; public constructor(state: INotificationStateSnapshotParams) { this.symbol = state.symbol; this.count = state.count; this.color = state.color; + this.muted = state.muted; } public isDifferentFrom(other: INotificationStateSnapshotParams): boolean { - const before = { count: this.count, symbol: this.symbol, color: this.color }; - const after = { count: other.count, symbol: other.symbol, color: other.color }; + const before = { count: this.count, symbol: this.symbol, color: this.color, muted: this.muted }; + const after = { count: other.count, symbol: other.symbol, color: other.color, muted: other.muted }; return JSON.stringify(before) !== JSON.stringify(after); } } diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index cde911f7021..3c0447a1434 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -93,9 +93,12 @@ export class RoomNotificationState extends NotificationState implements IDestroy const snapshot = this.snapshot(); const { color, symbol, count } = RoomNotifs.determineUnreadState(this.room); + const muted = + RoomNotifs.getRoomNotifsState(this.room.client, this.room.roomId) === RoomNotifs.RoomNotifState.Mute; this._color = color; this._symbol = symbol; this._count = count; + this._muted = muted; // finally, publish an update if needed this.emitIfUpdated(snapshot); diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index cb7ce972a86..2048aad080a 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -40,6 +40,7 @@ import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; import { SlidingRoomListStoreClass } from "./SlidingRoomListStore"; import { UPDATE_EVENT } from "../AsyncStore"; import { SdkContextClass } from "../../contexts/SDKContext"; +import { getChangedOverrideRoomMutePushRules } from "./utils/roomMute"; interface IState { // state is tracked in underlying classes @@ -289,6 +290,17 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements this.onDispatchMyMembership(payload); return; } + + const possibleMuteChangeRoomIds = getChangedOverrideRoomMutePushRules(payload); + if (possibleMuteChangeRoomIds) { + for (const roomId of possibleMuteChangeRoomIds) { + const room = roomId && this.matrixClient.getRoom(roomId); + if (room) { + await this.handleRoomUpdate(room, RoomUpdateCause.PossibleMuteChange); + } + } + this.updateFn.trigger(); + } } /** diff --git a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts index e0dfb5adca3..d29d48f7ea0 100644 --- a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts @@ -42,6 +42,7 @@ const CATEGORY_ORDER = [ NotificationColor.Grey, NotificationColor.Bold, NotificationColor.None, // idle + NotificationColor.Muted, ]; /** @@ -81,6 +82,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { [NotificationColor.Grey]: [], [NotificationColor.Bold]: [], [NotificationColor.None]: [], + [NotificationColor.Muted]: [], }; for (const room of rooms) { const category = this.getRoomCategory(room); @@ -94,7 +96,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { // It's fine for us to call this a lot because it's cached, and we shouldn't be // wasting anything by doing so as the store holds single references const state = RoomNotificationStateStore.instance.getRoomState(room); - return state.color; + return this.isMutedToBottom && state.muted ? NotificationColor.Muted : state.color; } public setRooms(rooms: Room[]): void { @@ -164,15 +166,25 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { return this.handleSplice(room, cause); } - if (cause !== RoomUpdateCause.Timeline && cause !== RoomUpdateCause.ReadReceipt) { + if ( + cause !== RoomUpdateCause.Timeline && + cause !== RoomUpdateCause.ReadReceipt && + cause !== RoomUpdateCause.PossibleMuteChange + ) { throw new Error(`Unsupported update cause: ${cause}`); } - const category = this.getRoomCategory(room); + // don't react to mute changes when we are not sorting by mute + if (cause === RoomUpdateCause.PossibleMuteChange && !this.isMutedToBottom) { + return false; + } + if (this.sortingAlgorithm === SortAlgorithm.Manual) { return false; // Nothing to do here. } + const category = this.getRoomCategory(room); + const roomIdx = this.getRoomIndex(room); if (roomIdx === -1) { throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`); diff --git a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts index 61a3d29dd2b..36019599503 100644 --- a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts @@ -21,42 +21,191 @@ import { SortAlgorithm } from "../models"; import { sortRoomsWithAlgorithm } from "../tag-sorting"; import { OrderingAlgorithm } from "./OrderingAlgorithm"; import { RoomUpdateCause, TagID } from "../../models"; +import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore"; + +type NaturalCategorizedRoomMap = { + defaultRooms: Room[]; + mutedRooms: Room[]; +}; /** * Uses the natural tag sorting algorithm order to determine tag ordering. No * additional behavioural changes are present. */ export class NaturalAlgorithm extends OrderingAlgorithm { + private cachedCategorizedOrderedRooms: NaturalCategorizedRoomMap = { + defaultRooms: [], + mutedRooms: [], + }; public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) { super(tagId, initialSortingAlgorithm); } public setRooms(rooms: Room[]): void { - this.cachedOrderedRooms = sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm); + const { defaultRooms, mutedRooms } = this.categorizeRooms(rooms); + + this.cachedCategorizedOrderedRooms = { + defaultRooms: sortRoomsWithAlgorithm(defaultRooms, this.tagId, this.sortingAlgorithm), + mutedRooms: sortRoomsWithAlgorithm(mutedRooms, this.tagId, this.sortingAlgorithm), + }; + this.buildCachedOrderedRooms(); } public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean { const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved; - const isInPlace = cause === RoomUpdateCause.Timeline || cause === RoomUpdateCause.ReadReceipt; + const isInPlace = + cause === RoomUpdateCause.Timeline || + cause === RoomUpdateCause.ReadReceipt || + cause === RoomUpdateCause.PossibleMuteChange; + const isMuted = this.isMutedToBottom && this.getRoomIsMuted(room); + if (!isSplice && !isInPlace) { throw new Error(`Unsupported update cause: ${cause}`); } if (cause === RoomUpdateCause.NewRoom) { - this.cachedOrderedRooms.push(room); + if (isMuted) { + this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm( + [...this.cachedCategorizedOrderedRooms.mutedRooms, room], + this.tagId, + this.sortingAlgorithm, + ); + } else { + this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm( + [...this.cachedCategorizedOrderedRooms.defaultRooms, room], + this.tagId, + this.sortingAlgorithm, + ); + } + this.buildCachedOrderedRooms(); + return true; } else if (cause === RoomUpdateCause.RoomRemoved) { - const idx = this.getRoomIndex(room); - if (idx >= 0) { - this.cachedOrderedRooms.splice(idx, 1); + return this.removeRoom(room); + } else if (cause === RoomUpdateCause.PossibleMuteChange) { + if (this.isMutedToBottom) { + return this.onPossibleMuteChange(room); } else { - logger.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`); + return false; } } // TODO: Optimize this to avoid useless operations: https://github.com/vector-im/element-web/issues/14457 // For example, we can skip updates to alphabetic (sometimes) and manually ordered tags - this.cachedOrderedRooms = sortRoomsWithAlgorithm(this.cachedOrderedRooms, this.tagId, this.sortingAlgorithm); - + if (isMuted) { + this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm( + this.cachedCategorizedOrderedRooms.mutedRooms, + this.tagId, + this.sortingAlgorithm, + ); + } else { + this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm( + this.cachedCategorizedOrderedRooms.defaultRooms, + this.tagId, + this.sortingAlgorithm, + ); + } + this.buildCachedOrderedRooms(); return true; } + + /** + * Remove a room from the cached room list + * @param room Room to remove + * @returns {boolean} true when room list should update as result + */ + private removeRoom(room: Room): boolean { + const defaultIndex = this.cachedCategorizedOrderedRooms.defaultRooms.findIndex((r) => r.roomId === room.roomId); + if (defaultIndex > -1) { + this.cachedCategorizedOrderedRooms.defaultRooms.splice(defaultIndex, 1); + this.buildCachedOrderedRooms(); + return true; + } + const mutedIndex = this.cachedCategorizedOrderedRooms.mutedRooms.findIndex((r) => r.roomId === room.roomId); + if (mutedIndex > -1) { + this.cachedCategorizedOrderedRooms.mutedRooms.splice(mutedIndex, 1); + this.buildCachedOrderedRooms(); + return true; + } + + logger.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`); + // room was not in cached lists, no update + return false; + } + + /** + * Sets cachedOrderedRooms from cachedCategorizedOrderedRooms + */ + private buildCachedOrderedRooms(): void { + this.cachedOrderedRooms = [ + ...this.cachedCategorizedOrderedRooms.defaultRooms, + ...this.cachedCategorizedOrderedRooms.mutedRooms, + ]; + } + + private getRoomIsMuted(room: Room): boolean { + // It's fine for us to call this a lot because it's cached, and we shouldn't be + // wasting anything by doing so as the store holds single references + const state = RoomNotificationStateStore.instance.getRoomState(room); + return state.muted; + } + + private categorizeRooms(rooms: Room[]): NaturalCategorizedRoomMap { + if (!this.isMutedToBottom) { + return { defaultRooms: rooms, mutedRooms: [] }; + } + return rooms.reduce( + (acc, room: Room) => { + if (this.getRoomIsMuted(room)) { + acc.mutedRooms.push(room); + } else { + acc.defaultRooms.push(room); + } + return acc; + }, + { defaultRooms: [], mutedRooms: [] } as NaturalCategorizedRoomMap, + ); + } + + private onPossibleMuteChange(room: Room): boolean { + const isMuted = this.getRoomIsMuted(room); + if (isMuted) { + const defaultIndex = this.cachedCategorizedOrderedRooms.defaultRooms.findIndex( + (r) => r.roomId === room.roomId, + ); + + // room has been muted + if (defaultIndex > -1) { + // remove from the default list + this.cachedCategorizedOrderedRooms.defaultRooms.splice(defaultIndex, 1); + // add to muted list and reorder + this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm( + [...this.cachedCategorizedOrderedRooms.mutedRooms, room], + this.tagId, + this.sortingAlgorithm, + ); + // rebuild + this.buildCachedOrderedRooms(); + return true; + } + } else { + const mutedIndex = this.cachedCategorizedOrderedRooms.mutedRooms.findIndex((r) => r.roomId === room.roomId); + + // room has been unmuted + if (mutedIndex > -1) { + // remove from the muted list + this.cachedCategorizedOrderedRooms.mutedRooms.splice(mutedIndex, 1); + // add to default list and reorder + this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm( + [...this.cachedCategorizedOrderedRooms.defaultRooms, room], + this.tagId, + this.sortingAlgorithm, + ); + // rebuild + this.buildCachedOrderedRooms(); + return true; + } + } + + return false; + } } diff --git a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts index 6cf8b4606fd..2bcb5b52899 100644 --- a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts @@ -42,6 +42,10 @@ export abstract class OrderingAlgorithm { return this.cachedOrderedRooms; } + public get isMutedToBottom(): boolean { + return this.sortingAlgorithm === SortAlgorithm.Recent; + } + /** * Sets the sorting algorithm to use within the list. * @param newAlgorithm The new algorithm. Must be defined. diff --git a/src/stores/room-list/models.ts b/src/stores/room-list/models.ts index 0345ff60534..d9f17cc2713 100644 --- a/src/stores/room-list/models.ts +++ b/src/stores/room-list/models.ts @@ -43,6 +43,7 @@ export type TagID = string | DefaultTagID; export enum RoomUpdateCause { Timeline = "TIMELINE", PossibleTagChange = "POSSIBLE_TAG_CHANGE", + PossibleMuteChange = "POSSIBLE_MUTE_CHANGE", ReadReceipt = "READ_RECEIPT", NewRoom = "NEW_ROOM", RoomRemoved = "ROOM_REMOVED", diff --git a/src/stores/room-list/utils/roomMute.ts b/src/stores/room-list/utils/roomMute.ts new file mode 100644 index 00000000000..d5b1c3520ff --- /dev/null +++ b/src/stores/room-list/utils/roomMute.ts @@ -0,0 +1,54 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent, EventType, IPushRules } from "matrix-js-sdk/src/matrix"; + +import { ActionPayload } from "../../../dispatcher/payloads"; +import { isRuleMaybeRoomMuteRule } from "../../../RoomNotifs"; +import { arrayDiff } from "../../../utils/arrays"; + +/** + * Gets any changed push rules that are room specific overrides + * that mute notifications + * @param actionPayload + * @returns {string[]} ruleIds of added or removed rules + */ +export const getChangedOverrideRoomMutePushRules = (actionPayload: ActionPayload): string[] | undefined => { + if ( + actionPayload.action !== "MatrixActions.accountData" || + actionPayload.event?.getType() !== EventType.PushRules + ) { + return undefined; + } + const event = actionPayload.event as MatrixEvent; + const prevEvent = actionPayload.previousEvent as MatrixEvent | undefined; + + if (!event || !prevEvent) { + return undefined; + } + + const roomPushRules = (event.getContent() as IPushRules)?.global?.override?.filter(isRuleMaybeRoomMuteRule); + const prevRoomPushRules = (prevEvent?.getContent() as IPushRules)?.global?.override?.filter( + isRuleMaybeRoomMuteRule, + ); + + const { added, removed } = arrayDiff( + prevRoomPushRules?.map((rule) => rule.rule_id) || [], + roomPushRules?.map((rule) => rule.rule_id) || [], + ); + + return [...added, ...removed]; +}; diff --git a/test/stores/room-list/RoomListStore-test.ts b/test/stores/room-list/RoomListStore-test.ts index 5c6435780e2..aaec6942132 100644 --- a/test/stores/room-list/RoomListStore-test.ts +++ b/test/stores/room-list/RoomListStore-test.ts @@ -14,16 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventType, MatrixEvent, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix"; - -import { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; +import { + ConditionKind, + EventType, + IPushRule, + MatrixEvent, + PendingEventOrdering, + PushRuleActionName, + Room, +} from "matrix-js-sdk/src/matrix"; + +import defaultDispatcher, { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import SettingsStore, { CallbackFn } from "../../../src/settings/SettingsStore"; import { ListAlgorithm, SortAlgorithm } from "../../../src/stores/room-list/algorithms/models"; import { OrderedDefaultTagIDs, RoomUpdateCause } from "../../../src/stores/room-list/models"; import RoomListStore, { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore"; import DMRoomMap from "../../../src/utils/DMRoomMap"; -import { stubClient, upsertRoomStateEvents } from "../../test-utils"; +import { flushPromises, stubClient, upsertRoomStateEvents } from "../../test-utils"; +import { DEFAULT_PUSH_RULES, makePushRule } from "../../test-utils/pushRules"; describe("RoomListStore", () => { const client = stubClient(); @@ -69,12 +78,15 @@ describe("RoomListStore", () => { }); upsertRoomStateEvents(roomNoPredecessor, [createNoPredecessor]); const oldRoom = new Room(oldRoomId, client, userId, {}); + const normalRoom = new Room("!normal:server.org", client, userId); client.getRoom = jest.fn().mockImplementation((roomId) => { switch (roomId) { case newRoomId: return roomWithCreatePredecessor; case oldRoomId: return oldRoom; + case normalRoom.roomId: + return normalRoom; default: return null; } @@ -274,4 +286,70 @@ describe("RoomListStore", () => { expect(client.getVisibleRooms).toHaveBeenCalledTimes(1); }); }); + + describe("room updates", () => { + const makeStore = async () => { + const store = new RoomListStoreClass(defaultDispatcher); + await store.start(); + return store; + }; + + describe("push rules updates", () => { + const makePushRulesEvent = (overrideRules: IPushRule[] = []): MatrixEvent => { + return new MatrixEvent({ + type: EventType.PushRules, + content: { + global: { + ...DEFAULT_PUSH_RULES.global, + override: overrideRules, + }, + }, + }); + }; + + it("triggers a room update when room mutes have changed", async () => { + const rule = makePushRule(normalRoom.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }], + }); + const event = makePushRulesEvent([rule]); + const previousEvent = makePushRulesEvent(); + + const store = await makeStore(); + // @ts-ignore private property alg + const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined); + // @ts-ignore cheat and call protected fn + store.onAction({ action: "MatrixActions.accountData", event, previousEvent }); + // flush setImmediate + await flushPromises(); + + expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange); + }); + + it("handles when a muted room is unknown by the room list", async () => { + const rule = makePushRule(normalRoom.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }], + }); + const unknownRoomRule = makePushRule("!unknown:server.org", { + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: "!unknown:server.org" }], + }); + const event = makePushRulesEvent([unknownRoomRule, rule]); + const previousEvent = makePushRulesEvent(); + + const store = await makeStore(); + // @ts-ignore private property alg + const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined); + + // @ts-ignore cheat and call protected fn + store.onAction({ action: "MatrixActions.accountData", event, previousEvent }); + // flush setImmediate + await flushPromises(); + + // only one call to update made for normalRoom + expect(algorithmSpy).toHaveBeenCalledTimes(1); + expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange); + }); + }); + }); }); diff --git a/test/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts b/test/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts index 6db3c369fb0..7ffc34d4eb9 100644 --- a/test/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts +++ b/test/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent, Room, RoomEvent } from "matrix-js-sdk/src/matrix"; +import { ConditionKind, MatrixEvent, PushRuleActionName, Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore"; @@ -25,6 +25,8 @@ import { DefaultTagID, RoomUpdateCause } from "../../../../../src/stores/room-li import { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor"; import { AlphabeticAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm"; import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../test-utils"; +import { RecentAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import { DEFAULT_PUSH_RULES, makePushRule } from "../../../../test-utils/pushRules"; describe("ImportanceAlgorithm", () => { const userId = "@alice:server.org"; @@ -57,6 +59,21 @@ describe("ImportanceAlgorithm", () => { const roomE = makeRoom("!eee:server.org", "Echo", 3); const roomX = makeRoom("!xxx:server.org", "Xylophone", 99); + const muteRoomARule = makePushRule(roomA.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomA.roomId }], + }); + const muteRoomBRule = makePushRule(roomB.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomB.roomId }], + }); + client.pushRules = { + global: { + ...DEFAULT_PUSH_RULES.global, + override: [...DEFAULT_PUSH_RULES.global.override!, muteRoomARule, muteRoomBRule], + }, + }; + const unreadStates: Record> = { red: { symbol: null, count: 1, color: NotificationColor.Red }, grey: { symbol: null, count: 1, color: NotificationColor.Grey }, @@ -240,6 +257,18 @@ describe("ImportanceAlgorithm", () => { ).toThrow("Unsupported update cause: something unexpected"); }); + it("ignores a mute change", () => { + // muted rooms are not pushed to the bottom when sort is alpha + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange); + + expect(shouldTriggerUpdate).toBe(false); + // no sorting + expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); + }); + describe("time and read receipt updates", () => { it("throws for when a room is not indexed", () => { const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); @@ -295,4 +324,110 @@ describe("ImportanceAlgorithm", () => { }); }); }); + + describe("When sortAlgorithm is recent", () => { + const sortAlgorithm = SortAlgorithm.Recent; + + // mock recent algorithm sorting + const fakeRecentOrder = [roomC, roomB, roomE, roomD, roomA]; + + beforeEach(async () => { + // destroy roomMap so we can start fresh + // @ts-ignore private property + RoomNotificationStateStore.instance.roomMap = new Map(); + + jest.spyOn(RecentAlgorithm.prototype, "sortRooms") + .mockClear() + .mockImplementation((rooms: Room[]) => + fakeRecentOrder.filter((sortedRoom) => rooms.includes(sortedRoom)), + ); + jest.spyOn(RoomNotifs, "determineUnreadState") + .mockClear() + .mockImplementation((room) => { + switch (room) { + // b, c and e have red notifs + case roomB: + case roomE: + case roomC: + return unreadStates.red; + default: + return unreadStates.none; + } + }); + }); + + it("orders rooms by recent when they have the same notif state", () => { + jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({ + symbol: null, + count: 0, + color: NotificationColor.None, + }); + const algorithm = setupAlgorithm(sortAlgorithm); + + // sorted according to recent + expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomA]); + }); + + it("orders rooms by notification state then recent", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + + expect(algorithm.orderedRooms).toEqual([ + // recent within red + roomC, + roomE, + // recent within none + roomD, + // muted + roomB, + roomA, + ]); + }); + + describe("handleRoomUpdate", () => { + it("removes a room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomC, roomB]); + // no re-sorting on a remove + expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); + }); + + it("warns and returns without change when removing a room that is not indexed", () => { + jest.spyOn(logger, "warn").mockReturnValue(undefined); + const algorithm = setupAlgorithm(sortAlgorithm); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`); + }); + + it("adds a new room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom); + + expect(shouldTriggerUpdate).toBe(true); + // inserted according to notif state and mute + expect(algorithm.orderedRooms).toEqual([roomC, roomE, roomB, roomA]); + // only sorted within category + expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomE, roomC], tagId); + }); + + it("re-sorts on a mute change", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange); + + expect(shouldTriggerUpdate).toBe(true); + expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomC, roomE], tagId); + }); + }); + }); }); diff --git a/test/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts b/test/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts index 21879586b38..46bef644cf4 100644 --- a/test/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts +++ b/test/stores/room-list/algorithms/list-ordering/NaturalAlgorithm-test.ts @@ -14,14 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Room } from "matrix-js-sdk/src/matrix"; +import { ConditionKind, EventType, MatrixEvent, PushRuleActionName, Room } from "matrix-js-sdk/src/matrix"; +import { ClientEvent } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; import { NaturalAlgorithm } from "../../../../../src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm"; import { SortAlgorithm } from "../../../../../src/stores/room-list/algorithms/models"; import { DefaultTagID, RoomUpdateCause } from "../../../../../src/stores/room-list/models"; import { AlphabeticAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm"; +import { RecentAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore"; +import * as RoomNotifs from "../../../../../src/RoomNotifs"; import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../test-utils"; +import { DEFAULT_PUSH_RULES, makePushRule } from "../../../../test-utils/pushRules"; +import { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor"; describe("NaturalAlgorithm", () => { const userId = "@alice:server.org"; @@ -43,6 +49,21 @@ describe("NaturalAlgorithm", () => { const roomE = makeRoom("!eee:server.org", "Echo"); const roomX = makeRoom("!xxx:server.org", "Xylophone"); + const muteRoomARule = makePushRule(roomA.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomA.roomId }], + }); + const muteRoomDRule = makePushRule(roomD.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomD.roomId }], + }); + client.pushRules = { + global: { + ...DEFAULT_PUSH_RULES.global, + override: [...DEFAULT_PUSH_RULES.global!.override!, muteRoomARule, muteRoomDRule], + }, + }; + const setupAlgorithm = (sortAlgorithm: SortAlgorithm, rooms?: Room[]) => { const algorithm = new NaturalAlgorithm(tagId, sortAlgorithm); algorithm.setRooms(rooms || [roomA, roomB, roomC]); @@ -80,7 +101,7 @@ describe("NaturalAlgorithm", () => { const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved); - expect(shouldTriggerUpdate).toBe(true); + expect(shouldTriggerUpdate).toBe(false); expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`); }); @@ -99,6 +120,29 @@ describe("NaturalAlgorithm", () => { ); }); + it("adds a new muted room", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomA, roomB, roomE]); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.NewRoom); + + expect(shouldTriggerUpdate).toBe(true); + // muted room mixed in main category + expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomD, roomE]); + // only sorted within category + expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1); + }); + + it("ignores a mute change update", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.PossibleMuteChange); + + expect(shouldTriggerUpdate).toBe(false); + expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); + }); + it("throws for an unhandled update cause", () => { const algorithm = setupAlgorithm(sortAlgorithm); @@ -133,4 +177,113 @@ describe("NaturalAlgorithm", () => { }); }); }); + + describe("When sortAlgorithm is recent", () => { + const sortAlgorithm = SortAlgorithm.Recent; + + // mock recent algorithm sorting + const fakeRecentOrder = [roomC, roomA, roomB, roomD, roomE]; + + beforeEach(async () => { + // destroy roomMap so we can start fresh + // @ts-ignore private property + RoomNotificationStateStore.instance.roomMap = new Map(); + + jest.spyOn(RecentAlgorithm.prototype, "sortRooms") + .mockClear() + .mockImplementation((rooms: Room[]) => + fakeRecentOrder.filter((sortedRoom) => rooms.includes(sortedRoom)), + ); + + jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({ + symbol: null, + count: 0, + color: NotificationColor.None, + }); + }); + + it("orders rooms by recent with muted rooms to the bottom", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + + // sorted according to recent + expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomA]); + }); + + describe("handleRoomUpdate", () => { + it("removes a room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([roomC, roomB]); + // no re-sorting on a remove + expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); + }); + + it("warns and returns without change when removing a room that is not indexed", () => { + jest.spyOn(logger, "warn").mockReturnValue(undefined); + const algorithm = setupAlgorithm(sortAlgorithm); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved); + + expect(shouldTriggerUpdate).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`); + }); + + it("adds a new room", () => { + const algorithm = setupAlgorithm(sortAlgorithm); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom); + + expect(shouldTriggerUpdate).toBe(true); + // inserted according to mute then recentness + expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomE, roomA]); + // only sorted within category, muted roomA is not resorted + expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomC, roomB, roomE], tagId); + }); + + it("does not re-sort on possible mute change when room did not change effective mutedness", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange); + + expect(shouldTriggerUpdate).toBe(false); + expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled(); + }); + + it("re-sorts on a mute change", () => { + const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]); + jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear(); + + // mute roomE + const muteRoomERule = makePushRule(roomE.roomId, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomE.roomId }], + }); + const pushRulesEvent = new MatrixEvent({ type: EventType.PushRules }); + client.pushRules!.global!.override!.push(muteRoomERule); + client.emit(ClientEvent.AccountData, pushRulesEvent); + + const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange); + + expect(shouldTriggerUpdate).toBe(true); + expect(algorithm.orderedRooms).toEqual([ + // unmuted, sorted by recent + roomC, + roomB, + // muted, sorted by recent + roomA, + roomD, + roomE, + ]); + // only sorted muted category + expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1); + expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomD, roomE], tagId); + }); + }); + }); }); diff --git a/test/stores/room-list/utils/roomMute-test.ts b/test/stores/room-list/utils/roomMute-test.ts new file mode 100644 index 00000000000..b7f8442fb9d --- /dev/null +++ b/test/stores/room-list/utils/roomMute-test.ts @@ -0,0 +1,96 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ConditionKind, EventType, IPushRule, MatrixEvent, PushRuleActionName } from "matrix-js-sdk/src/matrix"; + +import { getChangedOverrideRoomMutePushRules } from "../../../../src/stores/room-list/utils/roomMute"; +import { DEFAULT_PUSH_RULES, getDefaultRuleWithKind, makePushRule } from "../../../test-utils/pushRules"; + +describe("getChangedOverrideRoomMutePushRules()", () => { + const makePushRulesEvent = (overrideRules: IPushRule[] = []): MatrixEvent => { + return new MatrixEvent({ + type: EventType.PushRules, + content: { + global: { + ...DEFAULT_PUSH_RULES.global, + override: overrideRules, + }, + }, + }); + }; + + it("returns undefined when dispatched action is not accountData", () => { + const action = { action: "MatrixActions.Event.decrypted", event: new MatrixEvent({}) }; + expect(getChangedOverrideRoomMutePushRules(action)).toBeUndefined(); + }); + + it("returns undefined when dispatched action is not pushrules", () => { + const action = { action: "MatrixActions.accountData", event: new MatrixEvent({ type: "not-push-rules" }) }; + expect(getChangedOverrideRoomMutePushRules(action)).toBeUndefined(); + }); + + it("returns undefined when actions event is falsy", () => { + const action = { action: "MatrixActions.accountData" }; + expect(getChangedOverrideRoomMutePushRules(action)).toBeUndefined(); + }); + + it("returns undefined when actions previousEvent is falsy", () => { + const pushRulesEvent = makePushRulesEvent(); + const action = { action: "MatrixActions.accountData", event: pushRulesEvent }; + expect(getChangedOverrideRoomMutePushRules(action)).toBeUndefined(); + }); + + it("filters out non-room specific rules", () => { + // an override rule that exists in default rules + const { rule } = getDefaultRuleWithKind(".m.rule.contains_display_name"); + const updatedRule = { + ...rule, + actions: [PushRuleActionName.DontNotify], + enabled: false, + }; + const previousEvent = makePushRulesEvent([rule]); + const pushRulesEvent = makePushRulesEvent([updatedRule]); + const action = { action: "MatrixActions.accountData", event: pushRulesEvent, previousEvent: previousEvent }; + // contains_display_name changed, but is not room-specific + expect(getChangedOverrideRoomMutePushRules(action)).toEqual([]); + }); + + it("returns ruleIds for added room rules", () => { + const roomId1 = "!room1:server.org"; + const rule = makePushRule(roomId1, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomId1 }], + }); + const previousEvent = makePushRulesEvent(); + const pushRulesEvent = makePushRulesEvent([rule]); + const action = { action: "MatrixActions.accountData", event: pushRulesEvent, previousEvent: previousEvent }; + // contains_display_name changed, but is not room-specific + expect(getChangedOverrideRoomMutePushRules(action)).toEqual([rule.rule_id]); + }); + + it("returns ruleIds for removed room rules", () => { + const roomId1 = "!room1:server.org"; + const rule = makePushRule(roomId1, { + actions: [PushRuleActionName.DontNotify], + conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomId1 }], + }); + const previousEvent = makePushRulesEvent([rule]); + const pushRulesEvent = makePushRulesEvent(); + const action = { action: "MatrixActions.accountData", event: pushRulesEvent, previousEvent: previousEvent }; + // contains_display_name changed, but is not room-specific + expect(getChangedOverrideRoomMutePushRules(action)).toEqual([rule.rule_id]); + }); +});