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

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