Remove SS code related to registering lists and managing ranges

- Update the spidering code to spider all the relevant lists.
- Add canonical alias to the required_state to allow room name calcs to work.

Room sort order is busted because we don't yet look at `bump_stamp`.
dbkr/sss
Kegan Dougal 2024-09-17 11:52:55 +01:00
parent be76852529
commit 0b142b496d
9 changed files with 150 additions and 1079 deletions

View File

@ -42,6 +42,7 @@ import PlatformPeg from "./PlatformPeg";
import { formatList } from "./utils/FormattingUtils";
import SdkConfig from "./SdkConfig";
import { Features } from "./settings/Settings";
import SlidingSyncController from "./settings/controllers/SlidingSyncController";
export interface IMatrixClientCreds {
homeserverUrl: string;
@ -298,10 +299,16 @@ class MatrixClientPegClass implements IMatrixClientPeg {
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
opts.threadSupport = true;
/* TODO: Uncomment before PR lands
if (SettingsStore.getValue("feature_sliding_sync")) {
opts.slidingSync = await SlidingSyncManager.instance.setup(this.matrixClient);
} else {
SlidingSyncManager.instance.checkSupport(this.matrixClient);
} */
// TODO: remove before PR lands. Defaults to SSS if the server entered supports it.
await SlidingSyncManager.instance.checkSupport(this.matrixClient);
if (SlidingSyncController.serverSupportsSlidingSync) {
opts.slidingSync = await SlidingSyncManager.instance.setup(this.matrixClient);
}
// Connect the matrix client to the dispatcher and setting handlers

View File

@ -40,10 +40,13 @@ import { MatrixClient, EventType, ClientEvent, Room } from "matrix-js-sdk/src/ma
import {
MSC3575Filter,
MSC3575List,
MSC3575SlidingSyncResponse,
MSC3575_STATE_KEY_LAZY,
MSC3575_STATE_KEY_ME,
MSC3575_WILDCARD,
SlidingSync,
SlidingSyncEvent,
SlidingSyncState,
} from "matrix-js-sdk/src/sliding-sync";
import { logger } from "matrix-js-sdk/src/logger";
import { defer, sleep } from "matrix-js-sdk/src/utils";
@ -53,20 +56,27 @@ import SlidingSyncController from "./settings/controllers/SlidingSyncController"
// how long to long poll for
const SLIDING_SYNC_TIMEOUT_MS = 20 * 1000;
// The state events we will get for every single room/space/old room/etc
// This list is only augmented when a direct room subscription is made. (e.g you view a room)
const REQUIRED_STATE_LIST = [
[EventType.RoomJoinRules, ""], // the public icon on the room list
[EventType.RoomAvatar, ""], // any room avatar
[EventType.RoomCanonicalAlias, ""], // for room name calculations
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.SpaceChild, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
];
// the things to fetch when a user clicks on a room
const DEFAULT_ROOM_SUBSCRIPTION_INFO = {
timeline_limit: 50,
// missing required_state which will change depending on the kind of room
include_old_rooms: {
timeline_limit: 0,
required_state: [
// state needed to handle space navigation and tombstone chains
[EventType.RoomCreate, ""],
[EventType.RoomTombstone, ""],
[EventType.SpaceChild, MSC3575_WILDCARD],
[EventType.SpaceParent, MSC3575_WILDCARD],
[EventType.RoomMember, MSC3575_STATE_KEY_ME],
],
required_state: REQUIRED_STATE_LIST,
},
};
// lazy load room members so rooms like Matrix HQ don't take forever to load
@ -74,7 +84,6 @@ const UNENCRYPTED_SUBSCRIPTION_NAME = "unencrypted";
const UNENCRYPTED_SUBSCRIPTION = Object.assign(
{
required_state: [
[MSC3575_WILDCARD, MSC3575_WILDCARD], // all events
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // except for m.room.members, get our own membership
[EventType.RoomMember, MSC3575_STATE_KEY_LAZY], // ...and lazy load the rest.
],
@ -93,6 +102,72 @@ const ENCRYPTED_SUBSCRIPTION = Object.assign(
DEFAULT_ROOM_SUBSCRIPTION_INFO,
);
// the complete set of lists made in SSS. The manager will spider all of these lists depending
// on the count for each one.
const sssLists: Record<string, MSC3575List> = {
spaces: {
ranges: [[0, 20]],
timeline_limit: 0, // we don't care about the most recent message for spaces
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
filters: {
room_types: ["m.space"],
},
},
invites: {
ranges: [[0, 20]],
timeline_limit: 1, // most recent message display
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
filters: {
is_invite: true,
},
},
favourites: {
ranges: [[0, 20]],
timeline_limit: 1, // most recent message display
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
filters: {
tags: ["m.favourite"],
},
},
dms: {
ranges: [[0, 20]],
timeline_limit: 1, // most recent message display
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
filters: {
is_dm: true,
is_invite: 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"],
},
},
untagged: {
// SSS will dupe suppress invites/dms from here, so we don't need "not dms, not invites"
ranges: [[0, 20]],
timeline_limit: 1, // most recent message display
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
},
};
export type PartialSlidingSyncRequest = {
filters?: MSC3575Filter;
sort?: string[];
@ -119,110 +194,27 @@ export class SlidingSyncManager {
return SlidingSyncManager.internalInstance;
}
public configure(client: MatrixClient, proxyUrl: string): SlidingSync {
private configure(client: MatrixClient, proxyUrl: string): SlidingSync {
this.client = client;
// create the set of lists we will use.
const lists = new Map();
for (let listName in sssLists) {
lists.set(listName, sssLists[listName]);
}
// by default use the encrypted subscription as that gets everything, which is a safer
// default than potentially missing member events.
this.slidingSync = new SlidingSync(
proxyUrl,
new Map(),
lists,
ENCRYPTED_SUBSCRIPTION,
client,
SLIDING_SYNC_TIMEOUT_MS,
);
this.slidingSync.addCustomSubscription(UNENCRYPTED_SUBSCRIPTION_NAME, UNENCRYPTED_SUBSCRIPTION);
// set the space list
this.slidingSync.setList(SlidingSyncManager.ListSpaces, {
ranges: [[0, 20]],
sort: ["by_name"],
slow_get_all_rooms: true,
timeline_limit: 0,
required_state: [
[EventType.RoomJoinRules, ""], // the public icon on the room list
[EventType.RoomAvatar, ""], // any room avatar
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.SpaceChild, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // 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, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
},
filters: {
room_types: ["m.space"],
},
});
this.configureDefer.resolve();
return this.slidingSync;
}
/**
* Ensure that this list is registered.
* @param listKey The list key to register
* @param updateArgs The fields to update on the list.
* @returns The complete list request params
*/
public async ensureListRegistered(listKey: string, updateArgs: PartialSlidingSyncRequest): Promise<MSC3575List> {
logger.debug("ensureListRegistered:::", listKey, updateArgs);
await this.configureDefer.promise;
let list = this.slidingSync!.getListParams(listKey);
if (!list) {
list = {
ranges: [[0, 20]],
sort: ["by_notification_level", "by_recency"],
timeline_limit: 1, // most recent message display: though this seems to only be needed for favourites?
required_state: [
[EventType.RoomJoinRules, ""], // the public icon on the room list
[EventType.RoomAvatar, ""], // any room avatar
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // 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, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
},
};
list = Object.assign(list, updateArgs);
} else {
const updatedList = Object.assign({}, list, updateArgs);
// cannot use objectHasDiff as we need to do deep diff checking
if (JSON.stringify(list) === JSON.stringify(updatedList)) {
logger.debug("list matches, not sending, update => ", updateArgs);
return list;
}
list = updatedList;
}
try {
// if we only have range changes then call a different function so we don't nuke the list from before
if (updateArgs.ranges && Object.keys(updateArgs).length === 1) {
this.slidingSync!.setListRanges(listKey, updateArgs.ranges);
} else {
this.slidingSync!.setList(listKey, list);
}
} catch (err) {
logger.debug("ensureListRegistered: update failed txn_id=", err);
}
return this.slidingSync!.getListParams(listKey)!;
}
public async setRoomVisible(roomId: string, visible: boolean): Promise<string> {
await this.configureDefer.promise;
const subscriptions = this.slidingSync!.getRoomSubscriptions();
@ -264,57 +256,57 @@ export class SlidingSyncManager {
}
/**
* Retrieve all rooms on the user's account. Used for pre-populating the local search cache.
* Retrieval is gradual over time.
* Retrieve all rooms on the user's account. Retrieval is gradual over time.
* This function MUST be called BEFORE the first sync request goes out.
* @param batchSize The number of rooms to return in each request.
* @param gapBetweenRequestsMs The number of milliseconds to wait between requests.
*/
public async startSpidering(batchSize: number, gapBetweenRequestsMs: number): Promise<void> {
await sleep(gapBetweenRequestsMs); // wait a bit as this is called on first render so let's let things load
let fetchUpTo = batchSize;
let hasMore = true;
let firstTime = true;
while (hasMore) {
try {
if (firstTime) {
await this.slidingSync!.setList(SlidingSyncManager.ListSearch, {
ranges: [[0, fetchUpTo]],
sort: [
"by_recency", // this list isn't shown on the UI so just sorting by timestamp is enough
],
timeline_limit: 0, // we only care about the room details, not messages in the room
required_state: [
[EventType.RoomJoinRules, ""], // the public icon on the room list
[EventType.RoomAvatar, ""], // any room avatar
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
// we don't include_old_rooms here in an effort to reduce the impact of spidering all rooms
// on the user's account. This means some data in the search dialog results may be inaccurate
// e.g membership of space, but this will be corrected when the user clicks on the room
// as the direct room subscription does include old room iterations.
filters: {
// we get spaces via a different list, so filter them out
not_room_types: ["m.space"],
},
});
} else {
await this.slidingSync!.setListRanges(SlidingSyncManager.ListSearch, [[0,fetchUpTo]]);
}
} catch (err) {
// do nothing, as we reject only when we get interrupted but that's fine as the next
// request will include our data
} finally {
// gradually request more over time, even on errors.
await sleep(gapBetweenRequestsMs);
private async startSpidering(slidingSync: SlidingSync, batchSize: number, gapBetweenRequestsMs: number): Promise<void> {
// The manager has created several lists (see `sssLists` in this file), all of which will be spidered simultaneously.
// There are multiple lists to ensure that we can populate invites/favourites/DMs sections immediately, rather than
// potentially waiting minutes if they are all very old rooms (and hence are returned last by the server). In this
// way, the lists are effectively priority requests. We don't actually care which room goes into which list at this
// point, as the RoomListStore will calculate this based on the returned data.
// copy the initial set of list names and ranges, we'll keep this map updated.
const listToUpperBound = new Map(Object.keys(sssLists).map((listName) => {
return [listName, sssLists[listName].ranges[0][1]];
}));
console.log("startSpidering:",listToUpperBound);
// listen for a response from the server. ANY 200 OK will do here, as we assume that it is ACKing
// the request change we have sent out. TODO: this may not be true if you concurrently subscribe to a room :/
// but in that case, for spidering at least, it isn't the end of the world as request N+1 includes all indexes
// from request N.
const lifecycle = async (state: SlidingSyncState, _: MSC3575SlidingSyncResponse | null, err?: Error) => {
if (state !== SlidingSyncState.Complete) {
return;
}
await sleep(gapBetweenRequestsMs); // don't tightloop; even on errors
if (err) {
return;
}
// for all lists with total counts > range => increase the range
let hasSetRanges = false;
listToUpperBound.forEach((currentUpperBound, listName) => {
const totalCount = slidingSync.getListData(listName)?.joinedCount || 0;
if (currentUpperBound < totalCount) {
// increment the upper bound
const newUpperBound = currentUpperBound + batchSize;
console.log(`startSpidering: ${listName} ${currentUpperBound} => ${newUpperBound}`);
listToUpperBound.set(listName, newUpperBound);
// make the next request. This will only send the request when this callback has finished, so if
// we set all the list ranges at once we will only send 1 new request.
slidingSync.setListRanges(listName, [[0,newUpperBound]]);
hasSetRanges = true;
}
})
if (!hasSetRanges) { // finish spidering
slidingSync.off(SlidingSyncEvent.Lifecycle, lifecycle);
}
const listData = this.slidingSync!.getListData(SlidingSyncManager.ListSearch)!;
hasMore = fetchUpTo < listData.joinedCount;
fetchUpTo += batchSize;
firstTime = false;
}
slidingSync.on(SlidingSyncEvent.Lifecycle, lifecycle);
}
/**
@ -327,10 +319,10 @@ export class SlidingSyncManager {
* @returns A working Sliding Sync or undefined
*/
public async setup(client: MatrixClient): Promise<SlidingSync | undefined> {
this.configure(client, client.baseUrl);
const slidingSync = this.configure(client, client.baseUrl);
logger.info("Simplified Sliding Sync activated at", client.baseUrl);
this.startSpidering(100, 50); // 100 rooms at a time, 50ms apart
return this.slidingSync;
this.startSpidering(slidingSync, 100, 50); // 100 rooms at a time, 50ms apart
return slidingSync;
}
/**

View File

@ -319,7 +319,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
public componentDidMount(): void {
console.log("componentDidMount");
if (this.props.manageReadReceipts) {
this.updateReadReceiptOnUserActivity();
}
@ -365,7 +364,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
public componentWillUnmount(): void {
console.log("componentWillUnmount");
// set a boolean to say we've been unmounted, which any pending
// promises can use to throw away their results.
//
@ -706,7 +704,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
removed: boolean,
data: IRoomTimelineData,
): void => {
console.log("onRoomTimeline ", ev, room, "toStart:",toStartOfTimeline, "removed:",removed,"data:",data);
// ignore events for other timeline sets
if (
data.timeline.getTimelineSet() !== this.props.timelineSet &&
@ -812,7 +809,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
private onRoomTimelineReset = (room: Room | undefined, timelineSet: EventTimelineSet): void => {
console.log("onRoomTimelineReset", room, timelineSet);
if (timelineSet !== this.props.timelineSet && timelineSet !== this.props.overlayTimelineSet) return;
if (this.canResetTimeline()) {
@ -1428,7 +1424,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
};
private initTimeline(props: IProps): void {
console.log("initTimeline");
const initialEvent = props.eventId;
const pixelOffset = props.eventPixelOffset;
@ -1538,7 +1533,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
* @param {boolean?} scrollIntoView whether to scroll the event into view.
*/
private loadTimeline(eventId?: string, pixelOffset?: number, offsetBase?: number, scrollIntoView = true): void {
console.log("loadTimeline");
const cli = MatrixClientPeg.safeGet();
this.timelineWindow = new TimelineWindow(cli, this.props.timelineSet, { windowLimit: this.props.timelineCap });
this.overlayTimelineWindow = this.props.overlayTimelineSet
@ -1547,7 +1541,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
const onLoaded = (): void => {
if (this.unmounted) return;
console.log("loadTimeline -> onLoaded");
// clear the timeline min-height when (re)loading the timeline
this.messagePanel.current?.onTimelineReset();
this.reloadEvents();
@ -1595,7 +1588,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
const onError = (error: MatrixError): void => {
if (this.unmounted) return;
console.log("loadTimeline -> onError", error);
this.setState({ timelineLoading: false });
logger.error(`Error loading timeline panel at ${this.props.timelineSet.room?.roomId}/${eventId}`, error);
@ -1650,7 +1642,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
onLoaded();
return;
}
console.log("calling timelineWindow.load");
const prom = this.timelineWindow.load(eventId, INITIAL_SIZE).then(async (): Promise<void> => {
if (this.overlayTimelineWindow) {
// TODO: use timestampToEvent to load the overlay timeline
@ -1691,7 +1682,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
// get the list of events from the timeline windows and the pending event list
private getEvents(): Pick<IState, "events" | "liveEvents"> {
console.log("getEvents");
const mainEvents = this.timelineWindow!.getEvents();
let overlayEvents = this.overlayTimelineWindow?.getEvents() ?? [];
if (this.props.overlayTimelineSetFilter !== undefined) {

View File

@ -329,12 +329,6 @@ export default class RoomSublist extends React.Component<IProps, IState> {
};
private onShowAllClick = async (): Promise<void> => {
if (this.slidingSyncMode) {
const count = RoomListStore.instance.getCount(this.props.tagId);
await SlidingSyncManager.instance.ensureListRegistered(this.props.tagId, {
ranges: [[0, count]],
});
}
// read number of visible tiles before we mutate it
const numVisibleTiles = this.numVisibleTiles;
const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding);

View File

@ -1,82 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useState } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { useLatestResult } from "./useLatestResult";
import { SlidingSyncManager } from "../SlidingSyncManager";
export interface SlidingSyncRoomSearchOpts {
limit: number;
query: string;
}
export const useSlidingSyncRoomSearch = (): {
loading: boolean;
rooms: Room[];
search(opts: SlidingSyncRoomSearchOpts): Promise<boolean>;
} => {
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(false);
const [updateQuery, updateResult] = useLatestResult<{ term: string; limit?: number }, Room[]>(setRooms);
const search = useCallback(
async ({ limit = 100, query: term }: SlidingSyncRoomSearchOpts): Promise<boolean> => {
const opts = { limit, term };
updateQuery(opts);
if (!term?.length) {
setRooms([]);
return true;
}
try {
setLoading(true);
await SlidingSyncManager.instance.ensureListRegistered(SlidingSyncManager.ListSearch, {
ranges: [[0, limit]],
filters: {
room_name_like: term,
},
});
const rooms: Room[] = [];
const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync!.getListData(
SlidingSyncManager.ListSearch,
)!;
let i = 0;
while (roomIndexToRoomId[i]) {
const roomId = roomIndexToRoomId[i];
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (room) {
rooms.push(room);
}
i++;
}
updateResult(opts, rooms);
return true;
} catch (e) {
console.error("Could not fetch sliding sync rooms for params", { limit, term }, e);
updateResult(opts, []);
return false;
} finally {
setLoading(false);
// TODO: delete the list?
}
},
[updateQuery, updateResult],
);
return {
loading,
rooms,
search,
} as const;
};

View File

@ -27,7 +27,6 @@ import { VisibilityProvider } from "./filters/VisibilityProvider";
import { SpaceWatcher } from "./SpaceWatcher";
import { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators";
import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface";
import { SlidingRoomListStoreClass } from "./SlidingRoomListStore";
import { UPDATE_EVENT } from "../AsyncStore";
import { SdkContextClass } from "../../contexts/SDKContext";
import { getChangedOverrideRoomMutePushRules } from "./utils/roomMute";
@ -640,16 +639,9 @@ export default class RoomListStore {
public static get instance(): Interface {
if (!RoomListStore.internalInstance) {
if (SettingsStore.getValue("feature_sliding_sync")) {
logger.info("using SlidingRoomListStoreClass");
const instance = new SlidingRoomListStoreClass(defaultDispatcher, SdkContextClass.instance);
instance.start();
RoomListStore.internalInstance = instance;
} else {
const instance = new RoomListStoreClass(defaultDispatcher);
instance.start();
RoomListStore.internalInstance = instance;
}
const instance = new RoomListStoreClass(defaultDispatcher);
instance.start();
RoomListStore.internalInstance = instance;
}
return this.internalInstance;

View File

@ -1,396 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { MSC3575Filter, SlidingSyncEvent } from "matrix-js-sdk/src/sliding-sync";
import { Optional } from "matrix-events-sdk";
import { RoomUpdateCause, TagID, OrderedDefaultTagIDs, DefaultTagID } from "./models";
import { ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
import { ActionPayload } from "../../dispatcher/payloads";
import { MatrixDispatcher } from "../../dispatcher/dispatcher";
import { IFilterCondition } from "./filters/IFilterCondition";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface";
import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces";
import { LISTS_LOADING_EVENT } from "./RoomListStore";
import { UPDATE_EVENT } from "../AsyncStore";
import { SdkContextClass } from "../../contexts/SDKContext";
interface IState {
// state is tracked in underlying classes
}
export const SlidingSyncSortToFilter: Record<SortAlgorithm, string[]> = {
[SortAlgorithm.Alphabetic]: ["by_name", "by_recency"],
[SortAlgorithm.Recent]: ["by_notification_level", "by_recency"],
[SortAlgorithm.Manual]: ["by_recency"],
};
const filterConditions: Record<TagID, MSC3575Filter> = {
[DefaultTagID.Invite]: {
is_invite: true,
},
[DefaultTagID.Favourite]: {
tags: ["m.favourite"],
},
[DefaultTagID.DM]: {
is_dm: true,
is_invite: 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,
not_room_types: ["m.space"],
not_tags: ["m.favourite", "m.lowpriority"],
// spaces filter added dynamically
},
[DefaultTagID.LowPriority]: {
tags: ["m.lowpriority"],
// If a room has both Favourite & Low Prio tags then it'll be shown under Favourites
not_tags: ["m.favourite"],
},
// TODO https://github.com/vector-im/element-web/issues/23207
// DefaultTagID.ServerNotice,
// DefaultTagID.Suggested,
// DefaultTagID.Archived,
};
export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate;
export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> implements Interface {
private tagIdToSortAlgo: Record<TagID, SortAlgorithm> = {};
private tagMap: ITagMap = {};
private counts: Record<TagID, number> = {};
private stickyRoomId: Optional<string>;
public constructor(
dis: MatrixDispatcher,
private readonly context: SdkContextClass,
) {
super(dis);
this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares
}
public async setTagSorting(tagId: TagID, sort: SortAlgorithm): Promise<void> {
logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort);
this.tagIdToSortAlgo[tagId] = sort;
switch (sort) {
case SortAlgorithm.Alphabetic:
await this.context.slidingSyncManager.ensureListRegistered(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic],
});
break;
case SortAlgorithm.Recent:
await this.context.slidingSyncManager.ensureListRegistered(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Recent],
});
break;
case SortAlgorithm.Manual:
logger.error("cannot enable manual sort in sliding sync mode");
break;
default:
logger.error("unknown sort mode: ", sort);
}
}
public getTagSorting(tagId: TagID): SortAlgorithm {
let algo = this.tagIdToSortAlgo[tagId];
if (!algo) {
logger.warn("SlidingRoomListStore.getTagSorting: no sort algorithm for tag ", tagId);
algo = SortAlgorithm.Recent; // why not, we have to do something..
}
return algo;
}
public getCount(tagId: TagID): number {
return this.counts[tagId] || 0;
}
public setListOrder(tagId: TagID, order: ListAlgorithm): void {
// TODO: https://github.com/vector-im/element-web/issues/23207
}
public getListOrder(tagId: TagID): ListAlgorithm {
// TODO: handle unread msgs first? https://github.com/vector-im/element-web/issues/23207
return ListAlgorithm.Natural;
}
/**
* Adds a filter condition to the room list store. Filters may be applied async,
* and thus might not cause an update to the store immediately.
* @param {IFilterCondition} filter The filter condition to add.
*/
public async addFilter(filter: IFilterCondition): Promise<void> {
// Do nothing, the filters are only used by SpaceWatcher to see if a room should appear
// in the room list. We do not support arbitrary code for filters in sliding sync.
}
/**
* Removes a filter condition from the room list store. If the filter was
* not previously added to the room list store, this will no-op. The effects
* of removing a filter may be applied async and therefore might not cause
* an update right away.
* @param {IFilterCondition} filter The filter condition to remove.
*/
public removeFilter(filter: IFilterCondition): void {
// Do nothing, the filters are only used by SpaceWatcher to see if a room should appear
// in the room list. We do not support arbitrary code for filters in sliding sync.
}
/**
* Gets the tags for a room identified by the store. The returned set
* should never be empty, and will contain DefaultTagID.Untagged if
* the store is not aware of any tags.
* @param room The room to get the tags for.
* @returns The tags for the room.
*/
public getTagsForRoom(room: Room): TagID[] {
// 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 listData = this.context.slidingSyncManager.slidingSync?.getListData(tagId);
if (!listData) {
continue;
}
for (const roomIndex in listData.roomIndexToRoomId) {
const roomId = listData.roomIndexToRoomId[roomIndex];
if (roomId === room.roomId) {
tags.push(tagId);
break;
}
}
}
return tags;
}
/**
* Manually update a room with a given cause. This should only be used if the
* room list store would otherwise be incapable of doing the update itself. Note
* that this may race with the room list's regular operation.
* @param {Room} room The room to update.
* @param {RoomUpdateCause} cause The cause to update for.
*/
public async manualRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<void> {
// TODO: this is only used when you forget a room, not that important for now.
}
public get orderedLists(): ITagMap {
return this.tagMap;
}
private refreshOrderedLists(tagId: string, roomIndexToRoomId: Record<number, string>): void {
const tagMap = this.tagMap;
// 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 = this.context.roomViewStore.getRoomId();
let stickyRoomNewIndex = -1;
const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room): boolean => {
return room.roomId === this.stickyRoomId;
});
// order from low to high
const orderedRoomIndexes = Object.keys(roomIndexToRoomId)
.map((numStr) => {
return Number(numStr);
})
.sort((a, b) => {
return a - b;
});
const seenRoomIds = new Set<string>();
const orderedRoomIds = orderedRoomIndexes.map((i) => {
const rid = roomIndexToRoomId[i];
if (seenRoomIds.has(rid)) {
logger.error("room " + rid + " already has an index position: duplicate room!");
}
seenRoomIds.add(rid);
if (!rid) {
throw new Error("index " + i + " has no room ID: Map => " + JSON.stringify(roomIndexToRoomId));
}
if (rid === this.stickyRoomId) {
stickyRoomNewIndex = i;
}
return rid;
});
logger.debug(
`SlidingRoomListStore.refreshOrderedLists ${tagId} sticky: ${this.stickyRoomId}`,
`${stickyRoomOldIndex} -> ${stickyRoomNewIndex}`,
"rooms:",
orderedRoomIds.length < 30 ? orderedRoomIds : orderedRoomIds.length,
);
if (this.stickyRoomId && stickyRoomOldIndex >= 0 && stickyRoomNewIndex >= 0) {
// this update will move this sticky room from old to new, which we do not want.
// Instead, keep the sticky room ID index position as it is, swap it with
// whatever was in its place.
// Some scenarios with sticky room S and bump room B (other letters unimportant):
// A, S, C, B S, A, B
// B, A, S, C <---- without sticky rooms ---> B, S, A
// B, S, A, C <- with sticky rooms applied -> S, B, A
// In other words, we need to swap positions to keep it locked in place.
const inWayRoomId = orderedRoomIds[stickyRoomOldIndex];
orderedRoomIds[stickyRoomOldIndex] = this.stickyRoomId;
orderedRoomIds[stickyRoomNewIndex] = inWayRoomId;
}
// now set the rooms
const rooms: Room[] = [];
orderedRoomIds.forEach((roomId) => {
const room = this.matrixClient?.getRoom(roomId);
if (!room) {
return;
}
rooms.push(room);
});
tagMap[tagId] = rooms;
this.tagMap = tagMap;
}
private onSlidingSyncListUpdate(tagId: string, joinCount: number, roomIndexToRoomId: Record<number, string>): void {
this.counts[tagId] = joinCount;
this.refreshOrderedLists(tagId, roomIndexToRoomId);
// let the UI update
this.emit(LISTS_UPDATE_EVENT);
}
private onRoomViewStoreUpdated(): void {
// we only care about this to know when the user has clicked on a room to set the stickiness value
if (this.context.roomViewStore.getRoomId() === this.stickyRoomId) {
return;
}
let hasUpdatedAnyList = false;
// every list with the OLD sticky room ID needs to be resorted because it now needs to take
// its proper place as it is no longer sticky. The newly sticky room can remain the same though,
// as we only actually care about its sticky status when we get list updates.
const oldStickyRoom = this.stickyRoomId;
// it's not safe to check the data in slidingSync as it is tracking the server's view of the
// room list. There's an edge case whereby the sticky room has gone outside the window and so
// would not be present in the roomIndexToRoomId map anymore, and hence clicking away from it
// will make it disappear eventually. We need to check orderedLists as that is the actual
// sorted renderable list of rooms which sticky rooms apply to.
for (const tagId in this.orderedLists) {
const list = this.orderedLists[tagId];
const room = list.find((room) => {
return room.roomId === oldStickyRoom;
});
if (room) {
// resort it based on the slidingSync view of the list. This may cause this old sticky
// room to cease to exist.
const listData = this.context.slidingSyncManager.slidingSync?.getListData(tagId);
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 = this.context.roomViewStore.getRoomId();
if (hasUpdatedAnyList) {
this.emit(LISTS_UPDATE_EVENT);
}
}
protected async onReady(): Promise<any> {
logger.info("SlidingRoomListStore.onReady");
// permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation.
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.
// We do the spaces list _first_ to avoid potential flickering on DefaultTagID.Untagged list
// which would be caused by initially having no `spaces` filter set, and then suddenly setting one.
OrderedDefaultTagIDs.forEach((tagId) => {
const filter = filterConditions[tagId];
if (!filter) {
logger.info("SlidingRoomListStore.onReady unsupported list ", tagId);
return; // we do not support this list yet.
}
const sort = SortAlgorithm.Recent; // default to recency sort, TODO: read from config
this.tagIdToSortAlgo[tagId] = sort;
this.emit(LISTS_LOADING_EVENT, tagId, true);
this.context.slidingSyncManager
.ensureListRegistered(tagId, {
filters: filter,
sort: SlidingSyncSortToFilter[sort],
})
.then(() => {
this.emit(LISTS_LOADING_EVENT, tagId, false);
});
});
}
private onSelectedSpaceUpdated = (activeSpace: SpaceKey, allRoomsInHome: boolean): void => {
logger.info("TODO: Synapse does not implement filters.spaces yet. SlidingRoomListStore.onSelectedSpaceUpdated", activeSpace);
return;
// update the untagged filter
const tagId = DefaultTagID.Untagged;
const filters = filterConditions[tagId];
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;
}
if (!filters.spaces) {
filters.spaces = [];
}
filters.spaces.push(roomId); // add subspace
},
false,
);
this.emit(LISTS_LOADING_EVENT, tagId, true);
this.context.slidingSyncManager
.ensureListRegistered(tagId, {
filters: filters,
})
.then(() => {
this.emit(LISTS_LOADING_EVENT, tagId, false);
});
}
};
// Intended for test usage
public async resetStore(): Promise<void> {
// Test function
}
/**
* Regenerates the room whole room list, discarding any previous results.
*
* Note: This is only exposed externally for the tests. Do not call this from within
* the app.
* @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
public regenerateAllLists({ trigger = true }): void {
// Test function
}
protected async onNotReady(): Promise<any> {
await this.resetStore();
}
protected async onAction(payload: ActionPayload): Promise<void> {}
}

View File

@ -1,85 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { waitFor } from "@testing-library/react";
import { renderHook, act } from "@testing-library/react-hooks/dom";
import { mocked } from "jest-mock";
import { SlidingSync } from "matrix-js-sdk/src/sliding-sync";
import { Room } from "matrix-js-sdk/src/matrix";
import { useSlidingSyncRoomSearch } from "../../src/hooks/useSlidingSyncRoomSearch";
import { MockEventEmitter, stubClient } from "../test-utils";
import { SlidingSyncManager } from "../../src/SlidingSyncManager";
describe("useSlidingSyncRoomSearch", () => {
afterAll(() => {
jest.restoreAllMocks();
});
it("should display rooms when searching", async () => {
const client = stubClient();
const roomA = new Room("!a:localhost", client, client.getUserId()!);
const roomB = new Room("!b:localhost", client, client.getUserId()!);
const slidingSync = mocked(
new MockEventEmitter({
getListData: jest.fn(),
}) as unknown as SlidingSync,
);
jest.spyOn(SlidingSyncManager.instance, "ensureListRegistered").mockResolvedValue({
ranges: [[0, 9]],
});
SlidingSyncManager.instance.slidingSync = slidingSync;
mocked(slidingSync.getListData).mockReturnValue({
joinedCount: 2,
roomIndexToRoomId: {
0: roomA.roomId,
1: roomB.roomId,
},
});
mocked(client.getRoom).mockImplementation((roomId) => {
switch (roomId) {
case roomA.roomId:
return roomA;
case roomB.roomId:
return roomB;
default:
return null;
}
});
// first check that everything is empty
const { result } = renderHook(() => useSlidingSyncRoomSearch());
const query = {
limit: 10,
query: "foo",
};
expect(result.current.loading).toBe(false);
expect(result.current.rooms).toEqual([]);
// run the query
act(() => {
result.current.search(query);
});
// wait for loading to finish
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// now we expect there to be rooms
expect(result.current.rooms).toEqual([roomA, roomB]);
// run the query again
act(() => {
result.current.search(query);
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
});
});

View File

@ -1,341 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
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 { MetaSpace, 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;
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,
);
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(DefaultTagID.Untagged, {
filters: expect.objectContaining({
spaces: [spaceRoomId],
}),
});
});
it("gracefully handles subspaces in the home metaspace", async () => {
const subspace = "!sub:space";
mocked(context._SpaceStore!.traverseSpace).mockImplementation(
(spaceId: string, fn: (roomId: string) => void) => {
fn(subspace);
},
);
activeSpace = MetaSpace.Home;
await store.start(); // call onReady
expect(context._SlidingSyncManager!.ensureListRegistered).toHaveBeenCalledWith(DefaultTagID.Untagged, {
filters: expect.objectContaining({
spaces: [subspace],
}),
});
});
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(
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(DefaultTagID.Untagged, {
filters: expect.objectContaining({
spaces: [spaceRoomId, subSpace1, subSpace2],
}),
});
});
});
it("setTagSorting alters the 'sort' option in the list", async () => {
const tagId: TagID = "foo";
await store.setTagSorting(tagId, SortAlgorithm.Alphabetic);
expect(context._SlidingSyncManager!.ensureListRegistered).toHaveBeenCalledWith(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic],
});
expect(store.getTagSorting(tagId)).toEqual(SortAlgorithm.Alphabetic);
await store.setTagSorting(tagId, SortAlgorithm.Recent);
expect(context._SlidingSyncManager!.ensureListRegistered).toHaveBeenCalledWith(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Recent],
});
expect(store.getTagSorting(tagId)).toEqual(SortAlgorithm.Recent);
});
it("getTagsForRoom gets the tags for the room", async () => {
await store.start();
const roomA = "!a:localhost";
const roomB = "!b:localhost";
const keyToListData: Record<string, { joinedCount: number; roomIndexToRoomId: Record<number, string> }> = {
[DefaultTagID.Untagged]: {
joinedCount: 10,
roomIndexToRoomId: {
0: roomA,
1: roomB,
},
},
[DefaultTagID.Favourite]: {
joinedCount: 2,
roomIndexToRoomId: {
0: roomB,
},
},
};
mocked(context._SlidingSyncManager!.slidingSync!.getListData).mockImplementation((key: string) => {
return keyToListData[key] || 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 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, tagId, 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 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((key: string) => {
if (key !== tagId) {
return null;
}
return {
roomIndexToRoomId: roomIndexToRoomId,
joinedCount: joinCount,
};
});
let p = untilEmission(store, LISTS_UPDATE_EVENT);
context.slidingSyncManager.slidingSync!.emit(SlidingSyncEvent.List, tagId, 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, tagId, 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));
});
it("gracefully handles unknown room IDs", async () => {
await store.start();
const roomIdA = "!a:localhost";
const roomIdB = "!b:localhost"; // does not exist
const roomIdC = "!c:localhost";
const roomIndexToRoomId = {
0: roomIdA,
1: roomIdB, // does not exist
2: roomIdC,
};
const tagId = DefaultTagID.Favourite;
const joinCount = 10;
// seed the store with 2 rooms
const roomA = new Room(roomIdA, 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 roomIdC:
return roomC;
}
return null;
});
mocked(context._SlidingSyncManager!.slidingSync!.getListData).mockImplementation((key: string) => {
if (key !== tagId) {
return null;
}
return {
roomIndexToRoomId: roomIndexToRoomId,
joinedCount: joinCount,
};
});
const p = untilEmission(store, LISTS_UPDATE_EVENT);
context.slidingSyncManager.slidingSync!.emit(SlidingSyncEvent.List, tagId, joinCount, roomIndexToRoomId);
await p;
expect(store.orderedLists[tagId]).toEqual([roomA, roomC]);
});
});