From 0453b264e3b447e6a137502f899748fc47254144 Mon Sep 17 00:00:00 2001 From: kegsay Date: Wed, 26 Oct 2022 14:04:03 +0100 Subject: [PATCH] Sliding Sync: improve sort order, show subspace rooms, better tombstoned room handling (#9484) * Add support for include_old_rooms and by_notification_level * Include subspaces when apply spaces filter * Remove stray is_tombstoned * tests: add SlidingRoomListStore jest tests; update proxy version in cypress * Add additional tests * Additional tests * Linting * Update test/stores/room-list/SlidingRoomListStore-test.ts Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- cypress/plugins/sliding-sync/index.ts | 2 +- src/SlidingSyncManager.ts | 31 +- src/components/views/rooms/RoomSublist.tsx | 3 +- src/hooks/useSlidingSyncRoomSearch.ts | 1 - src/stores/room-list/RoomListStore.ts | 10 +- src/stores/room-list/SlidingRoomListStore.ts | 75 ++-- .../room-list/SlidingRoomListStore-test.ts | 319 ++++++++++++++++++ test/test-utils/client.ts | 20 ++ 8 files changed, 418 insertions(+), 43 deletions(-) create mode 100644 test/stores/room-list/SlidingRoomListStore-test.ts diff --git a/cypress/plugins/sliding-sync/index.ts b/cypress/plugins/sliding-sync/index.ts index 61a62aad13c..608ada8dbf3 100644 --- a/cypress/plugins/sliding-sync/index.ts +++ b/cypress/plugins/sliding-sync/index.ts @@ -77,7 +77,7 @@ async function proxyStart(synapse: SynapseInstance): Promise { const port = await getFreePort(); console.log(new Date(), "starting proxy container..."); const containerId = await dockerRun({ - image: "ghcr.io/matrix-org/sliding-sync-proxy:v0.4.0", + image: "ghcr.io/matrix-org/sliding-sync-proxy:v0.6.0", containerName: "react-sdk-cypress-sliding-sync-proxy", params: [ "--rm", diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts index 0e5736465ea..c41e6a78e32 100644 --- a/src/SlidingSyncManager.ts +++ b/src/SlidingSyncManager.ts @@ -63,6 +63,15 @@ const DEFAULT_ROOM_SUBSCRIPTION_INFO = { required_state: [ ["*", "*"], // all events ], + include_old_rooms: { + timeline_limit: 0, + required_state: [ // state needed to handle space navigation and tombstone chains + [EventType.RoomCreate, ""], + [EventType.RoomTombstone, ""], + [EventType.SpaceChild, "*"], + [EventType.SpaceParent, "*"], + ], + }, }; export type PartialSlidingSyncRequest = { @@ -121,6 +130,16 @@ export class SlidingSyncManager { [EventType.SpaceParent, "*"], // all space parents [EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room ], + include_old_rooms: { + timeline_limit: 0, + required_state: [ + [EventType.RoomCreate, ""], + [EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead + [EventType.SpaceChild, "*"], // all space children + [EventType.SpaceParent, "*"], // all space parents + [EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room + ], + }, filters: { room_types: ["m.space"], }, @@ -176,7 +195,7 @@ export class SlidingSyncManager { list = { ranges: [[0, 20]], sort: [ - "by_highlight_count", "by_notification_count", "by_recency", + "by_notification_level", "by_recency", ], timeline_limit: 1, // most recent message display: though this seems to only be needed for favourites? required_state: [ @@ -187,6 +206,16 @@ export class SlidingSyncManager { [EventType.RoomCreate, ""], // for isSpaceRoom checks [EventType.RoomMember, this.client.getUserId()], // lets the client calculate that we are in fact in the room ], + include_old_rooms: { + timeline_limit: 0, + required_state: [ + [EventType.RoomCreate, ""], + [EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead + [EventType.SpaceChild, "*"], // all space children + [EventType.SpaceParent, "*"], // all space parents + [EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room + ], + }, }; list = Object.assign(list, updateArgs); } else { diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index 9e890e9c219..9f8133d55cc 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -570,8 +570,7 @@ export default class RoomSublist extends React.Component { const slidingList = SlidingSyncManager.instance.slidingSync.getList(slidingSyncIndex); isAlphabetical = slidingList.sort[0] === "by_name"; isUnreadFirst = ( - slidingList.sort[0] === "by_highlight_count" || - slidingList.sort[0] === "by_notification_count" + slidingList.sort[0] === "by_notification_level" ); } diff --git a/src/hooks/useSlidingSyncRoomSearch.ts b/src/hooks/useSlidingSyncRoomSearch.ts index 6ba08dc1a7c..97e43d8b28a 100644 --- a/src/hooks/useSlidingSyncRoomSearch.ts +++ b/src/hooks/useSlidingSyncRoomSearch.ts @@ -52,7 +52,6 @@ export const useSlidingSyncRoomSearch = () => { ranges: [[0, limit]], filters: { room_name_like: term, - is_tombstoned: false, }, }); const rooms = []; diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index d6f9de79c31..73d6bdbd51f 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -24,7 +24,7 @@ import SettingsStore from "../../settings/SettingsStore"; import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models"; import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models"; import { ActionPayload } from "../../dispatcher/payloads"; -import defaultDispatcher from "../../dispatcher/dispatcher"; +import defaultDispatcher, { MatrixDispatcher } from "../../dispatcher/dispatcher"; import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition"; import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; @@ -65,8 +65,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements this.emit(LISTS_UPDATE_EVENT); }); - constructor() { - super(defaultDispatcher); + constructor(dis: MatrixDispatcher) { + super(dis); this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares this.algorithm.start(); } @@ -613,11 +613,11 @@ export default class RoomListStore { if (!RoomListStore.internalInstance) { if (SettingsStore.getValue("feature_sliding_sync")) { logger.info("using SlidingRoomListStoreClass"); - const instance = new SlidingRoomListStoreClass(); + const instance = new SlidingRoomListStoreClass(defaultDispatcher, SdkContextClass.instance); instance.start(); RoomListStore.internalInstance = instance; } else { - const instance = new RoomListStoreClass(); + const instance = new RoomListStoreClass(defaultDispatcher); instance.start(); RoomListStore.internalInstance = instance; } diff --git a/src/stores/room-list/SlidingRoomListStore.ts b/src/stores/room-list/SlidingRoomListStore.ts index 35550d04f1e..1c5fd1adea2 100644 --- a/src/stores/room-list/SlidingRoomListStore.ts +++ b/src/stores/room-list/SlidingRoomListStore.ts @@ -21,12 +21,10 @@ import { MSC3575Filter, SlidingSyncEvent } from "matrix-js-sdk/src/sliding-sync" import { RoomUpdateCause, TagID, OrderedDefaultTagIDs, DefaultTagID } from "./models"; import { ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models"; import { ActionPayload } from "../../dispatcher/payloads"; -import defaultDispatcher from "../../dispatcher/dispatcher"; +import { MatrixDispatcher } from "../../dispatcher/dispatcher"; import { IFilterCondition } from "./filters/IFilterCondition"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; -import { SlidingSyncManager } from "../../SlidingSyncManager"; -import SpaceStore from "../spaces/SpaceStore"; import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces"; import { LISTS_LOADING_EVENT } from "./RoomListStore"; import { UPDATE_EVENT } from "../AsyncStore"; @@ -38,7 +36,7 @@ interface IState { export const SlidingSyncSortToFilter: Record = { [SortAlgorithm.Alphabetic]: ["by_name", "by_recency"], - [SortAlgorithm.Recent]: ["by_highlight_count", "by_notification_count", "by_recency"], + [SortAlgorithm.Recent]: ["by_notification_level", "by_recency"], [SortAlgorithm.Manual]: ["by_recency"], }; @@ -48,21 +46,18 @@ const filterConditions: Record = { }, [DefaultTagID.Favourite]: { tags: ["m.favourite"], - is_tombstoned: false, }, // TODO https://github.com/vector-im/element-web/issues/23207 // DefaultTagID.SavedItems, [DefaultTagID.DM]: { is_dm: true, is_invite: false, - is_tombstoned: false, // If a DM has a Favourite & Low Prio tag then it'll be shown in those lists instead not_tags: ["m.favourite", "m.lowpriority"], }, [DefaultTagID.Untagged]: { is_dm: false, is_invite: false, - is_tombstoned: false, not_room_types: ["m.space"], not_tags: ["m.favourite", "m.lowpriority"], // spaces filter added dynamically @@ -71,7 +66,6 @@ const filterConditions: Record = { tags: ["m.lowpriority"], // If a room has both Favourite & Low Prio tags then it'll be shown under Favourites not_tags: ["m.favourite"], - is_tombstoned: false, }, // TODO https://github.com/vector-im/element-web/issues/23207 // DefaultTagID.ServerNotice, @@ -87,25 +81,25 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl private counts: Record = {}; private stickyRoomId: string | null; - public constructor() { - super(defaultDispatcher); + public constructor(dis: MatrixDispatcher, private readonly context: SdkContextClass) { + super(dis); this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares } public async setTagSorting(tagId: TagID, sort: SortAlgorithm) { logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort); this.tagIdToSortAlgo[tagId] = sort; - const slidingSyncIndex = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); + const slidingSyncIndex = this.context.slidingSyncManager.getOrAllocateListIndex(tagId); switch (sort) { case SortAlgorithm.Alphabetic: - await SlidingSyncManager.instance.ensureListRegistered( + await this.context.slidingSyncManager.ensureListRegistered( slidingSyncIndex, { sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic], }, ); break; case SortAlgorithm.Recent: - await SlidingSyncManager.instance.ensureListRegistered( + await this.context.slidingSyncManager.ensureListRegistered( slidingSyncIndex, { sort: SlidingSyncSortToFilter[SortAlgorithm.Recent], }, @@ -174,10 +168,13 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl // check all lists for each tag we know about and see if the room is there const tags: TagID[] = []; for (const tagId in this.tagIdToSortAlgo) { - const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); - const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(index); - for (const roomIndex in roomIndexToRoomId) { - const roomId = roomIndexToRoomId[roomIndex]; + const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId); + const listData = this.context.slidingSyncManager.slidingSync.getListData(index); + if (!listData) { + continue; + } + for (const roomIndex in listData.roomIndexToRoomId) { + const roomId = listData.roomIndexToRoomId[roomIndex]; if (roomId === room.roomId) { tags.push(tagId); break; @@ -207,7 +204,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl // this room will not move due to it being viewed: it is sticky. This can be null to indicate // no sticky room if you aren't viewing a room. - this.stickyRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); + this.stickyRoomId = this.context.roomViewStore.getRoomId(); let stickyRoomNewIndex = -1; const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room) => { return room.roomId === this.stickyRoomId; @@ -264,7 +261,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl } private onSlidingSyncListUpdate(listIndex: number, joinCount: number, roomIndexToRoomId: Record) { - const tagId = SlidingSyncManager.instance.listIdForIndex(listIndex); + const tagId = this.context.slidingSyncManager.listIdForIndex(listIndex); this.counts[tagId]= joinCount; this.refreshOrderedLists(tagId, roomIndexToRoomId); // let the UI update @@ -273,7 +270,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl private onRoomViewStoreUpdated() { // we only care about this to know when the user has clicked on a room to set the stickiness value - if (SdkContextClass.instance.roomViewStore.getRoomId() === this.stickyRoomId) { + if (this.context.roomViewStore.getRoomId() === this.stickyRoomId) { return; } @@ -296,14 +293,17 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl if (room) { // resort it based on the slidingSync view of the list. This may cause this old sticky // room to cease to exist. - const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); - const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(index); - this.refreshOrderedLists(tagId, roomIndexToRoomId); + const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId); + const listData = this.context.slidingSyncManager.slidingSync.getListData(index); + if (!listData) { + continue; + } + this.refreshOrderedLists(tagId, listData.roomIndexToRoomId); hasUpdatedAnyList = true; } } // in the event we didn't call refreshOrderedLists, it helps to still remember the sticky room ID. - this.stickyRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); + this.stickyRoomId = this.context.roomViewStore.getRoomId(); if (hasUpdatedAnyList) { this.emit(LISTS_UPDATE_EVENT); @@ -313,11 +313,11 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl protected async onReady(): Promise { logger.info("SlidingRoomListStore.onReady"); // permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation. - SlidingSyncManager.instance.slidingSync.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this)); - SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); - SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this)); - if (SpaceStore.instance.activeSpace) { - this.onSelectedSpaceUpdated(SpaceStore.instance.activeSpace, false); + this.context.slidingSyncManager.slidingSync.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this)); + this.context.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); + this.context.spaceStore.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this)); + if (this.context.spaceStore.activeSpace) { + this.onSelectedSpaceUpdated(this.context.spaceStore.activeSpace, false); } // sliding sync has an initial response for spaces. Now request all the lists. @@ -332,8 +332,8 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl const sort = SortAlgorithm.Recent; // default to recency sort, TODO: read from config this.tagIdToSortAlgo[tagId] = sort; this.emit(LISTS_LOADING_EVENT, tagId, true); - const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); - SlidingSyncManager.instance.ensureListRegistered(index, { + const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId); + this.context.slidingSyncManager.ensureListRegistered(index, { filters: filter, sort: SlidingSyncSortToFilter[sort], }).then(() => { @@ -350,9 +350,18 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl const oldSpace = filters.spaces?.[0]; filters.spaces = (activeSpace && activeSpace != MetaSpace.Home) ? [activeSpace] : undefined; if (oldSpace !== activeSpace) { + // include subspaces in this list + this.context.spaceStore.traverseSpace(activeSpace, (roomId: string) => { + if (roomId === activeSpace) { + return; + } + filters.spaces.push(roomId); // add subspace + }, false); + this.emit(LISTS_LOADING_EVENT, tagId, true); - SlidingSyncManager.instance.ensureListRegistered( - SlidingSyncManager.instance.getOrAllocateListIndex(tagId), + const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId); + this.context.slidingSyncManager.ensureListRegistered( + index, { filters: filters, }, diff --git a/test/stores/room-list/SlidingRoomListStore-test.ts b/test/stores/room-list/SlidingRoomListStore-test.ts new file mode 100644 index 00000000000..488c92396a1 --- /dev/null +++ b/test/stores/room-list/SlidingRoomListStore-test.ts @@ -0,0 +1,319 @@ +/* +Copyright 2022 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 { mocked } from 'jest-mock'; +import { SlidingSync, SlidingSyncEvent } from 'matrix-js-sdk/src/sliding-sync'; +import { Room } from 'matrix-js-sdk/src/matrix'; + +import { + LISTS_UPDATE_EVENT, + SlidingRoomListStoreClass, + SlidingSyncSortToFilter, +} from "../../../src/stores/room-list/SlidingRoomListStore"; +import { SpaceStoreClass } from "../../../src/stores/spaces/SpaceStore"; +import { MockEventEmitter, stubClient, untilEmission } from "../../test-utils"; +import { TestSdkContext } from '../../TestSdkContext'; +import { SlidingSyncManager } from '../../../src/SlidingSyncManager'; +import { RoomViewStore } from '../../../src/stores/RoomViewStore'; +import { MatrixDispatcher } from '../../../src/dispatcher/dispatcher'; +import { SortAlgorithm } from '../../../src/stores/room-list/algorithms/models'; +import { DefaultTagID, TagID } from '../../../src/stores/room-list/models'; +import { UPDATE_SELECTED_SPACE } from '../../../src/stores/spaces'; +import { LISTS_LOADING_EVENT } from '../../../src/stores/room-list/RoomListStore'; +import { UPDATE_EVENT } from '../../../src/stores/AsyncStore'; + +jest.mock('../../../src/SlidingSyncManager'); +const MockSlidingSyncManager = >SlidingSyncManager; + +describe("SlidingRoomListStore", () => { + let store: SlidingRoomListStoreClass; + let context: TestSdkContext; + let dis: MatrixDispatcher; + let activeSpace: string; + let tagIdToIndex = {}; + + beforeEach(async () => { + context = new TestSdkContext(); + context.client = stubClient(); + context._SpaceStore = new MockEventEmitter({ + traverseSpace: jest.fn(), + get activeSpace() { + return activeSpace; + }, + }) as SpaceStoreClass; + context._SlidingSyncManager = new MockSlidingSyncManager(); + context._SlidingSyncManager.slidingSync = mocked(new MockEventEmitter({ + getListData: jest.fn(), + }) as unknown as SlidingSync); + context._RoomViewStore = mocked(new MockEventEmitter({ + getRoomId: jest.fn(), + }) as unknown as RoomViewStore); + + // mock implementations to allow the store to map tag IDs to sliding sync list indexes and vice versa + let index = 0; + tagIdToIndex = {}; + mocked(context._SlidingSyncManager.getOrAllocateListIndex).mockImplementation((listId: string): number => { + if (tagIdToIndex[listId] != null) { + return tagIdToIndex[listId]; + } + tagIdToIndex[listId] = index; + index++; + return index; + }); + mocked(context.slidingSyncManager.listIdForIndex).mockImplementation((i) => { + for (const tagId in tagIdToIndex) { + const j = tagIdToIndex[tagId]; + if (i === j) { + return tagId; + } + } + return null; + }); + mocked(context._SlidingSyncManager.ensureListRegistered).mockResolvedValue({ + ranges: [[0, 10]], + }); + + dis = new MatrixDispatcher(); + store = new SlidingRoomListStoreClass(dis, context); + }); + + describe("spaces", () => { + it("alters 'filters.spaces' on the DefaultTagID.Untagged list when the selected space changes", async () => { + await store.start(); // call onReady + const spaceRoomId = "!foo:bar"; + + const p = untilEmission(store, LISTS_LOADING_EVENT, (listName, isLoading) => { + return listName === DefaultTagID.Untagged && !isLoading; + }); + + // change the active space + activeSpace = spaceRoomId; + context._SpaceStore.emit(UPDATE_SELECTED_SPACE, spaceRoomId, false); + await p; + + expect(context._SlidingSyncManager.ensureListRegistered).toHaveBeenCalledWith( + tagIdToIndex[DefaultTagID.Untagged], + { + filters: expect.objectContaining({ + spaces: [spaceRoomId], + }), + }, + ); + }); + + it("alters 'filters.spaces' on the DefaultTagID.Untagged list if it loads with an active space", async () => { + // change the active space before we are ready + const spaceRoomId = "!foo2:bar"; + activeSpace = spaceRoomId; + const p = untilEmission(store, LISTS_LOADING_EVENT, (listName, isLoading) => { + return listName === DefaultTagID.Untagged && !isLoading; + }); + await store.start(); // call onReady + await p; + expect(context._SlidingSyncManager.ensureListRegistered).toHaveBeenCalledWith( + tagIdToIndex[DefaultTagID.Untagged], + expect.objectContaining({ + filters: expect.objectContaining({ + spaces: [spaceRoomId], + }), + }), + ); + }); + + it("includes subspaces in 'filters.spaces' when the selected space has subspaces", async () => { + await store.start(); // call onReady + const spaceRoomId = "!foo:bar"; + const subSpace1 = "!ss1:bar"; + const subSpace2 = "!ss2:bar"; + + const p = untilEmission(store, LISTS_LOADING_EVENT, (listName, isLoading) => { + return listName === DefaultTagID.Untagged && !isLoading; + }); + + mocked(context._SpaceStore.traverseSpace).mockImplementation( + (spaceId: string, fn: (roomId: string) => void) => { + if (spaceId === spaceRoomId) { + fn(subSpace1); + fn(subSpace2); + } + }, + ); + + // change the active space + activeSpace = spaceRoomId; + context._SpaceStore.emit(UPDATE_SELECTED_SPACE, spaceRoomId, false); + await p; + + expect(context._SlidingSyncManager.ensureListRegistered).toHaveBeenCalledWith( + tagIdToIndex[DefaultTagID.Untagged], + { + filters: expect.objectContaining({ + spaces: [spaceRoomId, subSpace1, subSpace2], + }), + }, + ); + }); + }); + + it("setTagSorting alters the 'sort' option in the list", async () => { + mocked(context._SlidingSyncManager.getOrAllocateListIndex).mockReturnValue(0); + const tagId: TagID = "foo"; + await store.setTagSorting(tagId, SortAlgorithm.Alphabetic); + expect(context._SlidingSyncManager.ensureListRegistered).toBeCalledWith(0, { + sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic], + }); + expect(store.getTagSorting(tagId)).toEqual(SortAlgorithm.Alphabetic); + + await store.setTagSorting(tagId, SortAlgorithm.Recent); + expect(context._SlidingSyncManager.ensureListRegistered).toBeCalledWith(0, { + sort: SlidingSyncSortToFilter[SortAlgorithm.Recent], + }); + expect(store.getTagSorting(tagId)).toEqual(SortAlgorithm.Recent); + }); + + it("getTagsForRoom gets the tags for the room", async () => { + await store.start(); + const untaggedIndex = context._SlidingSyncManager.getOrAllocateListIndex(DefaultTagID.Untagged); + const favIndex = context._SlidingSyncManager.getOrAllocateListIndex(DefaultTagID.Favourite); + const roomA = "!a:localhost"; + const roomB = "!b:localhost"; + const indexToListData = { + [untaggedIndex]: { + joinedCount: 10, + roomIndexToRoomId: { + 0: roomA, + 1: roomB, + }, + }, + [favIndex]: { + joinedCount: 2, + roomIndexToRoomId: { + 0: roomB, + }, + }, + }; + mocked(context._SlidingSyncManager.slidingSync.getListData).mockImplementation((i: number) => { + return indexToListData[i] || null; + }); + + expect(store.getTagsForRoom(new Room(roomA, context.client, context.client.getUserId()))).toEqual( + [DefaultTagID.Untagged], + ); + expect(store.getTagsForRoom(new Room(roomB, context.client, context.client.getUserId()))).toEqual( + [DefaultTagID.Favourite, DefaultTagID.Untagged], + ); + }); + + it("emits LISTS_UPDATE_EVENT when slidingSync lists update", async () => { + await store.start(); + const roomA = "!a:localhost"; + const roomB = "!b:localhost"; + const roomC = "!c:localhost"; + const tagId = DefaultTagID.Favourite; + const listIndex = context.slidingSyncManager.getOrAllocateListIndex(tagId); + const joinCount = 10; + const roomIndexToRoomId = { // mixed to ensure we sort + 1: roomB, + 2: roomC, + 0: roomA, + }; + const rooms = [ + new Room(roomA, context.client, context.client.getUserId()), + new Room(roomB, context.client, context.client.getUserId()), + new Room(roomC, context.client, context.client.getUserId()), + ]; + mocked(context.client.getRoom).mockImplementation((roomId: string) => { + switch (roomId) { + case roomA: + return rooms[0]; + case roomB: + return rooms[1]; + case roomC: + return rooms[2]; + } + return null; + }); + const p = untilEmission(store, LISTS_UPDATE_EVENT); + context.slidingSyncManager.slidingSync.emit(SlidingSyncEvent.List, listIndex, joinCount, roomIndexToRoomId); + await p; + expect(store.getCount(tagId)).toEqual(joinCount); + expect(store.orderedLists[tagId]).toEqual(rooms); + }); + + it("sets the sticky room on the basis of the viewed room in RoomViewStore", async () => { + await store.start(); + // seed the store with 3 rooms + const roomIdA = "!a:localhost"; + const roomIdB = "!b:localhost"; + const roomIdC = "!c:localhost"; + const tagId = DefaultTagID.Favourite; + const listIndex = context.slidingSyncManager.getOrAllocateListIndex(tagId); + const joinCount = 10; + const roomIndexToRoomId = { // mixed to ensure we sort + 1: roomIdB, + 2: roomIdC, + 0: roomIdA, + }; + const roomA = new Room(roomIdA, context.client, context.client.getUserId()); + const roomB = new Room(roomIdB, context.client, context.client.getUserId()); + const roomC = new Room(roomIdC, context.client, context.client.getUserId()); + mocked(context.client.getRoom).mockImplementation((roomId: string) => { + switch (roomId) { + case roomIdA: + return roomA; + case roomIdB: + return roomB; + case roomIdC: + return roomC; + } + return null; + }); + mocked(context._SlidingSyncManager.slidingSync.getListData).mockImplementation((i: number) => { + if (i !== listIndex) { + return null; + } + return { + roomIndexToRoomId: roomIndexToRoomId, + joinedCount: joinCount, + }; + }); + let p = untilEmission(store, LISTS_UPDATE_EVENT); + context.slidingSyncManager.slidingSync.emit(SlidingSyncEvent.List, listIndex, joinCount, roomIndexToRoomId); + await p; + expect(store.orderedLists[tagId]).toEqual([roomA, roomB, roomC]); + + // make roomB sticky and inform the store + mocked(context.roomViewStore.getRoomId).mockReturnValue(roomIdB); + context.roomViewStore.emit(UPDATE_EVENT); + + // bump room C to the top, room B should not move from i=1 despite the list update saying to + roomIndexToRoomId[0] = roomIdC; + roomIndexToRoomId[1] = roomIdA; + roomIndexToRoomId[2] = roomIdB; + p = untilEmission(store, LISTS_UPDATE_EVENT); + context.slidingSyncManager.slidingSync.emit(SlidingSyncEvent.List, listIndex, joinCount, roomIndexToRoomId); + await p; + + // check that B didn't move and that A was put below B + expect(store.orderedLists[tagId]).toEqual([roomC, roomB, roomA]); + + // make room C sticky: rooms should move as a result, without needing an additional list update + mocked(context.roomViewStore.getRoomId).mockReturnValue(roomIdC); + p = untilEmission(store, LISTS_UPDATE_EVENT); + context.roomViewStore.emit(UPDATE_EVENT); + await p; + expect(store.orderedLists[tagId].map((r) => r.roomId)).toEqual([roomC, roomA, roomB].map((r) => r.roomId)); + }); +}); diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index e0c532c0216..e155dd17c4c 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -21,6 +21,26 @@ import { MatrixClient, User } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +/** + * Mocked generic class with a real EventEmitter. + * Useful for mocks which need event emitters. + */ +export class MockEventEmitter extends EventEmitter { + /** + * Construct a new event emitter with additional properties/functions. The event emitter functions + * like .emit and .on will be real. + * @param mockProperties An object with the mock property or function implementations. 'getters' + * are correctly cloned to this event emitter. + */ + constructor(mockProperties: Partial|PropertyKeysOf, unknown>> = {}) { + super(); + // We must use defineProperties and not assign as the former clones getters correctly, + // whereas the latter invokes the getter and sets the return value permanently on the + // destination object. + Object.defineProperties(this, Object.getOwnPropertyDescriptors(mockProperties)); + } +} + /** * Mock client with real event emitter * useful for testing code that listens