David Baker f59af3786e
Simplified Sliding Sync (#28515)
* Experimental SSS

Working branch to get SSS functional on element-web.

Requires https://github.com/matrix-org/matrix-js-sdk/pull/4400

* Adjust tests to use new behaviour

* Remove well-known proxy URL lookup; always use native

This is actually required for SSS because otherwise it would use
the proxy over native support.

* Linting

* Debug logging

* Control the race condition when swapping between rooms

* Dont' filter by space as synapse doesn't support it

* 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`.

* User bumpStamp if it is present

* Drop initial room load from 20 per list to 10

* Half the batch size to trickle more quickly

* Prettier

* prettier on tests too

* Remove proxy URL & unused import

* Hopefully fix tests to assert what the behaviour is supposed to be

* Move the singleton to the manager tyo fix import loop

* Very well, code, I will remove you

Why were you there in the first place?

* Strip out more unused stuff

* Fix playwright test

Seems like this lack of order updating unless a room is selected
was just always a bug with both regular and non-sliding sync. I
have no idea how the test passed on develop because it won't run.

* Fix test to do maybe what it was supposed to do... possibly?

* Remove test for old pre-simplified sliding sync behaviour

* Unused import

* Remove sliding sync proxy & test

I was wrong about what this test was asserting, it was suposed
to assert that notification dots aren't shown (because SS didn't
support them somehow I guess) but they are fine in SSS so the test
is just no longer relevant.

* Remove now pointless credentials

* Remove subscription removal as SSS doesn't do that

* Update tests

* add test

* Switch to new labs flag & break if old labs flag is enabled

* Remove unused import & fix test

* Fix other test

* Remove name & description from old labs flag

as they're not displayed anywhere so not useful

* Remove old sliding sync option

by making it not a feature

* Add back unread nindicator test but inverted

and minus the bit about disabling notification which surely would have
defeated the original point anyway?

* Reinstate test for room_subscriptions

...and also make tests actually use sliding sync

* Use UserFriendlyError

* Remove empty constructor

* Remove unrelated changes

* Unused import

* Fix import

* Avoid moving import

---------

Co-authored-by: Kegan Dougal <7190048+kegsay@users.noreply.github.com>
2025-03-18 17:54:32 +00:00

338 lines
15 KiB
TypeScript

/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { range } from "lodash";
import { act, renderHook, waitFor } from "jest-matrix-react";
import { mocked } from "jest-mock";
import RoomListStoreV3 from "../../../../../src/stores/room-list-v3/RoomListStoreV3";
import { mkStubRoom } from "../../../../test-utils";
import { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list/RoomListStore";
import { useRoomListViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filters";
import { SecondaryFilters } from "../../../../../src/components/viewmodels/roomlist/useFilteredRooms";
import { SortingAlgorithm } from "../../../../../src/stores/room-list-v3/skip-list/sorters";
import { SortOption } from "../../../../../src/components/viewmodels/roomlist/useSorter";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { hasCreateRoomRights, createRoom } from "../../../../../src/components/viewmodels/roomlist/utils";
import dispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
hasCreateRoomRights: jest.fn().mockReturnValue(false),
createRoom: jest.fn(),
}));
describe("RoomListViewModel", () => {
function mockAndCreateRooms() {
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
const fn = jest
.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace")
.mockImplementation(() => [...rooms]);
return { rooms, fn };
}
afterEach(() => {
jest.restoreAllMocks();
});
it("should return a list of rooms", async () => {
const { rooms } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.rooms).toHaveLength(10);
for (const room of rooms) {
expect(vm.current.rooms).toContain(room);
}
});
it("should update list of rooms on event from room list store", async () => {
const { rooms } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
const newRoom = mkStubRoom("bar:matrix.org", "Bar", undefined);
rooms.push(newRoom);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
await waitFor(() => {
expect(vm.current.rooms).toContain(newRoom);
});
});
describe("Filters", () => {
it("should provide list of available filters", () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// should have 4 filters
expect(vm.current.primaryFilters).toHaveLength(4);
// check the order
for (const [i, name] of ["Unread", "Favourites", "People", "Rooms"].entries()) {
expect(vm.current.primaryFilters[i].name).toEqual(name);
expect(vm.current.primaryFilters[i].active).toEqual(false);
}
});
it("should get filtered rooms from RLS on toggle", () => {
const { fn } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's say we toggle the People toggle
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
act(() => {
vm.current.primaryFilters[i].toggle();
});
expect(fn).toHaveBeenCalledWith([FilterKey.PeopleFilter]);
expect(vm.current.primaryFilters[i].active).toEqual(true);
});
it("should change active property on toggle", () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's say we toggle the People filter
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
expect(vm.current.primaryFilters[i].active).toEqual(false);
act(() => {
vm.current.primaryFilters[i].toggle();
});
expect(vm.current.primaryFilters[i].active).toEqual(true);
// Let's say that we toggle the Favourite filter
const j = vm.current.primaryFilters.findIndex((f) => f.name === "Favourites");
act(() => {
vm.current.primaryFilters[j].toggle();
});
expect(vm.current.primaryFilters[i].active).toEqual(false);
expect(vm.current.primaryFilters[j].active).toEqual(true);
});
it("should select all activity as default secondary filter", () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// By default, all activity should be the active secondary filter
expect(vm.current.activeSecondaryFilter).toEqual(SecondaryFilters.AllActivity);
});
it("should be able to filter using secondary filters", () => {
const { fn } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's say we toggle the mentions secondary filter
act(() => {
vm.current.activateSecondaryFilter(SecondaryFilters.MentionsOnly);
});
expect(fn).toHaveBeenCalledWith([FilterKey.MentionsFilter]);
});
it("primary filters are applied on top of secondary filers", () => {
const { fn } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's say we toggle the mentions secondary filter
act(() => {
vm.current.activateSecondaryFilter(SecondaryFilters.MentionsOnly);
});
// Let's say we toggle the People filter
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
act(() => {
vm.current.primaryFilters[i].toggle();
});
// RLS call must include both these filters
expect(fn).toHaveBeenLastCalledWith(
expect.arrayContaining([FilterKey.PeopleFilter, FilterKey.MentionsFilter]),
);
});
it("should return the current active primary filter", async () => {
// Let's say that the user's preferred sorting is alphabetic
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Toggle people filter
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
expect(vm.current.primaryFilters[i].active).toEqual(false);
act(() => vm.current.primaryFilters[i].toggle());
// The active primary filter should be the People filter
expect(vm.current.activePrimaryFilter).toEqual(vm.current.primaryFilters[i]);
});
const testcases: Array<[string, { secondary: SecondaryFilters; filterKey: FilterKey }, string]> = [
[
"Mentions only",
{ secondary: SecondaryFilters.MentionsOnly, filterKey: FilterKey.MentionsFilter },
"Unread",
],
["Invites only", { secondary: SecondaryFilters.InvitesOnly, filterKey: FilterKey.InvitesFilter }, "Unread"],
[
"Invites only",
{ secondary: SecondaryFilters.InvitesOnly, filterKey: FilterKey.InvitesFilter },
"Favourites",
],
[
"Low priority",
{ secondary: SecondaryFilters.LowPriority, filterKey: FilterKey.LowPriorityFilter },
"Favourites",
],
];
describe.each(testcases)("For secondary filter: %s", (secondaryFilterName, secondary, primaryFilterName) => {
it(`should unapply incompatible primary filter that is already active: ${primaryFilterName}`, () => {
const { fn } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Apply the primary filter
const i = vm.current.primaryFilters.findIndex((f) => f.name === primaryFilterName);
act(() => {
vm.current.primaryFilters[i].toggle();
});
// Apply the secondary filter
act(() => {
vm.current.activateSecondaryFilter(secondary.secondary);
});
// RLS call should only include the secondary filter
expect(fn).toHaveBeenLastCalledWith([secondary.filterKey]);
// Primary filter should have been unapplied
expect(vm.current.primaryFilters[i].active).toEqual(false);
});
it(`should hide incompatible primary filter: ${primaryFilterName}`, () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Apply the secondary filter
act(() => {
vm.current.activateSecondaryFilter(secondary.secondary);
});
// Incompatible primary filter must be hidden
expect(vm.current.primaryFilters.find((f) => f.name === primaryFilterName)).toBeUndefined();
});
});
it("should change sort order", () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
const resort = jest.spyOn(RoomListStoreV3.instance, "resort").mockImplementation(() => {});
// Change the sort option
act(() => {
vm.current.sort(SortOption.AToZ);
});
// Resort method in RLS must have been called
expect(resort).toHaveBeenCalledWith(SortingAlgorithm.Alphabetic);
});
it("should set activeSortOption based on value from settings", () => {
// Let's say that the user's preferred sorting is alphabetic
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => SortingAlgorithm.Alphabetic);
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.activeSortOption).toEqual(SortOption.AToZ);
});
});
describe("message preview toggle", () => {
it("should return shouldShowMessagePreview based on setting", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => true);
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.shouldShowMessagePreview).toEqual(true);
});
it("should change setting on toggle", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => true);
const fn = jest.spyOn(SettingsStore, "setValue").mockImplementation(async () => {});
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.shouldShowMessagePreview).toEqual(true);
act(() => {
vm.current.toggleMessagePreview();
});
expect(vm.current.shouldShowMessagePreview).toEqual(false);
expect(fn).toHaveBeenCalled();
});
});
describe("Create room and chat", () => {
it("should be canCreateRoom=false if hasCreateRoomRights=false", () => {
mocked(hasCreateRoomRights).mockReturnValue(false);
const { result } = renderHook(() => useRoomListViewModel());
expect(result.current.canCreateRoom).toBe(false);
});
it("should be canCreateRoom=true if hasCreateRoomRights=true", () => {
mocked(hasCreateRoomRights).mockReturnValue(true);
const { result } = renderHook(() => useRoomListViewModel());
expect(result.current.canCreateRoom).toBe(true);
});
it("should call createRoom", () => {
const { result } = renderHook(() => useRoomListViewModel());
result.current.createRoom();
expect(mocked(createRoom)).toHaveBeenCalled();
});
it("should dispatch Action.CreateChat", () => {
const spy = jest.spyOn(dispatcher, "fire");
const { result } = renderHook(() => useRoomListViewModel());
result.current.createChatRoom();
expect(spy).toHaveBeenCalledWith(Action.CreateChat);
});
});
describe("active index", () => {
it("should recalculate active index when list of rooms change", () => {
const { rooms } = mockAndCreateRooms();
// Let's say that the first room is the active room initially
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => rooms[0].roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.activeIndex).toEqual(0);
// Let's say that a new room is added and that becomes active
const newRoom = mkStubRoom("bar:matrix.org", "Bar", undefined);
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => newRoom.roomId);
rooms.push(newRoom);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
// Now the active room should be the last room which we just added
expect(vm.current.activeIndex).toEqual(rooms.length - 1);
});
it("should recalculate active index when active room changes", () => {
const { rooms } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// No active room yet
expect(vm.current.activeIndex).toBeUndefined();
// Let's say that room at index 5 becomes active
const room = rooms[5];
act(() => {
dispatcher.dispatch(
{
action: Action.ActiveRoomChanged,
oldRoomId: null,
newRoomId: room.roomId,
},
true,
);
});
// We expect index 5 to be active now
expect(vm.current.activeIndex).toEqual(5);
});
});
});