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>
This commit is contained in:
kegsay 2022-10-26 14:04:03 +01:00 committed by GitHub
parent 097ca43420
commit 0453b264e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 418 additions and 43 deletions

View file

@ -77,7 +77,7 @@ async function proxyStart(synapse: SynapseInstance): Promise<ProxyInstance> {
const port = await getFreePort(); const port = await getFreePort();
console.log(new Date(), "starting proxy container..."); console.log(new Date(), "starting proxy container...");
const containerId = await dockerRun({ 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", containerName: "react-sdk-cypress-sliding-sync-proxy",
params: [ params: [
"--rm", "--rm",

View file

@ -63,6 +63,15 @@ const DEFAULT_ROOM_SUBSCRIPTION_INFO = {
required_state: [ required_state: [
["*", "*"], // all events ["*", "*"], // 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 = { export type PartialSlidingSyncRequest = {
@ -121,6 +130,16 @@ export class SlidingSyncManager {
[EventType.SpaceParent, "*"], // all space parents [EventType.SpaceParent, "*"], // all space parents
[EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room [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: { filters: {
room_types: ["m.space"], room_types: ["m.space"],
}, },
@ -176,7 +195,7 @@ export class SlidingSyncManager {
list = { list = {
ranges: [[0, 20]], ranges: [[0, 20]],
sort: [ 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? timeline_limit: 1, // most recent message display: though this seems to only be needed for favourites?
required_state: [ required_state: [
@ -187,6 +206,16 @@ export class SlidingSyncManager {
[EventType.RoomCreate, ""], // for isSpaceRoom checks [EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.RoomMember, this.client.getUserId()], // lets the client calculate that we are in fact in the room [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); list = Object.assign(list, updateArgs);
} else { } else {

View file

@ -570,8 +570,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
const slidingList = SlidingSyncManager.instance.slidingSync.getList(slidingSyncIndex); const slidingList = SlidingSyncManager.instance.slidingSync.getList(slidingSyncIndex);
isAlphabetical = slidingList.sort[0] === "by_name"; isAlphabetical = slidingList.sort[0] === "by_name";
isUnreadFirst = ( isUnreadFirst = (
slidingList.sort[0] === "by_highlight_count" || slidingList.sort[0] === "by_notification_level"
slidingList.sort[0] === "by_notification_count"
); );
} }

View file

@ -52,7 +52,6 @@ export const useSlidingSyncRoomSearch = () => {
ranges: [[0, limit]], ranges: [[0, limit]],
filters: { filters: {
room_name_like: term, room_name_like: term,
is_tombstoned: false,
}, },
}); });
const rooms = []; const rooms = [];

View file

@ -24,7 +24,7 @@ import SettingsStore from "../../settings/SettingsStore";
import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models"; import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models"; import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher, { MatrixDispatcher } from "../../dispatcher/dispatcher";
import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition"; import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition";
import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
@ -65,8 +65,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> implements
this.emit(LISTS_UPDATE_EVENT); this.emit(LISTS_UPDATE_EVENT);
}); });
constructor() { constructor(dis: MatrixDispatcher) {
super(defaultDispatcher); super(dis);
this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares
this.algorithm.start(); this.algorithm.start();
} }
@ -613,11 +613,11 @@ export default class RoomListStore {
if (!RoomListStore.internalInstance) { if (!RoomListStore.internalInstance) {
if (SettingsStore.getValue("feature_sliding_sync")) { if (SettingsStore.getValue("feature_sliding_sync")) {
logger.info("using SlidingRoomListStoreClass"); logger.info("using SlidingRoomListStoreClass");
const instance = new SlidingRoomListStoreClass(); const instance = new SlidingRoomListStoreClass(defaultDispatcher, SdkContextClass.instance);
instance.start(); instance.start();
RoomListStore.internalInstance = instance; RoomListStore.internalInstance = instance;
} else { } else {
const instance = new RoomListStoreClass(); const instance = new RoomListStoreClass(defaultDispatcher);
instance.start(); instance.start();
RoomListStore.internalInstance = instance; RoomListStore.internalInstance = instance;
} }

View file

@ -21,12 +21,10 @@ import { MSC3575Filter, SlidingSyncEvent } from "matrix-js-sdk/src/sliding-sync"
import { RoomUpdateCause, TagID, OrderedDefaultTagIDs, DefaultTagID } from "./models"; import { RoomUpdateCause, TagID, OrderedDefaultTagIDs, DefaultTagID } from "./models";
import { ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models"; import { ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
import defaultDispatcher from "../../dispatcher/dispatcher"; import { MatrixDispatcher } from "../../dispatcher/dispatcher";
import { IFilterCondition } from "./filters/IFilterCondition"; import { IFilterCondition } from "./filters/IFilterCondition";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; 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 { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces";
import { LISTS_LOADING_EVENT } from "./RoomListStore"; import { LISTS_LOADING_EVENT } from "./RoomListStore";
import { UPDATE_EVENT } from "../AsyncStore"; import { UPDATE_EVENT } from "../AsyncStore";
@ -38,7 +36,7 @@ interface IState {
export const SlidingSyncSortToFilter: Record<SortAlgorithm, string[]> = { export const SlidingSyncSortToFilter: Record<SortAlgorithm, string[]> = {
[SortAlgorithm.Alphabetic]: ["by_name", "by_recency"], [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"], [SortAlgorithm.Manual]: ["by_recency"],
}; };
@ -48,21 +46,18 @@ const filterConditions: Record<TagID, MSC3575Filter> = {
}, },
[DefaultTagID.Favourite]: { [DefaultTagID.Favourite]: {
tags: ["m.favourite"], tags: ["m.favourite"],
is_tombstoned: false,
}, },
// TODO https://github.com/vector-im/element-web/issues/23207 // TODO https://github.com/vector-im/element-web/issues/23207
// DefaultTagID.SavedItems, // DefaultTagID.SavedItems,
[DefaultTagID.DM]: { [DefaultTagID.DM]: {
is_dm: true, is_dm: true,
is_invite: false, is_invite: false,
is_tombstoned: false,
// If a DM has a Favourite & Low Prio tag then it'll be shown in those lists instead // If a DM has a Favourite & Low Prio tag then it'll be shown in those lists instead
not_tags: ["m.favourite", "m.lowpriority"], not_tags: ["m.favourite", "m.lowpriority"],
}, },
[DefaultTagID.Untagged]: { [DefaultTagID.Untagged]: {
is_dm: false, is_dm: false,
is_invite: false, is_invite: false,
is_tombstoned: false,
not_room_types: ["m.space"], not_room_types: ["m.space"],
not_tags: ["m.favourite", "m.lowpriority"], not_tags: ["m.favourite", "m.lowpriority"],
// spaces filter added dynamically // spaces filter added dynamically
@ -71,7 +66,6 @@ const filterConditions: Record<TagID, MSC3575Filter> = {
tags: ["m.lowpriority"], tags: ["m.lowpriority"],
// If a room has both Favourite & Low Prio tags then it'll be shown under Favourites // If a room has both Favourite & Low Prio tags then it'll be shown under Favourites
not_tags: ["m.favourite"], not_tags: ["m.favourite"],
is_tombstoned: false,
}, },
// TODO https://github.com/vector-im/element-web/issues/23207 // TODO https://github.com/vector-im/element-web/issues/23207
// DefaultTagID.ServerNotice, // DefaultTagID.ServerNotice,
@ -87,25 +81,25 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> impl
private counts: Record<TagID, number> = {}; private counts: Record<TagID, number> = {};
private stickyRoomId: string | null; private stickyRoomId: string | null;
public constructor() { public constructor(dis: MatrixDispatcher, private readonly context: SdkContextClass) {
super(defaultDispatcher); super(dis);
this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares
} }
public async setTagSorting(tagId: TagID, sort: SortAlgorithm) { public async setTagSorting(tagId: TagID, sort: SortAlgorithm) {
logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort); logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort);
this.tagIdToSortAlgo[tagId] = sort; this.tagIdToSortAlgo[tagId] = sort;
const slidingSyncIndex = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); const slidingSyncIndex = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
switch (sort) { switch (sort) {
case SortAlgorithm.Alphabetic: case SortAlgorithm.Alphabetic:
await SlidingSyncManager.instance.ensureListRegistered( await this.context.slidingSyncManager.ensureListRegistered(
slidingSyncIndex, { slidingSyncIndex, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic], sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic],
}, },
); );
break; break;
case SortAlgorithm.Recent: case SortAlgorithm.Recent:
await SlidingSyncManager.instance.ensureListRegistered( await this.context.slidingSyncManager.ensureListRegistered(
slidingSyncIndex, { slidingSyncIndex, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Recent], sort: SlidingSyncSortToFilter[SortAlgorithm.Recent],
}, },
@ -174,10 +168,13 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> impl
// check all lists for each tag we know about and see if the room is there // check all lists for each tag we know about and see if the room is there
const tags: TagID[] = []; const tags: TagID[] = [];
for (const tagId in this.tagIdToSortAlgo) { for (const tagId in this.tagIdToSortAlgo) {
const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(index); const listData = this.context.slidingSyncManager.slidingSync.getListData(index);
for (const roomIndex in roomIndexToRoomId) { if (!listData) {
const roomId = roomIndexToRoomId[roomIndex]; continue;
}
for (const roomIndex in listData.roomIndexToRoomId) {
const roomId = listData.roomIndexToRoomId[roomIndex];
if (roomId === room.roomId) { if (roomId === room.roomId) {
tags.push(tagId); tags.push(tagId);
break; break;
@ -207,7 +204,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> impl
// this room will not move due to it being viewed: it is sticky. This can be null to indicate // 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. // 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; let stickyRoomNewIndex = -1;
const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room) => { const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room) => {
return room.roomId === this.stickyRoomId; return room.roomId === this.stickyRoomId;
@ -264,7 +261,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> impl
} }
private onSlidingSyncListUpdate(listIndex: number, joinCount: number, roomIndexToRoomId: Record<number, string>) { private onSlidingSyncListUpdate(listIndex: number, joinCount: number, roomIndexToRoomId: Record<number, string>) {
const tagId = SlidingSyncManager.instance.listIdForIndex(listIndex); const tagId = this.context.slidingSyncManager.listIdForIndex(listIndex);
this.counts[tagId]= joinCount; this.counts[tagId]= joinCount;
this.refreshOrderedLists(tagId, roomIndexToRoomId); this.refreshOrderedLists(tagId, roomIndexToRoomId);
// let the UI update // let the UI update
@ -273,7 +270,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> impl
private onRoomViewStoreUpdated() { private onRoomViewStoreUpdated() {
// we only care about this to know when the user has clicked on a room to set the stickiness value // 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; return;
} }
@ -296,14 +293,17 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> impl
if (room) { if (room) {
// resort it based on the slidingSync view of the list. This may cause this old sticky // resort it based on the slidingSync view of the list. This may cause this old sticky
// room to cease to exist. // room to cease to exist.
const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(index); const listData = this.context.slidingSyncManager.slidingSync.getListData(index);
this.refreshOrderedLists(tagId, roomIndexToRoomId); if (!listData) {
continue;
}
this.refreshOrderedLists(tagId, listData.roomIndexToRoomId);
hasUpdatedAnyList = true; hasUpdatedAnyList = true;
} }
} }
// in the event we didn't call refreshOrderedLists, it helps to still remember the sticky room ID. // 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) { if (hasUpdatedAnyList) {
this.emit(LISTS_UPDATE_EVENT); this.emit(LISTS_UPDATE_EVENT);
@ -313,11 +313,11 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> impl
protected async onReady(): Promise<any> { protected async onReady(): Promise<any> {
logger.info("SlidingRoomListStore.onReady"); logger.info("SlidingRoomListStore.onReady");
// permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation. // 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)); this.context.slidingSyncManager.slidingSync.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this));
SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); this.context.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this));
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this)); this.context.spaceStore.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this));
if (SpaceStore.instance.activeSpace) { if (this.context.spaceStore.activeSpace) {
this.onSelectedSpaceUpdated(SpaceStore.instance.activeSpace, false); this.onSelectedSpaceUpdated(this.context.spaceStore.activeSpace, false);
} }
// sliding sync has an initial response for spaces. Now request all the lists. // sliding sync has an initial response for spaces. Now request all the lists.
@ -332,8 +332,8 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> impl
const sort = SortAlgorithm.Recent; // default to recency sort, TODO: read from config const sort = SortAlgorithm.Recent; // default to recency sort, TODO: read from config
this.tagIdToSortAlgo[tagId] = sort; this.tagIdToSortAlgo[tagId] = sort;
this.emit(LISTS_LOADING_EVENT, tagId, true); this.emit(LISTS_LOADING_EVENT, tagId, true);
const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId); const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
SlidingSyncManager.instance.ensureListRegistered(index, { this.context.slidingSyncManager.ensureListRegistered(index, {
filters: filter, filters: filter,
sort: SlidingSyncSortToFilter[sort], sort: SlidingSyncSortToFilter[sort],
}).then(() => { }).then(() => {
@ -350,9 +350,18 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> impl
const oldSpace = filters.spaces?.[0]; const oldSpace = filters.spaces?.[0];
filters.spaces = (activeSpace && activeSpace != MetaSpace.Home) ? [activeSpace] : undefined; filters.spaces = (activeSpace && activeSpace != MetaSpace.Home) ? [activeSpace] : undefined;
if (oldSpace !== activeSpace) { 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); this.emit(LISTS_LOADING_EVENT, tagId, true);
SlidingSyncManager.instance.ensureListRegistered( const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
SlidingSyncManager.instance.getOrAllocateListIndex(tagId), this.context.slidingSyncManager.ensureListRegistered(
index,
{ {
filters: filters, filters: filters,
}, },

View file

@ -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 = <jest.Mock<SlidingSyncManager>><unknown>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<SpaceStoreClass>({
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));
});
});

View file

@ -21,6 +21,26 @@ import { MatrixClient, User } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../src/MatrixClientPeg";
/**
* Mocked generic class with a real EventEmitter.
* Useful for mocks which need event emitters.
*/
export class MockEventEmitter<T> 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<Record<MethodKeysOf<T>|PropertyKeysOf<T>, 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 * Mock client with real event emitter
* useful for testing code that listens * useful for testing code that listens