Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

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

Merged
merged 39 commits into from
May 5, 2023
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
b937438
muted-to-the-bottom POC
Apr 13, 2023
5292e47
split muted rooms in natural algorithm
Apr 18, 2023
07606b1
add previous event to account data dispatch
Apr 18, 2023
668cf8e
add muted to notification state
Apr 18, 2023
6450a73
sort muted rooms to the bottom
Apr 18, 2023
94ed2a6
only split muted rooms when sorting is RECENT
Apr 19, 2023
4352b46
Merge branch 'develop' into kerry/sort-muted
Apr 19, 2023
1b095d3
remove debugs
Apr 19, 2023
3ed9133
Merge branch 'develop' into kerry/sort-muted
Apr 19, 2023
acb7606
use RoomNotifState better
Apr 19, 2023
17c72fb
add default notifications test util
Apr 17, 2023
e638e15
test getChangedOverrideRoomPushRules
Apr 20, 2023
cd00719
remove file
Apr 20, 2023
625e286
test roomudpate in roomliststore
Apr 20, 2023
b8cb050
unit test ImportanceAlgorithm
Apr 21, 2023
86194ab
Merge branch 'develop' into kerry/test-importance-algorithm
Apr 21, 2023
e44f8be
strict fixes
Apr 21, 2023
b92f410
Merge branch 'develop' into kerry/sort-muted
Apr 21, 2023
a4a05d2
Merge branch 'kerry/test-importance-algorithm' into kerry/sort-muted
Apr 21, 2023
4028131
test recent x importance with muted rooms
Apr 21, 2023
f5faa5f
unit test NaturalAlgorithm
Apr 21, 2023
097b81b
Merge branch 'kerry/test-importance-algorithm' into kerry/sort-muted
Apr 21, 2023
1c81d6f
Merge branch 'develop' into kerry/sort-muted
Apr 24, 2023
652a41f
test naturalalgorithm with muted rooms
Apr 24, 2023
84ea40f
strict fixes
Apr 24, 2023
b2e9281
Merge branch 'develop' into kerry/sort-muted
Apr 25, 2023
bbb19f5
comments
Apr 25, 2023
6a15a48
add push rules test utility
Apr 25, 2023
33a32f6
Merge branch 'kerry/push-rules-test-util' into kerry/sort-muted
Apr 25, 2023
bfb9ed6
strict fixes
Apr 26, 2023
6eb155b
more strict
Apr 26, 2023
c418923
Merge branch 'develop' into kerry/sort-muted
Apr 27, 2023
0b9997c
tidy comment
Apr 27, 2023
237b4ca
document previousevent on account data dispatch event
Apr 27, 2023
43f3316
simplify (?) room mute rule utilities, comments
Apr 27, 2023
ca05e61
Merge branch 'develop' into kerry/sort-muted
Apr 28, 2023
8654bea
Merge branch 'develop' into kerry/sort-muted
May 1, 2023
30be86f
remove debug
May 5, 2023
947f994
Merge branch 'develop' into kerry/sort-muted
May 5, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/RoomNotifs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,21 @@ function findOverrideMuteRule(roomId: string): IPushRule | null {
return null;
}

