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:
parent
3ca957b541
commit
44e0732144
15 changed files with 765 additions and 27 deletions
|
@ -182,19 +182,44 @@ function findOverrideMuteRule(roomId: string): IPushRule | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
for (const rule of cli.pushRules.global.override) {
|
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 rule;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
const cond = rule.conditions[0];
|
// isRuleMaybeRoomMuteRule checks this condition exists
|
||||||
return cond.kind === ConditionKind.EventMatch && cond.key === "room_id" && cond.pattern === roomId;
|
const cond = rule.conditions![0]!;
|
||||||
|
return cond.pattern === roomId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMuteRule(rule: IPushRule): boolean {
|
function isMuteRule(rule: IPushRule): boolean {
|
||||||
|
|
|
@ -48,6 +48,7 @@ function createSyncAction(matrixClient: MatrixClient, state: string, prevState:
|
||||||
* @property {MatrixEvent} event the MatrixEvent that triggered the dispatch.
|
* @property {MatrixEvent} event the MatrixEvent that triggered the dispatch.
|
||||||
* @property {string} event_type the type of the MatrixEvent, e.g. "m.direct".
|
* @property {string} event_type the type of the MatrixEvent, e.g. "m.direct".
|
||||||
* @property {Object} event_content the content of the MatrixEvent.
|
* @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 {MatrixClient} matrixClient the matrix client.
|
||||||
* @param {MatrixEvent} accountDataEvent the account data event.
|
* @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.
|
* @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 {
|
return {
|
||||||
action: "MatrixActions.accountData",
|
action: "MatrixActions.accountData",
|
||||||
event: accountDataEvent,
|
event: accountDataEvent,
|
||||||
event_type: accountDataEvent.getType(),
|
event_type: accountDataEvent.getType(),
|
||||||
event_content: accountDataEvent.getContent(),
|
event_content: accountDataEvent.getContent(),
|
||||||
|
previousEvent: previousAccountDataEvent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
import { _t } from "../../languageHandler";
|
import { _t } from "../../languageHandler";
|
||||||
|
|
||||||
export enum NotificationColor {
|
export enum NotificationColor {
|
||||||
|
Muted,
|
||||||
// Inverted (None -> Red) because we do integer comparisons on this
|
// Inverted (None -> Red) because we do integer comparisons on this
|
||||||
None, // nothing special
|
None, // nothing special
|
||||||
// TODO: Remove bold with notifications: https://github.com/vector-im/element-web/issues/14227
|
// TODO: Remove bold with notifications: https://github.com/vector-im/element-web/issues/14227
|
||||||
|
|
|
@ -24,6 +24,7 @@ export interface INotificationStateSnapshotParams {
|
||||||
symbol: string | null;
|
symbol: string | null;
|
||||||
count: number;
|
count: number;
|
||||||
color: NotificationColor;
|
color: NotificationColor;
|
||||||
|
muted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NotificationStateEvents {
|
export enum NotificationStateEvents {
|
||||||
|
@ -42,6 +43,7 @@ export abstract class NotificationState
|
||||||
protected _symbol: string | null = null;
|
protected _symbol: string | null = null;
|
||||||
protected _count = 0;
|
protected _count = 0;
|
||||||
protected _color: NotificationColor = NotificationColor.None;
|
protected _color: NotificationColor = NotificationColor.None;
|
||||||
|
protected _muted = false;
|
||||||
|
|
||||||
private watcherReferences: string[] = [];
|
private watcherReferences: string[] = [];
|
||||||
|
|
||||||
|
@ -66,6 +68,10 @@ export abstract class NotificationState
|
||||||
return this._color;
|
return this._color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get muted(): boolean {
|
||||||
|
return this._muted;
|
||||||
|
}
|
||||||
|
|
||||||
public get isIdle(): boolean {
|
public get isIdle(): boolean {
|
||||||
return this.color <= NotificationColor.None;
|
return this.color <= NotificationColor.None;
|
||||||
}
|
}
|
||||||
|
@ -110,16 +116,18 @@ export class NotificationStateSnapshot {
|
||||||
private readonly symbol: string | null;
|
private readonly symbol: string | null;
|
||||||
private readonly count: number;
|
private readonly count: number;
|
||||||
private readonly color: NotificationColor;
|
private readonly color: NotificationColor;
|
||||||
|
private readonly muted: boolean;
|
||||||
|
|
||||||
public constructor(state: INotificationStateSnapshotParams) {
|
public constructor(state: INotificationStateSnapshotParams) {
|
||||||
this.symbol = state.symbol;
|
this.symbol = state.symbol;
|
||||||
this.count = state.count;
|
this.count = state.count;
|
||||||
this.color = state.color;
|
this.color = state.color;
|
||||||
|
this.muted = state.muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
public isDifferentFrom(other: INotificationStateSnapshotParams): boolean {
|
public isDifferentFrom(other: INotificationStateSnapshotParams): boolean {
|
||||||
const before = { count: this.count, symbol: this.symbol, color: this.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 };
|
const after = { count: other.count, symbol: other.symbol, color: other.color, muted: other.muted };
|
||||||
return JSON.stringify(before) !== JSON.stringify(after);
|
return JSON.stringify(before) !== JSON.stringify(after);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,9 +93,12 @@ export class RoomNotificationState extends NotificationState implements IDestroy
|
||||||
const snapshot = this.snapshot();
|
const snapshot = this.snapshot();
|
||||||
|
|
||||||
const { color, symbol, count } = RoomNotifs.determineUnreadState(this.room);
|
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._color = color;
|
||||||
this._symbol = symbol;
|
this._symbol = symbol;
|
||||||
this._count = count;
|
this._count = count;
|
||||||
|
this._muted = muted;
|
||||||
|
|
||||||
// finally, publish an update if needed
|
// finally, publish an update if needed
|
||||||
this.emitIfUpdated(snapshot);
|
this.emitIfUpdated(snapshot);
|
||||||
|
|
|
@ -40,6 +40,7 @@ import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface";
|
||||||
import { SlidingRoomListStoreClass } from "./SlidingRoomListStore";
|
import { SlidingRoomListStoreClass } from "./SlidingRoomListStore";
|
||||||
import { UPDATE_EVENT } from "../AsyncStore";
|
import { UPDATE_EVENT } from "../AsyncStore";
|
||||||
import { SdkContextClass } from "../../contexts/SDKContext";
|
import { SdkContextClass } from "../../contexts/SDKContext";
|
||||||
|
import { getChangedOverrideRoomMutePushRules } from "./utils/roomMute";
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
// state is tracked in underlying classes
|
// state is tracked in underlying classes
|
||||||
|
@ -289,6 +290,17 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> implements
|
||||||
this.onDispatchMyMembership(<any>payload);
|
this.onDispatchMyMembership(<any>payload);
|
||||||
return;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -42,6 +42,7 @@ const CATEGORY_ORDER = [
|
||||||
NotificationColor.Grey,
|
NotificationColor.Grey,
|
||||||
NotificationColor.Bold,
|
NotificationColor.Bold,
|
||||||
NotificationColor.None, // idle
|
NotificationColor.None, // idle
|
||||||
|
NotificationColor.Muted,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -81,6 +82,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
|
||||||
[NotificationColor.Grey]: [],
|
[NotificationColor.Grey]: [],
|
||||||
[NotificationColor.Bold]: [],
|
[NotificationColor.Bold]: [],
|
||||||
[NotificationColor.None]: [],
|
[NotificationColor.None]: [],
|
||||||
|
[NotificationColor.Muted]: [],
|
||||||
};
|
};
|
||||||
for (const room of rooms) {
|
for (const room of rooms) {
|
||||||
const category = this.getRoomCategory(room);
|
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
|
// 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
|
// wasting anything by doing so as the store holds single references
|
||||||
const state = RoomNotificationStateStore.instance.getRoomState(room);
|
const state = RoomNotificationStateStore.instance.getRoomState(room);
|
||||||
return state.color;
|
return this.isMutedToBottom && state.muted ? NotificationColor.Muted : state.color;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setRooms(rooms: Room[]): void {
|
public setRooms(rooms: Room[]): void {
|
||||||
|
@ -164,15 +166,25 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
|
||||||
return this.handleSplice(room, cause);
|
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}`);
|
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) {
|
if (this.sortingAlgorithm === SortAlgorithm.Manual) {
|
||||||
return false; // Nothing to do here.
|
return false; // Nothing to do here.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const category = this.getRoomCategory(room);
|
||||||
|
|
||||||
const roomIdx = this.getRoomIndex(room);
|
const roomIdx = this.getRoomIndex(room);
|
||||||
if (roomIdx === -1) {
|
if (roomIdx === -1) {
|
||||||
throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`);
|
throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`);
|
||||||
|
|
|
@ -21,42 +21,191 @@ import { SortAlgorithm } from "../models";
|
||||||
import { sortRoomsWithAlgorithm } from "../tag-sorting";
|
import { sortRoomsWithAlgorithm } from "../tag-sorting";
|
||||||
import { OrderingAlgorithm } from "./OrderingAlgorithm";
|
import { OrderingAlgorithm } from "./OrderingAlgorithm";
|
||||||
import { RoomUpdateCause, TagID } from "../../models";
|
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
|
* Uses the natural tag sorting algorithm order to determine tag ordering. No
|
||||||
* additional behavioural changes are present.
|
* additional behavioural changes are present.
|
||||||
*/
|
*/
|
||||||
export class NaturalAlgorithm extends OrderingAlgorithm {
|
export class NaturalAlgorithm extends OrderingAlgorithm {
|
||||||
|
private cachedCategorizedOrderedRooms: NaturalCategorizedRoomMap = {
|
||||||
|
defaultRooms: [],
|
||||||
|
mutedRooms: [],
|
||||||
|
};
|
||||||
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
|
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
|
||||||
super(tagId, initialSortingAlgorithm);
|
super(tagId, initialSortingAlgorithm);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setRooms(rooms: Room[]): void {
|
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 {
|
public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean {
|
||||||
const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved;
|
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) {
|
if (!isSplice && !isInPlace) {
|
||||||
throw new Error(`Unsupported update cause: ${cause}`);
|
throw new Error(`Unsupported update cause: ${cause}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cause === RoomUpdateCause.NewRoom) {
|
if (cause === RoomUpdateCause.NewRoom) {
|
||||||
this.cachedOrderedRooms.push(room);
|
if (isMuted) {
|
||||||
} else if (cause === RoomUpdateCause.RoomRemoved) {
|
this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm(
|
||||||
const idx = this.getRoomIndex(room);
|
[...this.cachedCategorizedOrderedRooms.mutedRooms, room],
|
||||||
if (idx >= 0) {
|
this.tagId,
|
||||||
this.cachedOrderedRooms.splice(idx, 1);
|
this.sortingAlgorithm,
|
||||||
|
);
|
||||||
} else {
|
} 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
|
// 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
|
// 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;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,10 @@ export abstract class OrderingAlgorithm {
|
||||||
return this.cachedOrderedRooms;
|
return this.cachedOrderedRooms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get isMutedToBottom(): boolean {
|
||||||
|
return this.sortingAlgorithm === SortAlgorithm.Recent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the sorting algorithm to use within the list.
|
* Sets the sorting algorithm to use within the list.
|
||||||
* @param newAlgorithm The new algorithm. Must be defined.
|
* @param newAlgorithm The new algorithm. Must be defined.
|
||||||
|
|
|
@ -43,6 +43,7 @@ export type TagID = string | DefaultTagID;
|
||||||
export enum RoomUpdateCause {
|
export enum RoomUpdateCause {
|
||||||
Timeline = "TIMELINE",
|
Timeline = "TIMELINE",
|
||||||
PossibleTagChange = "POSSIBLE_TAG_CHANGE",
|
PossibleTagChange = "POSSIBLE_TAG_CHANGE",
|
||||||
|
PossibleMuteChange = "POSSIBLE_MUTE_CHANGE",
|
||||||
ReadReceipt = "READ_RECEIPT",
|
ReadReceipt = "READ_RECEIPT",
|
||||||
NewRoom = "NEW_ROOM",
|
NewRoom = "NEW_ROOM",
|
||||||
RoomRemoved = "ROOM_REMOVED",
|
RoomRemoved = "ROOM_REMOVED",
|
||||||
|
|
54
src/stores/room-list/utils/roomMute.ts
Normal file
54
src/stores/room-list/utils/roomMute.ts
Normal 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];
|
||||||
|
};
|
|
@ -14,16 +14,25 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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 { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||||
import SettingsStore, { CallbackFn } from "../../../src/settings/SettingsStore";
|
import SettingsStore, { CallbackFn } from "../../../src/settings/SettingsStore";
|
||||||
import { ListAlgorithm, SortAlgorithm } from "../../../src/stores/room-list/algorithms/models";
|
import { ListAlgorithm, SortAlgorithm } from "../../../src/stores/room-list/algorithms/models";
|
||||||
import { OrderedDefaultTagIDs, RoomUpdateCause } from "../../../src/stores/room-list/models";
|
import { OrderedDefaultTagIDs, RoomUpdateCause } from "../../../src/stores/room-list/models";
|
||||||
import RoomListStore, { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore";
|
import RoomListStore, { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore";
|
||||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
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", () => {
|
describe("RoomListStore", () => {
|
||||||
const client = stubClient();
|
const client = stubClient();
|
||||||
|
@ -69,12 +78,15 @@ describe("RoomListStore", () => {
|
||||||
});
|
});
|
||||||
upsertRoomStateEvents(roomNoPredecessor, [createNoPredecessor]);
|
upsertRoomStateEvents(roomNoPredecessor, [createNoPredecessor]);
|
||||||
const oldRoom = new Room(oldRoomId, client, userId, {});
|
const oldRoom = new Room(oldRoomId, client, userId, {});
|
||||||
|
const normalRoom = new Room("!normal:server.org", client, userId);
|
||||||
client.getRoom = jest.fn().mockImplementation((roomId) => {
|
client.getRoom = jest.fn().mockImplementation((roomId) => {
|
||||||
switch (roomId) {
|
switch (roomId) {
|
||||||
case newRoomId:
|
case newRoomId:
|
||||||
return roomWithCreatePredecessor;
|
return roomWithCreatePredecessor;
|
||||||
case oldRoomId:
|
case oldRoomId:
|
||||||
return oldRoom;
|
return oldRoom;
|
||||||
|
case normalRoom.roomId:
|
||||||
|
return normalRoom;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -274,4 +286,70 @@ describe("RoomListStore", () => {
|
||||||
expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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 { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore";
|
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 { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor";
|
||||||
import { AlphabeticAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm";
|
import { AlphabeticAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm";
|
||||||
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../test-utils";
|
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", () => {
|
describe("ImportanceAlgorithm", () => {
|
||||||
const userId = "@alice:server.org";
|
const userId = "@alice:server.org";
|
||||||
|
@ -57,6 +59,21 @@ describe("ImportanceAlgorithm", () => {
|
||||||
const roomE = makeRoom("!eee:server.org", "Echo", 3);
|
const roomE = makeRoom("!eee:server.org", "Echo", 3);
|
||||||
const roomX = makeRoom("!xxx:server.org", "Xylophone", 99);
|
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"]>> = {
|
const unreadStates: Record<string, ReturnType<(typeof RoomNotifs)["determineUnreadState"]>> = {
|
||||||
red: { symbol: null, count: 1, color: NotificationColor.Red },
|
red: { symbol: null, count: 1, color: NotificationColor.Red },
|
||||||
grey: { symbol: null, count: 1, color: NotificationColor.Grey },
|
grey: { symbol: null, count: 1, color: NotificationColor.Grey },
|
||||||
|
@ -240,6 +257,18 @@ describe("ImportanceAlgorithm", () => {
|
||||||
).toThrow("Unsupported update cause: something unexpected");
|
).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", () => {
|
describe("time and read receipt updates", () => {
|
||||||
it("throws for when a room is not indexed", () => {
|
it("throws for when a room is not indexed", () => {
|
||||||
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,14 +14,20 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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 { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { NaturalAlgorithm } from "../../../../../src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm";
|
import { NaturalAlgorithm } from "../../../../../src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm";
|
||||||
import { SortAlgorithm } from "../../../../../src/stores/room-list/algorithms/models";
|
import { SortAlgorithm } from "../../../../../src/stores/room-list/algorithms/models";
|
||||||
import { DefaultTagID, RoomUpdateCause } from "../../../../../src/stores/room-list/models";
|
import { DefaultTagID, RoomUpdateCause } from "../../../../../src/stores/room-list/models";
|
||||||
import { AlphabeticAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm";
|
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 { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../test-utils";
|
||||||
|
import { DEFAULT_PUSH_RULES, makePushRule } from "../../../../test-utils/pushRules";
|
||||||
|
import { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor";
|
||||||
|
|
||||||
describe("NaturalAlgorithm", () => {
|
describe("NaturalAlgorithm", () => {
|
||||||
const userId = "@alice:server.org";
|
const userId = "@alice:server.org";
|
||||||
|
@ -43,6 +49,21 @@ describe("NaturalAlgorithm", () => {
|
||||||
const roomE = makeRoom("!eee:server.org", "Echo");
|
const roomE = makeRoom("!eee:server.org", "Echo");
|
||||||
const roomX = makeRoom("!xxx:server.org", "Xylophone");
|
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 setupAlgorithm = (sortAlgorithm: SortAlgorithm, rooms?: Room[]) => {
|
||||||
const algorithm = new NaturalAlgorithm(tagId, sortAlgorithm);
|
const algorithm = new NaturalAlgorithm(tagId, sortAlgorithm);
|
||||||
algorithm.setRooms(rooms || [roomA, roomB, roomC]);
|
algorithm.setRooms(rooms || [roomA, roomB, roomC]);
|
||||||
|
@ -80,7 +101,7 @@ describe("NaturalAlgorithm", () => {
|
||||||
|
|
||||||
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved);
|
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}`);
|
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", () => {
|
it("throws for an unhandled update cause", () => {
|
||||||
const algorithm = setupAlgorithm(sortAlgorithm);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
96
test/stores/room-list/utils/roomMute-test.ts
Normal file
96
test/stores/room-list/utils/roomMute-test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Add table
Add a link
Reference in a new issue