Sort muted rooms to the bottom of their section of the room list (#10592)

* muted-to-the-bottom POC

* split muted rooms in natural algorithm

* add previous event to account data dispatch

* add muted to notification state

* sort muted rooms to the bottom

* only split muted rooms when sorting is RECENT

* remove debugs

* use RoomNotifState better

* add default notifications test util

* test getChangedOverrideRoomPushRules

* remove file

* test roomudpate in roomliststore

* unit test ImportanceAlgorithm

* strict fixes

* test recent x importance with muted rooms

* unit test NaturalAlgorithm

* test naturalalgorithm with muted rooms

* strict fixes

* comments

* add push rules test utility

* strict fixes

* more strict

* tidy comment

* document previousevent on account data dispatch event

* simplify (?) room mute rule utilities, comments

* remove debug
This commit is contained in:
Kerry 2023-05-05 13:53:26 +12:00 committed by GitHub
parent 3ca957b541
commit 44e0732144
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 765 additions and 27 deletions

View file

@ -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 {

View file

@ -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,
};
}

View file

@ -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

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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<IState> implements
this.onDispatchMyMembership(<any>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();
}
}
/**

View file

@ -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}`);

View file

@ -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);
} else if (cause === RoomUpdateCause.RoomRemoved) {
const idx = this.getRoomIndex(room);
if (idx >= 0) {
this.cachedOrderedRooms.splice(idx, 1);
if (isMuted) {
this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm(
[...this.cachedCategorizedOrderedRooms.mutedRooms, room],
this.tagId,
this.sortingAlgorithm,
);
} else {
logger.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm(
[...this.cachedCategorizedOrderedRooms.defaultRooms, room],
this.tagId,
this.sortingAlgorithm,
);
}
this.buildCachedOrderedRooms();
return true;
} else if (cause === RoomUpdateCause.RoomRemoved) {
return this.removeRoom(room);
} else if (cause === RoomUpdateCause.PossibleMuteChange) {
if (this.isMutedToBottom) {
return this.onPossibleMuteChange(room);
} else {
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<NaturalCategorizedRoomMap>(
(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;
}
}

View file

@ -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.

View file

@ -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",

View file

@ -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];
};

View file

@ -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 {
ConditionKind,
EventType,
IPushRule,
MatrixEvent,
PendingEventOrdering,
PushRuleActionName,
Room,
} from "matrix-js-sdk/src/matrix";
import { MatrixDispatcher } from "../../../src/dispatcher/dispatcher";
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);
});
});
});
});

View file

@ -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<string, ReturnType<(typeof RoomNotifs)["determineUnreadState"]>> = {
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<Room, RoomNotificationState>();
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);
});
});
});
});

View file

@ -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<Room, RoomNotificationState>();
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);
});
});
});
});

View file

@ -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]);
});
});