function isRuleForRoom(roomId: string, rule: IPushRule): boolean {
export function isRuleRoomSpecific(rule: IPushRule): boolean {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A room-specific rule is a different thing to what this function appears to be looking for (https://spec.matrix.org/v1.6/client-server-api/#push-rules).

I'd suggest finding a different name, but a comment would also be good, defining exactly what it matches.

The fact it only matches rules with a single condition is a bit obscure: why doesn't it include rules which have multiple conditions (of which one is room match)? I can easily see future developers deciding to change that, which seems like it would break isRuleForRoom, so it seems particularly important to call out in a comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair call.
I've (hopefully) simplified this a bit, and added some comments

if (rule.conditions?.length !== 1) {
return false;
}
const cond = rule.conditions[0];
return cond.kind === ConditionKind.EventMatch && cond.key === "room_id" && cond.pattern === roomId;
return cond.kind === ConditionKind.EventMatch && cond.key === "room_id";
}

function isRuleForRoom(roomId: string, rule: IPushRule): boolean {
if (!isRuleRoomSpecific(rule)) {
return false;
}
// isRuleRoomSpecific checks this condition exists
const cond = rule.conditions![0]!;
return cond.pattern === roomId;
}

function isMuteRule(rule: IPushRule): boolean {
Expand Down
7 changes: 6 additions & 1 deletion src/actions/MatrixActionCreators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,17 @@ function createSyncAction(matrixClient: MatrixClient, state: string, prevState:
* @param {MatrixEvent} accountDataEvent the account data event.
* @returns {AccountDataAction} an action of type MatrixActions.accountData.
*/
function createAccountDataAction(matrixClient: MatrixClient, accountDataEvent: MatrixEvent): ActionPayload {
function createAccountDataAction(
matrixClient: MatrixClient,
accountDataEvent: MatrixEvent,
previousAccountDataEvent?: MatrixEvent,
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved
): ActionPayload {
return {
action: "MatrixActions.accountData",
event: accountDataEvent,
event_type: accountDataEvent.getType(),
event_content: accountDataEvent.getContent(),
previousEvent: previousAccountDataEvent,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add this to the definition of AccountDataAction please.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels a bit asymmetric that we add the type and content of accountDataEvent to the action separately, but use a single field for previousAccountDataEvent. Did you consider splitting previousAccountDataEvent for symmetry?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like the destructuring in the first place as the event is also included in full.
Most other matrix actions that have events in their payload don't do any destructuring.
previousEvent will also always have the same type as event

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yes, I'd failed to spot the presence of event. What a strange structure. 👍 to your solution here.

};
}

Expand Down
1 change: 1 addition & 0 deletions src/stores/notifications/NotificationColor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
import { _t } from "../../languageHandler";

export enum NotificationColor {
Muted, // the room
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this comment - are there some words missing?

// 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
Expand Down
12 changes: 10 additions & 2 deletions src/stores/notifications/NotificationState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface INotificationStateSnapshotParams {
symbol: string | null;
count: number;
color: NotificationColor;
muted: boolean;
}

export enum NotificationStateEvents {
Expand All @@ -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[] = [];

Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
}
}
3 changes: 3 additions & 0 deletions src/stores/notifications/RoomNotificationState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/stores/room-list/RoomListStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { getChangedOverrideRoomPushRules } from "./utils/roomMute";

interface IState {
// state is tracked in underlying classes
Expand Down Expand Up @@ -289,6 +290,17 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> implements
this.onDispatchMyMembership(<any>payload);
return;
}

const possibleMuteChangeRoomIds = getChangedOverrideRoomPushRules(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();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const CATEGORY_ORDER = [
NotificationColor.Grey,
NotificationColor.Bold,
NotificationColor.None, // idle
NotificationColor.Muted,
];

/**
Expand Down Expand Up @@ -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);
Expand All @@ -94,7 +96,8 @@ 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;
console.log(room.roomId, state.muted, state.color);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remaining console.log

return this.isMutedToBottom && state.muted ? NotificationColor.Muted : state.color;
}

public setRooms(rooms: Room[]): void {
Expand Down Expand Up @@ -164,15 +167,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}`);
Expand Down
167 changes: 158 additions & 9 deletions src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a lot of if conditions, some additional comments are welcomed :)

Maybe split the content of the function in multiple private functions to make it easier to read.

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

// TODO: Optimize this to avoid useless operations: https://github.com/vector-im/element-web/issues/14457
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
this.cachedOrderedRooms = sortRoomsWithAlgorithm(this.cachedOrderedRooms, this.tagId, this.sortingAlgorithm);

if (isMuted) {
this.cachedCategorizedOrderedRooms.mutedRooms = sortRoomsWithAlgorithm(
this.cachedCategorizedOrderedRooms.mutedRooms,
this.tagId,
this.sortingAlgorithm,
);
} else {
this.cachedCategorizedOrderedRooms.defaultRooms = sortRoomsWithAlgorithm(
this.cachedCategorizedOrderedRooms.defaultRooms,
this.tagId,
this.sortingAlgorithm,
);
}
this.buildCachedOrderedRooms();
return true;
}

/**
* Remove a room from the cached room list
* @param room Room to remove
* @returns {boolean} true when room list should update as result
*/
private removeRoom(room: Room): boolean {
const defaultIndex = this.cachedCategorizedOrderedRooms.defaultRooms.findIndex((r) => r.roomId === room.roomId);
if (defaultIndex > -1) {
this.cachedCategorizedOrderedRooms.defaultRooms.splice(defaultIndex, 1);
this.buildCachedOrderedRooms();
return true;
}
const mutedIndex = this.cachedCategorizedOrderedRooms.mutedRooms.findIndex((r) => r.roomId === room.roomId);
if (mutedIndex > -1) {
this.cachedCategorizedOrderedRooms.mutedRooms.splice(mutedIndex, 1);
this.buildCachedOrderedRooms();
return true;
}

logger.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
// room was not in cached lists, no update
return false;
}

/**
* Sets cachedOrderedRooms from cachedCategorizedOrderedRooms
*/
private buildCachedOrderedRooms(): void {
this.cachedOrderedRooms = [
...this.cachedCategorizedOrderedRooms.defaultRooms,
...this.cachedCategorizedOrderedRooms.mutedRooms,
];
}

private getRoomIsMuted(room: Room): boolean {
// It's fine for us to call this a lot because it's cached, and we shouldn't be
// wasting anything by doing so as the store holds single references
const state = RoomNotificationStateStore.instance.getRoomState(room);
return state.muted;
}

private categorizeRooms(rooms: Room[]): NaturalCategorizedRoomMap {
if (!this.isMutedToBottom) {
return { defaultRooms: rooms, mutedRooms: [] };
}
return rooms.reduce<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;
}
}
Loading