Merge pull request #9934 from matrix-org/kegan/lists-as-keys

refactor: sliding sync: convert to lists-as-keys rather than indexes
This commit is contained in:
Andy Balaam 2023-01-23 15:26:42 +00:00 committed by GitHub
commit 51b4555106
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 329 additions and 178 deletions

View File

@ -30,8 +30,8 @@ export default defineConfig({
specPattern: "cypress/e2e/**/*.{js,jsx,ts,tsx}", specPattern: "cypress/e2e/**/*.{js,jsx,ts,tsx}",
}, },
env: { env: {
// Docker tag to use for `ghcr.io/matrix-org/sliding-sync-proxy` image. // Docker tag to use for `ghcr.io/matrix-org/sliding-sync` image.
SLIDING_SYNC_PROXY_TAG: "v0.6.0", SLIDING_SYNC_PROXY_TAG: "v0.99.0-rc1",
HOMESERVER: "synapse", HOMESERVER: "synapse",
}, },
retries: { retries: {

View File

@ -21,8 +21,6 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { Interception } from "cypress/types/net-stubbing"; import { Interception } from "cypress/types/net-stubbing";
import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { HomeserverInstance } from "../../plugins/utils/homeserver";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import { ProxyInstance } from "../../plugins/sliding-sync"; import { ProxyInstance } from "../../plugins/sliding-sync";
describe("Sliding Sync", () => { describe("Sliding Sync", () => {
@ -102,21 +100,6 @@ describe("Sliding Sync", () => {
}); });
}; };
// sanity check everything works
it("should correctly render expected messages", () => {
cy.get<string>("@roomId").then((roomId) => cy.visit("/#/room/" + roomId));
cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
// Wait until configuration is finished
cy.contains(
".mx_RoomView_body .mx_GenericEventListSummary .mx_GenericEventListSummary_summary",
"created and configured the room.",
);
// Click "expand" link button
cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=false]").click();
});
it("should render the Rooms list in reverse chronological order by default and allowing sorting A-Z", () => { it("should render the Rooms list in reverse chronological order by default and allowing sorting A-Z", () => {
// create rooms and check room names are correct // create rooms and check room names are correct
cy.createRoom({ name: "Apple" }).then(() => cy.contains(".mx_RoomSublist", "Apple")); cy.createRoom({ name: "Apple" }).then(() => cy.contains(".mx_RoomSublist", "Apple"));

View File

@ -23,7 +23,7 @@ import { getFreePort } from "../utils/port";
import { HomeserverInstance } from "../utils/homeserver"; import { HomeserverInstance } from "../utils/homeserver";
// A cypress plugin to add command to start & stop https://github.com/matrix-org/sliding-sync // A cypress plugin to add command to start & stop https://github.com/matrix-org/sliding-sync
// SLIDING_SYNC_PROXY_TAG env used as the docker tag to use for `ghcr.io/matrix-org/sliding-sync-proxy` image. // SLIDING_SYNC_PROXY_TAG env used as the docker tag to use for `ghcr.io/matrix-org/sliding-sync` image.
export interface ProxyInstance { export interface ProxyInstance {
containerId: string; containerId: string;
@ -72,7 +72,7 @@ async function proxyStart(dockerTag: string, homeserver: HomeserverInstance): Pr
const port = await getFreePort(); const port = await getFreePort();
console.log(new Date(), "starting proxy container...", dockerTag); console.log(new Date(), "starting proxy container...", dockerTag);
const containerId = await dockerRun({ const containerId = await dockerRun({
image: "ghcr.io/matrix-org/sliding-sync-proxy:" + dockerTag, image: "ghcr.io/matrix-org/sliding-sync:" + dockerTag,
containerName: "react-sdk-cypress-sliding-sync-proxy", containerName: "react-sdk-cypress-sliding-sync-proxy",
params: [ params: [
"--rm", "--rm",

View File

@ -119,12 +119,10 @@ export class SlidingSyncManager {
public slidingSync: SlidingSync; public slidingSync: SlidingSync;
private client: MatrixClient; private client: MatrixClient;
private listIdToIndex: Record<string, number>;
private configureDefer: IDeferred<void>; private configureDefer: IDeferred<void>;
public constructor() { public constructor() {
this.listIdToIndex = {};
this.configureDefer = defer<void>(); this.configureDefer = defer<void>();
} }
@ -134,13 +132,18 @@ export class SlidingSyncManager {
public configure(client: MatrixClient, proxyUrl: string): SlidingSync { public configure(client: MatrixClient, proxyUrl: string): SlidingSync {
this.client = client; this.client = client;
this.listIdToIndex = {};
// by default use the encrypted subscription as that gets everything, which is a safer // by default use the encrypted subscription as that gets everything, which is a safer
// default than potentially missing member events. // default than potentially missing member events.
this.slidingSync = new SlidingSync(proxyUrl, [], ENCRYPTED_SUBSCRIPTION, client, SLIDING_SYNC_TIMEOUT_MS); this.slidingSync = new SlidingSync(
proxyUrl,
new Map(),
ENCRYPTED_SUBSCRIPTION,
client,
SLIDING_SYNC_TIMEOUT_MS,
);
this.slidingSync.addCustomSubscription(UNENCRYPTED_SUBSCRIPTION_NAME, UNENCRYPTED_SUBSCRIPTION); this.slidingSync.addCustomSubscription(UNENCRYPTED_SUBSCRIPTION_NAME, UNENCRYPTED_SUBSCRIPTION);
// set the space list // set the space list
this.slidingSync.setList(this.getOrAllocateListIndex(SlidingSyncManager.ListSpaces), { this.slidingSync.setList(SlidingSyncManager.ListSpaces, {
ranges: [[0, 20]], ranges: [[0, 20]],
sort: ["by_name"], sort: ["by_name"],
slow_get_all_rooms: true, slow_get_all_rooms: true,
@ -173,47 +176,16 @@ export class SlidingSyncManager {
return this.slidingSync; return this.slidingSync;
} }
public listIdForIndex(index: number): string | null {
for (const listId in this.listIdToIndex) {
if (this.listIdToIndex[listId] === index) {
return listId;
}
}
return null;
}
/**
* Allocate or retrieve the list index for an arbitrary list ID. For example SlidingSyncManager.ListSpaces
* @param listId A string which represents the list.
* @returns The index to use when registering lists or listening for callbacks.
*/
public getOrAllocateListIndex(listId: string): number {
let index = this.listIdToIndex[listId];
if (index === undefined) {
// assign next highest index
index = -1;
for (const id in this.listIdToIndex) {
const listIndex = this.listIdToIndex[id];
if (listIndex > index) {
index = listIndex;
}
}
index++;
this.listIdToIndex[listId] = index;
}
return index;
}
/** /**
* Ensure that this list is registered. * Ensure that this list is registered.
* @param listIndex The list index to register * @param listKey The list key to register
* @param updateArgs The fields to update on the list. * @param updateArgs The fields to update on the list.
* @returns The complete list request params * @returns The complete list request params
*/ */
public async ensureListRegistered(listIndex: number, updateArgs: PartialSlidingSyncRequest): Promise<MSC3575List> { public async ensureListRegistered(listKey: string, updateArgs: PartialSlidingSyncRequest): Promise<MSC3575List> {
logger.debug("ensureListRegistered:::", listIndex, updateArgs); logger.debug("ensureListRegistered:::", listKey, updateArgs);
await this.configureDefer.promise; await this.configureDefer.promise;
let list = this.slidingSync.getList(listIndex); let list = this.slidingSync.getListParams(listKey);
if (!list) { if (!list) {
list = { list = {
ranges: [[0, 20]], ranges: [[0, 20]],
@ -252,14 +224,14 @@ export class SlidingSyncManager {
try { try {
// if we only have range changes then call a different function so we don't nuke the list from before // 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) { if (updateArgs.ranges && Object.keys(updateArgs).length === 1) {
await this.slidingSync.setListRanges(listIndex, updateArgs.ranges); await this.slidingSync.setListRanges(listKey, updateArgs.ranges);
} else { } else {
await this.slidingSync.setList(listIndex, list); await this.slidingSync.setList(listKey, list);
} }
} catch (err) { } catch (err) {
logger.debug("ensureListRegistered: update failed txn_id=", err); logger.debug("ensureListRegistered: update failed txn_id=", err);
} }
return this.slidingSync.getList(listIndex); return this.slidingSync.getListParams(listKey)!;
} }
public async setRoomVisible(roomId: string, visible: boolean): Promise<string> { public async setRoomVisible(roomId: string, visible: boolean): Promise<string> {
@ -304,7 +276,6 @@ export class SlidingSyncManager {
*/ */
public async startSpidering(batchSize: number, gapBetweenRequestsMs: number): Promise<void> { 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 await sleep(gapBetweenRequestsMs); // wait a bit as this is called on first render so let's let things load
const listIndex = this.getOrAllocateListIndex(SlidingSyncManager.ListSearch);
let startIndex = batchSize; let startIndex = batchSize;
let hasMore = true; let hasMore = true;
let firstTime = true; let firstTime = true;
@ -316,7 +287,7 @@ export class SlidingSyncManager {
[startIndex, endIndex], [startIndex, endIndex],
]; ];
if (firstTime) { if (firstTime) {
await this.slidingSync.setList(listIndex, { await this.slidingSync.setList(SlidingSyncManager.ListSearch, {
// e.g [0,19] [20,39] then [0,19] [40,59]. We keep [0,20] constantly to ensure // e.g [0,19] [20,39] then [0,19] [40,59]. We keep [0,20] constantly to ensure
// any changes to the list whilst spidering are caught. // any changes to the list whilst spidering are caught.
ranges: ranges, ranges: ranges,
@ -342,15 +313,17 @@ export class SlidingSyncManager {
}, },
}); });
} else { } else {
await this.slidingSync.setListRanges(listIndex, ranges); await this.slidingSync.setListRanges(SlidingSyncManager.ListSearch, ranges);
} }
// gradually request more over time
await sleep(gapBetweenRequestsMs);
} catch (err) { } catch (err) {
// do nothing, as we reject only when we get interrupted but that's fine as the next // do nothing, as we reject only when we get interrupted but that's fine as the next
// request will include our data // request will include our data
} finally {
// gradually request more over time, even on errors.
await sleep(gapBetweenRequestsMs);
} }
hasMore = endIndex + 1 < this.slidingSync.getListData(listIndex)?.joinedCount; const listData = this.slidingSync.getListData(SlidingSyncManager.ListSearch)!;
hasMore = endIndex + 1 < listData.joinedCount;
startIndex += batchSize; startIndex += batchSize;
firstTime = false; firstTime = false;
} }

View File

@ -340,9 +340,8 @@ export default class RoomSublist extends React.Component<IProps, IState> {
private onShowAllClick = async (): Promise<void> => { private onShowAllClick = async (): Promise<void> => {
if (this.slidingSyncMode) { if (this.slidingSyncMode) {
const slidingSyncIndex = SlidingSyncManager.instance.getOrAllocateListIndex(this.props.tagId);
const count = RoomListStore.instance.getCount(this.props.tagId); const count = RoomListStore.instance.getCount(this.props.tagId);
await SlidingSyncManager.instance.ensureListRegistered(slidingSyncIndex, { await SlidingSyncManager.instance.ensureListRegistered(this.props.tagId, {
ranges: [[0, count]], ranges: [[0, count]],
}); });
} }
@ -566,10 +565,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
let isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; let isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
let isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; let isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
if (this.slidingSyncMode) { if (this.slidingSyncMode) {
const slidingSyncIndex = SlidingSyncManager.instance.getOrAllocateListIndex(this.props.tagId); const slidingList = SlidingSyncManager.instance.slidingSync.getListParams(this.props.tagId);
const slidingList = SlidingSyncManager.instance.slidingSync.getList(slidingSyncIndex); isAlphabetical = (slidingList?.sort || [])[0] === "by_name";
isAlphabetical = slidingList.sort[0] === "by_name"; isUnreadFirst = (slidingList?.sort || [])[0] === "by_notification_level";
isUnreadFirst = slidingList.sort[0] === "by_notification_level";
} }
// Invites don't get some nonsense options, so only add them if we have to. // Invites don't get some nonsense options, so only add them if we have to.

View File

@ -34,7 +34,6 @@ export const useSlidingSyncRoomSearch = (): {
const [rooms, setRooms] = useState<Room[]>([]); const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const listIndex = SlidingSyncManager.instance.getOrAllocateListIndex(SlidingSyncManager.ListSearch);
const [updateQuery, updateResult] = useLatestResult<{ term: string; limit?: number }, Room[]>(setRooms); const [updateQuery, updateResult] = useLatestResult<{ term: string; limit?: number }, Room[]>(setRooms);
@ -50,14 +49,16 @@ export const useSlidingSyncRoomSearch = (): {
try { try {
setLoading(true); setLoading(true);
await SlidingSyncManager.instance.ensureListRegistered(listIndex, { await SlidingSyncManager.instance.ensureListRegistered(SlidingSyncManager.ListSearch, {
ranges: [[0, limit]], ranges: [[0, limit]],
filters: { filters: {
room_name_like: term, room_name_like: term,
}, },
}); });
const rooms = []; const rooms = [];
const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(listIndex); const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(
SlidingSyncManager.ListSearch,
)!;
let i = 0; let i = 0;
while (roomIndexToRoomId[i]) { while (roomIndexToRoomId[i]) {
const roomId = roomIndexToRoomId[i]; const roomId = roomIndexToRoomId[i];
@ -78,7 +79,7 @@ export const useSlidingSyncRoomSearch = (): {
// TODO: delete the list? // TODO: delete the list?
} }
}, },
[updateQuery, updateResult, listIndex], [updateQuery, updateResult],
); );
return { return {

View File

@ -84,20 +84,20 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> impl
public constructor(dis: MatrixDispatcher, private readonly context: SdkContextClass) { public constructor(dis: MatrixDispatcher, private readonly context: SdkContextClass) {
super(dis); super(dis);
this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares
this.stickyRoomId = null;
} }
public async setTagSorting(tagId: TagID, sort: SortAlgorithm): Promise<void> { public async setTagSorting(tagId: TagID, sort: SortAlgorithm): Promise<void> {
logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort); logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort);
this.tagIdToSortAlgo[tagId] = sort; this.tagIdToSortAlgo[tagId] = sort;
const slidingSyncIndex = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
switch (sort) { switch (sort) {
case SortAlgorithm.Alphabetic: case SortAlgorithm.Alphabetic:
await this.context.slidingSyncManager.ensureListRegistered(slidingSyncIndex, { await this.context.slidingSyncManager.ensureListRegistered(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic], sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic],
}); });
break; break;
case SortAlgorithm.Recent: case SortAlgorithm.Recent:
await this.context.slidingSyncManager.ensureListRegistered(slidingSyncIndex, { await this.context.slidingSyncManager.ensureListRegistered(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Recent], sort: SlidingSyncSortToFilter[SortAlgorithm.Recent],
}); });
break; break;
@ -164,8 +164,7 @@ 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 = this.context.slidingSyncManager.getOrAllocateListIndex(tagId); const listData = this.context.slidingSyncManager.slidingSync.getListData(tagId);
const listData = this.context.slidingSyncManager.slidingSync.getListData(index);
if (!listData) { if (!listData) {
continue; continue;
} }
@ -251,19 +250,19 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> impl
} }
// now set the rooms // now set the rooms
const rooms = orderedRoomIds.map((roomId) => { const rooms: Room[] = [];
return this.matrixClient.getRoom(roomId); orderedRoomIds.forEach((roomId) => {
const room = this.matrixClient.getRoom(roomId);
if (!room) {
return;
}
rooms.push(room);
}); });
tagMap[tagId] = rooms; tagMap[tagId] = rooms;
this.tagMap = tagMap; this.tagMap = tagMap;
} }
private onSlidingSyncListUpdate( private onSlidingSyncListUpdate(tagId: string, joinCount: number, roomIndexToRoomId: Record<number, string>): void {
listIndex: number,
joinCount: number,
roomIndexToRoomId: Record<number, string>,
): void {
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
@ -295,8 +294,7 @@ 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 = this.context.slidingSyncManager.getOrAllocateListIndex(tagId); const listData = this.context.slidingSyncManager.slidingSync.getListData(tagId);
const listData = this.context.slidingSyncManager.slidingSync.getListData(index);
if (!listData) { if (!listData) {
continue; continue;
} }
@ -334,9 +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 = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
this.context.slidingSyncManager this.context.slidingSyncManager
.ensureListRegistered(index, { .ensureListRegistered(tagId, {
filters: filter, filters: filter,
sort: SlidingSyncSortToFilter[sort], sort: SlidingSyncSortToFilter[sort],
}) })
@ -361,15 +358,17 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient<IState> impl
if (roomId === activeSpace) { if (roomId === activeSpace) {
return; return;
} }
if (!filters.spaces) {
filters.spaces = [];
}
filters.spaces.push(roomId); // add subspace filters.spaces.push(roomId); // add subspace
}, },
false, false,
); );
this.emit(LISTS_LOADING_EVENT, tagId, true); this.emit(LISTS_LOADING_EVENT, tagId, true);
const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
this.context.slidingSyncManager this.context.slidingSyncManager
.ensureListRegistered(index, { .ensureListRegistered(tagId, {
filters: filters, filters: filters,
}) })
.then(() => { .then(() => {

View File

@ -78,13 +78,76 @@ describe("SlidingSyncManager", () => {
}); });
}); });
describe("ensureListRegistered", () => {
it("creates a new list based on the key", async () => {
const listKey = "key";
mocked(slidingSync.getListParams).mockReturnValue(null);
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
sort: ["by_recency"],
});
expect(slidingSync.setList).toBeCalledWith(
listKey,
expect.objectContaining({
sort: ["by_recency"],
}),
);
});
it("updates an existing list based on the key", async () => {
const listKey = "key";
mocked(slidingSync.getListParams).mockReturnValue({
ranges: [[0, 42]],
});
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
sort: ["by_recency"],
});
expect(slidingSync.setList).toBeCalledWith(
listKey,
expect.objectContaining({
sort: ["by_recency"],
ranges: [[0, 42]],
}),
);
});
it("updates ranges on an existing list based on the key if there's no other changes", async () => {
const listKey = "key";
mocked(slidingSync.getListParams).mockReturnValue({
ranges: [[0, 42]],
});
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
ranges: [[0, 52]],
});
expect(slidingSync.setList).not.toBeCalled();
expect(slidingSync.setListRanges).toBeCalledWith(listKey, [[0, 52]]);
});
it("no-ops for idential changes", async () => {
const listKey = "key";
mocked(slidingSync.getListParams).mockReturnValue({
ranges: [[0, 42]],
sort: ["by_recency"],
});
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
ranges: [[0, 42]],
sort: ["by_recency"],
});
expect(slidingSync.setList).not.toBeCalled();
expect(slidingSync.setListRanges).not.toBeCalled();
});
});
describe("startSpidering", () => { describe("startSpidering", () => {
it("requests in batchSizes", async () => { it("requests in batchSizes", async () => {
const gapMs = 1; const gapMs = 1;
const batchSize = 10; const batchSize = 10;
mocked(slidingSync.setList).mockResolvedValue("yep"); mocked(slidingSync.setList).mockResolvedValue("yep");
mocked(slidingSync.setListRanges).mockResolvedValue("yep"); mocked(slidingSync.setListRanges).mockResolvedValue("yep");
mocked(slidingSync.getListData).mockImplementation((i) => { mocked(slidingSync.getListData).mockImplementation((key) => {
return { return {
joinedCount: 64, joinedCount: 64,
roomIndexToRoomId: {}, roomIndexToRoomId: {},
@ -106,24 +169,24 @@ describe("SlidingSyncManager", () => {
wantWindows.forEach((range, i) => { wantWindows.forEach((range, i) => {
if (i === 0) { if (i === 0) {
expect(slidingSync.setList).toBeCalledWith( expect(slidingSync.setList).toBeCalledWith(
manager.getOrAllocateListIndex(SlidingSyncManager.ListSearch), SlidingSyncManager.ListSearch,
expect.objectContaining({ expect.objectContaining({
ranges: [[0, batchSize - 1], range], ranges: [[0, batchSize - 1], range],
}), }),
); );
return; return;
} }
expect(slidingSync.setListRanges).toBeCalledWith( expect(slidingSync.setListRanges).toBeCalledWith(SlidingSyncManager.ListSearch, [
manager.getOrAllocateListIndex(SlidingSyncManager.ListSearch), [0, batchSize - 1],
[[0, batchSize - 1], range], range,
); ]);
}); });
}); });
it("handles accounts with zero rooms", async () => { it("handles accounts with zero rooms", async () => {
const gapMs = 1; const gapMs = 1;
const batchSize = 10; const batchSize = 10;
mocked(slidingSync.setList).mockResolvedValue("yep"); mocked(slidingSync.setList).mockResolvedValue("yep");
mocked(slidingSync.getListData).mockImplementation((i) => { mocked(slidingSync.getListData).mockImplementation((key) => {
return { return {
joinedCount: 0, joinedCount: 0,
roomIndexToRoomId: {}, roomIndexToRoomId: {},
@ -133,7 +196,7 @@ describe("SlidingSyncManager", () => {
expect(slidingSync.getListData).toBeCalledTimes(1); expect(slidingSync.getListData).toBeCalledTimes(1);
expect(slidingSync.setList).toBeCalledTimes(1); expect(slidingSync.setList).toBeCalledTimes(1);
expect(slidingSync.setList).toBeCalledWith( expect(slidingSync.setList).toBeCalledWith(
manager.getOrAllocateListIndex(SlidingSyncManager.ListSearch), SlidingSyncManager.ListSearch,
expect.objectContaining({ expect.objectContaining({
ranges: [ ranges: [
[0, batchSize - 1], [0, batchSize - 1],
@ -146,7 +209,7 @@ describe("SlidingSyncManager", () => {
const gapMs = 1; const gapMs = 1;
const batchSize = 10; const batchSize = 10;
mocked(slidingSync.setList).mockRejectedValue("narp"); mocked(slidingSync.setList).mockRejectedValue("narp");
mocked(slidingSync.getListData).mockImplementation((i) => { mocked(slidingSync.getListData).mockImplementation((key) => {
return { return {
joinedCount: 0, joinedCount: 0,
roomIndexToRoomId: {}, roomIndexToRoomId: {},
@ -156,7 +219,7 @@ describe("SlidingSyncManager", () => {
expect(slidingSync.getListData).toBeCalledTimes(1); expect(slidingSync.getListData).toBeCalledTimes(1);
expect(slidingSync.setList).toBeCalledTimes(1); expect(slidingSync.setList).toBeCalledTimes(1);
expect(slidingSync.setList).toBeCalledWith( expect(slidingSync.setList).toBeCalledWith(
manager.getOrAllocateListIndex(SlidingSyncManager.ListSearch), SlidingSyncManager.ListSearch,
expect.objectContaining({ expect.objectContaining({
ranges: [ ranges: [
[0, batchSize - 1], [0, batchSize - 1],

View File

@ -0,0 +1,111 @@
/*
Copyright 2023 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.
*/
// eslint-disable-next-line deprecate/import
import { mount } from "enzyme";
import { sleep } from "matrix-js-sdk/src/utils";
import React from "react";
import { act } from "react-dom/test-utils";
import { mocked } from "jest-mock";
import { SlidingSync } from "matrix-js-sdk/src/sliding-sync";
import { Room } from "matrix-js-sdk/src/matrix";
import { SlidingSyncRoomSearchOpts, useSlidingSyncRoomSearch } from "../../src/hooks/useSlidingSyncRoomSearch";
import { MockEventEmitter, stubClient } from "../test-utils";
import { SlidingSyncManager } from "../../src/SlidingSyncManager";
type RoomSearchHook = {
loading: boolean;
rooms: Room[];
search(opts: SlidingSyncRoomSearchOpts): Promise<boolean>;
};
// hooks must be inside a React component else you get:
// "Invalid hook call. Hooks can only be called inside of the body of a function component."
function RoomSearchComponent(props: { onClick: (h: RoomSearchHook) => void }) {
const roomSearch = useSlidingSyncRoomSearch();
return <div onClick={() => props.onClick(roomSearch)} />;
}
describe("useSlidingSyncRoomSearch", () => {
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 and then do the search
let executeHook = (roomSearch: RoomSearchHook) => {
expect(roomSearch.loading).toBe(false);
expect(roomSearch.rooms).toEqual([]);
roomSearch.search({
limit: 10,
query: "foo",
});
};
const wrapper = mount(
<RoomSearchComponent
onClick={(roomSearch: RoomSearchHook) => {
executeHook(roomSearch);
}}
/>,
);
// run the query
await act(async () => {
await sleep(1);
wrapper.simulate("click");
return act(() => sleep(1));
});
// now we expect there to be rooms
executeHook = (roomSearch) => {
expect(roomSearch.loading).toBe(false);
expect(roomSearch.rooms).toEqual([roomA, roomB]);
};
// run the query
await act(async () => {
await sleep(1);
wrapper.simulate("click");
return act(() => sleep(1));
});
});
});

View File

@ -30,7 +30,7 @@ import { RoomViewStore } from "../../../src/stores/RoomViewStore";
import { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; import { MatrixDispatcher } from "../../../src/dispatcher/dispatcher";
import { SortAlgorithm } from "../../../src/stores/room-list/algorithms/models"; import { SortAlgorithm } from "../../../src/stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../../../src/stores/room-list/models"; import { DefaultTagID, TagID } from "../../../src/stores/room-list/models";
import { UPDATE_SELECTED_SPACE } from "../../../src/stores/spaces"; import { MetaSpace, UPDATE_SELECTED_SPACE } from "../../../src/stores/spaces";
import { LISTS_LOADING_EVENT } from "../../../src/stores/room-list/RoomListStore"; import { LISTS_LOADING_EVENT } from "../../../src/stores/room-list/RoomListStore";
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
@ -42,7 +42,6 @@ describe("SlidingRoomListStore", () => {
let context: TestSdkContext; let context: TestSdkContext;
let dis: MatrixDispatcher; let dis: MatrixDispatcher;
let activeSpace: string; let activeSpace: string;
let tagIdToIndex = {};
beforeEach(async () => { beforeEach(async () => {
context = new TestSdkContext(); context = new TestSdkContext();
@ -64,27 +63,6 @@ describe("SlidingRoomListStore", () => {
getRoomId: jest.fn(), getRoomId: jest.fn(),
}) as unknown as RoomViewStore, }) 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({ mocked(context._SlidingSyncManager.ensureListRegistered).mockResolvedValue({
ranges: [[0, 10]], ranges: [[0, 10]],
}); });
@ -104,17 +82,31 @@ describe("SlidingRoomListStore", () => {
// change the active space // change the active space
activeSpace = spaceRoomId; activeSpace = spaceRoomId;
context._SpaceStore.emit(UPDATE_SELECTED_SPACE, spaceRoomId, false); context._SpaceStore!.emit(UPDATE_SELECTED_SPACE, spaceRoomId, false);
await p; await p;
expect(context._SlidingSyncManager.ensureListRegistered).toHaveBeenCalledWith( expect(context._SlidingSyncManager!.ensureListRegistered).toHaveBeenCalledWith(DefaultTagID.Untagged, {
tagIdToIndex[DefaultTagID.Untagged], filters: expect.objectContaining({
{ spaces: [spaceRoomId],
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 () => { it("alters 'filters.spaces' on the DefaultTagID.Untagged list if it loads with an active space", async () => {
@ -126,8 +118,8 @@ describe("SlidingRoomListStore", () => {
}); });
await store.start(); // call onReady await store.start(); // call onReady
await p; await p;
expect(context._SlidingSyncManager.ensureListRegistered).toHaveBeenCalledWith( expect(context._SlidingSyncManager!.ensureListRegistered).toHaveBeenCalledWith(
tagIdToIndex[DefaultTagID.Untagged], DefaultTagID.Untagged,
expect.objectContaining({ expect.objectContaining({
filters: expect.objectContaining({ filters: expect.objectContaining({
spaces: [spaceRoomId], spaces: [spaceRoomId],
@ -146,7 +138,7 @@ describe("SlidingRoomListStore", () => {
return listName === DefaultTagID.Untagged && !isLoading; return listName === DefaultTagID.Untagged && !isLoading;
}); });
mocked(context._SpaceStore.traverseSpace).mockImplementation( mocked(context._SpaceStore!.traverseSpace).mockImplementation(
(spaceId: string, fn: (roomId: string) => void) => { (spaceId: string, fn: (roomId: string) => void) => {
if (spaceId === spaceRoomId) { if (spaceId === spaceRoomId) {
fn(subSpace1); fn(subSpace1);
@ -157,31 +149,27 @@ describe("SlidingRoomListStore", () => {
// change the active space // change the active space
activeSpace = spaceRoomId; activeSpace = spaceRoomId;
context._SpaceStore.emit(UPDATE_SELECTED_SPACE, spaceRoomId, false); context._SpaceStore!.emit(UPDATE_SELECTED_SPACE, spaceRoomId, false);
await p; await p;
expect(context._SlidingSyncManager.ensureListRegistered).toHaveBeenCalledWith( expect(context._SlidingSyncManager!.ensureListRegistered).toHaveBeenCalledWith(DefaultTagID.Untagged, {
tagIdToIndex[DefaultTagID.Untagged], filters: expect.objectContaining({
{ spaces: [spaceRoomId, subSpace1, subSpace2],
filters: expect.objectContaining({ }),
spaces: [spaceRoomId, subSpace1, subSpace2], });
}),
},
);
}); });
}); });
it("setTagSorting alters the 'sort' option in the list", async () => { it("setTagSorting alters the 'sort' option in the list", async () => {
mocked(context._SlidingSyncManager.getOrAllocateListIndex).mockReturnValue(0);
const tagId: TagID = "foo"; const tagId: TagID = "foo";
await store.setTagSorting(tagId, SortAlgorithm.Alphabetic); await store.setTagSorting(tagId, SortAlgorithm.Alphabetic);
expect(context._SlidingSyncManager.ensureListRegistered).toBeCalledWith(0, { expect(context._SlidingSyncManager!.ensureListRegistered).toBeCalledWith(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic], sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic],
}); });
expect(store.getTagSorting(tagId)).toEqual(SortAlgorithm.Alphabetic); expect(store.getTagSorting(tagId)).toEqual(SortAlgorithm.Alphabetic);
await store.setTagSorting(tagId, SortAlgorithm.Recent); await store.setTagSorting(tagId, SortAlgorithm.Recent);
expect(context._SlidingSyncManager.ensureListRegistered).toBeCalledWith(0, { expect(context._SlidingSyncManager!.ensureListRegistered).toBeCalledWith(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Recent], sort: SlidingSyncSortToFilter[SortAlgorithm.Recent],
}); });
expect(store.getTagSorting(tagId)).toEqual(SortAlgorithm.Recent); expect(store.getTagSorting(tagId)).toEqual(SortAlgorithm.Recent);
@ -189,33 +177,31 @@ describe("SlidingRoomListStore", () => {
it("getTagsForRoom gets the tags for the room", async () => { it("getTagsForRoom gets the tags for the room", async () => {
await store.start(); await store.start();
const untaggedIndex = context._SlidingSyncManager.getOrAllocateListIndex(DefaultTagID.Untagged);
const favIndex = context._SlidingSyncManager.getOrAllocateListIndex(DefaultTagID.Favourite);
const roomA = "!a:localhost"; const roomA = "!a:localhost";
const roomB = "!b:localhost"; const roomB = "!b:localhost";
const indexToListData = { const keyToListData: Record<string, { joinedCount: number; roomIndexToRoomId: Record<number, string> }> = {
[untaggedIndex]: { [DefaultTagID.Untagged]: {
joinedCount: 10, joinedCount: 10,
roomIndexToRoomId: { roomIndexToRoomId: {
0: roomA, 0: roomA,
1: roomB, 1: roomB,
}, },
}, },
[favIndex]: { [DefaultTagID.Favourite]: {
joinedCount: 2, joinedCount: 2,
roomIndexToRoomId: { roomIndexToRoomId: {
0: roomB, 0: roomB,
}, },
}, },
}; };
mocked(context._SlidingSyncManager.slidingSync.getListData).mockImplementation((i: number) => { mocked(context._SlidingSyncManager!.slidingSync.getListData).mockImplementation((key: string) => {
return indexToListData[i] || null; return keyToListData[key] || null;
}); });
expect(store.getTagsForRoom(new Room(roomA, context.client, context.client.getUserId()))).toEqual([ expect(store.getTagsForRoom(new Room(roomA, context.client!, context.client!.getUserId()))).toEqual([
DefaultTagID.Untagged, DefaultTagID.Untagged,
]); ]);
expect(store.getTagsForRoom(new Room(roomB, context.client, context.client.getUserId()))).toEqual([ expect(store.getTagsForRoom(new Room(roomB, context.client!, context.client!.getUserId()))).toEqual([
DefaultTagID.Favourite, DefaultTagID.Favourite,
DefaultTagID.Untagged, DefaultTagID.Untagged,
]); ]);
@ -227,7 +213,6 @@ describe("SlidingRoomListStore", () => {
const roomB = "!b:localhost"; const roomB = "!b:localhost";
const roomC = "!c:localhost"; const roomC = "!c:localhost";
const tagId = DefaultTagID.Favourite; const tagId = DefaultTagID.Favourite;
const listIndex = context.slidingSyncManager.getOrAllocateListIndex(tagId);
const joinCount = 10; const joinCount = 10;
const roomIndexToRoomId = { const roomIndexToRoomId = {
// mixed to ensure we sort // mixed to ensure we sort
@ -236,11 +221,11 @@ describe("SlidingRoomListStore", () => {
0: roomA, 0: roomA,
}; };
const rooms = [ const rooms = [
new Room(roomA, context.client, context.client.getUserId()), new Room(roomA, context.client!, context.client!.getUserId()),
new Room(roomB, context.client, context.client.getUserId()), new Room(roomB, context.client!, context.client!.getUserId()),
new Room(roomC, context.client, context.client.getUserId()), new Room(roomC, context.client!, context.client!.getUserId()),
]; ];
mocked(context.client.getRoom).mockImplementation((roomId: string) => { mocked(context.client!.getRoom).mockImplementation((roomId: string) => {
switch (roomId) { switch (roomId) {
case roomA: case roomA:
return rooms[0]; return rooms[0];
@ -252,7 +237,7 @@ describe("SlidingRoomListStore", () => {
return null; return null;
}); });
const p = untilEmission(store, LISTS_UPDATE_EVENT); const p = untilEmission(store, LISTS_UPDATE_EVENT);
context.slidingSyncManager.slidingSync.emit(SlidingSyncEvent.List, listIndex, joinCount, roomIndexToRoomId); context.slidingSyncManager.slidingSync.emit(SlidingSyncEvent.List, tagId, joinCount, roomIndexToRoomId);
await p; await p;
expect(store.getCount(tagId)).toEqual(joinCount); expect(store.getCount(tagId)).toEqual(joinCount);
expect(store.orderedLists[tagId]).toEqual(rooms); expect(store.orderedLists[tagId]).toEqual(rooms);
@ -265,7 +250,6 @@ describe("SlidingRoomListStore", () => {
const roomIdB = "!b:localhost"; const roomIdB = "!b:localhost";
const roomIdC = "!c:localhost"; const roomIdC = "!c:localhost";
const tagId = DefaultTagID.Favourite; const tagId = DefaultTagID.Favourite;
const listIndex = context.slidingSyncManager.getOrAllocateListIndex(tagId);
const joinCount = 10; const joinCount = 10;
const roomIndexToRoomId = { const roomIndexToRoomId = {
// mixed to ensure we sort // mixed to ensure we sort
@ -273,10 +257,10 @@ describe("SlidingRoomListStore", () => {
2: roomIdC, 2: roomIdC,
0: roomIdA, 0: roomIdA,
}; };
const roomA = new Room(roomIdA, context.client, context.client.getUserId()); const roomA = new Room(roomIdA, context.client!, context.client!.getUserId());
const roomB = new Room(roomIdB, 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()); const roomC = new Room(roomIdC, context.client!, context.client!.getUserId());
mocked(context.client.getRoom).mockImplementation((roomId: string) => { mocked(context.client!.getRoom).mockImplementation((roomId: string) => {
switch (roomId) { switch (roomId) {
case roomIdA: case roomIdA:
return roomA; return roomA;
@ -287,8 +271,8 @@ describe("SlidingRoomListStore", () => {
} }
return null; return null;
}); });
mocked(context._SlidingSyncManager.slidingSync.getListData).mockImplementation((i: number) => { mocked(context._SlidingSyncManager!.slidingSync.getListData).mockImplementation((key: string) => {
if (i !== listIndex) { if (key !== tagId) {
return null; return null;
} }
return { return {
@ -297,7 +281,7 @@ describe("SlidingRoomListStore", () => {
}; };
}); });
let p = untilEmission(store, LISTS_UPDATE_EVENT); let p = untilEmission(store, LISTS_UPDATE_EVENT);
context.slidingSyncManager.slidingSync.emit(SlidingSyncEvent.List, listIndex, joinCount, roomIndexToRoomId); context.slidingSyncManager.slidingSync.emit(SlidingSyncEvent.List, tagId, joinCount, roomIndexToRoomId);
await p; await p;
expect(store.orderedLists[tagId]).toEqual([roomA, roomB, roomC]); expect(store.orderedLists[tagId]).toEqual([roomA, roomB, roomC]);
@ -310,7 +294,7 @@ describe("SlidingRoomListStore", () => {
roomIndexToRoomId[1] = roomIdA; roomIndexToRoomId[1] = roomIdA;
roomIndexToRoomId[2] = roomIdB; roomIndexToRoomId[2] = roomIdB;
p = untilEmission(store, LISTS_UPDATE_EVENT); p = untilEmission(store, LISTS_UPDATE_EVENT);
context.slidingSyncManager.slidingSync.emit(SlidingSyncEvent.List, listIndex, joinCount, roomIndexToRoomId); context.slidingSyncManager.slidingSync.emit(SlidingSyncEvent.List, tagId, joinCount, roomIndexToRoomId);
await p; await p;
// check that B didn't move and that A was put below B // check that B didn't move and that A was put below B
@ -323,4 +307,43 @@ describe("SlidingRoomListStore", () => {
await p; await p;
expect(store.orderedLists[tagId].map((r) => r.roomId)).toEqual([roomC, roomA, roomB].map((r) => r.roomId)); 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]);
});
}); });