Remove legacy room list (#34040)

* refactor(room-list): migrate SpaceStore off the legacy room list store

SpaceStore.setActiveRoomInSpace iterated the legacy RoomListStore's
`orderedLists` in `TAG_ORDER`; switch it to the space-aware
RoomListStoreV3.getSortedRoomsInActiveSpace() accessor. This drops the
last non-UI dependency on the legacy store and on `TAG_ORDER` (exported
from LegacyRoomList, deleted next).

* feat(room-list)!: remove the legacy room list UI

Delete the old sublist-based room list and its components now that the
new RoomListPanel is the default. Removed: LegacyRoomList,
LegacyRoomListHeader, RoomSublist, ExtraTile, RoomTile (+ Subtitle/
CallSummary), RoomBreadcrumbs and RoomSearch, plus their styles and
tests.

LeftPanel collapses to the RoomListPanel-only path. The shared
`contextMenuBelow` helper is relocated into RoomResultContextMenus (its
only remaining consumer).

* feat(room-list)!: remove the legacy RoomListStore

The legacy sublist-based room list UI is gone, so the old
`stores/room-list` store (Algorithm, sorters, filters, layout store,
space
watcher) has no remaining consumers. Delete the directory and its tests.

MatrixChat.forgetRoom no longer calls the legacy `manualRoomUpdate`; the
new room list store removes the room on the `AfterForgetRoom` dispatch
that still fires. Drop the `mxRoomListStore`/`mxRoomListLayoutStore`
globals and the now-dead test imports.

* feat(room-list)!: remove the feature_new_room_list labs flag

The new room list is now the only room list, so remove the
feature_new_room_list labs flag and make its enabled behaviour
unconditional everywhere it was gated:

- LoggedInView: always use the resizable layout and
NEW_ROOM_LIST_MIN_WIDTH;
  drop the collapsible/minimized legacy path.
- SpaceStore: People and Favourites are dropped from metaSpaceOrder (per
the
  long-standing TODO on the removed accessor).
- MessagePreviewStore: stop appending thread replies to previews.
- Settings, SidebarUserSettingsTab, PreferencesUserSettingsTab,
  QuickSettingsButton, SpacePanel, LandmarkNavigation: drop the flag
reads and
  legacy branches.

Update the tests that toggled the flag; the People/Favourites meta space
tests covered behaviour that the flag (default on) already disabled.

* feat(room-list)!: remove the dead legacy left-panel resizer

LoggedInView still built the old `Resizer`/`CollapseDistributor` over an
`lp-resizer` ResizeHandle and persisted `mx_lhs_size`. That handle is no
longer rendered (the resizable layout is now driven by
LeftResizablePanelView + ResizerViewModel, which persists its own state
via RoomList.panelSize/RoomList.isPanelCollapsed), so the old resizer
was
inert dead code left over from the legacy room list.

Remove createResizer/loadResizer/loadResizerPreferences, the
_resizeContainer/resizeHandler refs, the ResizeHandle render, the
mx_lhs_size handling and NEW_ROOM_LIST_MIN_WIDTH, plus the unit tests
that
exercised the mocked resizer.

* feat(room-list)!: update i18n files

* refactor(room-list): remove the now-unused collapseLhs state

`collapseLhs` is write-only since the left panel no longer collapses: it
was last read by LoggedInView's `shouldUseMinimizedUI`, removed with the
feature_new_room_list flag. Drop it from MatrixChat's IState (and its
assignments), collapsing the hide/show_left_panel handlers to just the
`notifyLeftHandleResized()` call they still need, and from
LoggedInView's
IProps and the test props.

* fix(room-list): instantiate message previewers lazily

Removing the unused SettingsStore import from MessagePreviewStore (when
the
feature_new_room_list flag was dropped) changed module load order and
exposed a latent circular dependency: ReactionEventPreview imports
MessagePreviewStore, which eagerly did `new ReactionEventPreview()` at
module-eval — so importing ReactionEventPreview first (as its unit test
does) hit "ReactionEventPreview is not a constructor".

Construct the previewers lazily on first use (cached) instead of at
module
load, so nothing dereferences a mid-evaluation module. Fixes
ReactionEventPreview-test.

* test(room-list): remove `feature_new_room_list` labs flag in e2e tests

* chore: remove remaining `newRoomList` flag

* chore: cleanup theme files

* fix: restore the re-resizable TouchEvent polyfill

* chore: remove usage of breadcrumbs settings in BreadcrumbStore

* Revert "fix(room-list): instantiate message previewers lazily"

This reverts commit 4e6eedfff0449c68a96c0470a4eb425b5aec5512.

* chore: remove unused function in BreadCrumbStore

* test: remove unused fuction of BreadcrumStore in tests

* test: add tests for RoomResultContextMenu
This commit is contained in:
Florian Duros
2026-07-02 09:07:37 +00:00
committed by GitHub
parent 522ec3aa57
commit b68ecdb860
103 changed files with 91 additions and 11086 deletions
@@ -1,43 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Mikhail Aheichyk
Copyright 2023 Nordeck IT + Consulting GmbH.
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 React from "react";
import { render, type RenderResult, screen } from "jest-matrix-react";
import { mocked } from "jest-mock";
import LeftPanel from "../../../../src/components/structures/LeftPanel";
import ResizeNotifier from "../../../../src/utils/ResizeNotifier";
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../src/settings/UIFeature";
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
describe("LeftPanel", () => {
function renderComponent(): RenderResult {
return render(<LeftPanel isMinimized={false} resizeNotifier={new ResizeNotifier()} />);
}
it("does not show filter container when disabled by UIComponent customisations", () => {
mocked(shouldShowComponent).mockReturnValue(false);
renderComponent();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.FilterContainer);
expect(screen.queryByRole("button", { name: /search/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Explore rooms" })).not.toBeInTheDocument();
});
it("renders filter container when enabled by UIComponent customisations", () => {
mocked(shouldShowComponent).mockReturnValue(true);
renderComponent();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.FilterContainer);
expect(screen.getByRole("button", { name: /search/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Explore rooms" })).toBeInTheDocument();
});
});
@@ -41,27 +41,6 @@ import { SETTINGS } from "../../../../src/settings/Settings";
import ToastStore from "../../../../src/stores/ToastStore";
import { ModuleApi } from "../../../../src/modules/Api";
// Create a mock resizer instance that can be shared across tests
const mockResizerInstance = {
attach: jest.fn(),
detach: jest.fn(),
forHandleWithId: jest.fn().mockReturnValue({ resize: jest.fn() }),
setClassNames: jest.fn(),
};
// Mock the Resizer module
jest.mock("../../../../src/resizer", () => {
const originalModule = jest.requireActual("../../../../src/resizer");
return {
...originalModule,
Resizer: jest.fn().mockImplementation((container, distributorBuilder, collapseConfig) => {
// Store the callbacks globally for test access
(global as any).__resizeCallbacks = collapseConfig;
return mockResizerInstance;
}),
};
});
describe("<LoggedInView />", () => {
const userId = "@alice:domain.org";
const mockClient = getMockClientWithEventEmitter({
@@ -86,7 +65,6 @@ describe("<LoggedInView />", () => {
matrixClient: mockClient,
onRegistered: jest.fn(),
resizeNotifier: new ResizeNotifier(),
collapseLhs: false,
hideToSRUsers: false,
config: {
brand: "Test",
@@ -535,100 +513,18 @@ describe("<LoggedInView />", () => {
});
});
describe("resizer preferences", () => {
let mockResize: jest.Mock;
let mockForHandleWithId: jest.Mock;
beforeEach(() => {
// Clear localStorage before each test
window.localStorage.clear();
mockResize = jest.fn();
mockForHandleWithId = jest.fn().mockReturnValue({ resize: mockResize });
// Update the shared mock instance for this test
mockResizerInstance.forHandleWithId = mockForHandleWithId;
// Clear any global callback state
delete (global as any).__resizeCallbacks;
});
it("should call resize with default size when localStorage contains NaN value", () => {
// Set invalid value in localStorage that will result in NaN
window.localStorage.setItem("mx_lhs_size", "not-a-number");
getComponent();
// Verify that when lhsSize is NaN, it defaults to 350 and calls resize
expect(mockForHandleWithId).toHaveBeenCalledWith("lp-resizer");
expect(mockResize).toHaveBeenCalledWith(350);
});
it("should use existing size when localStorage contains valid value", () => {
// Set valid value in localStorage
window.localStorage.setItem("mx_lhs_size", "400");
getComponent();
// Verify the resize method was called with the stored size (400)
expect(mockResize).toHaveBeenCalledWith(400);
});
it("should enforce minimum width for new room list when stored size is zero", async () => {
// Enable new room list feature
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, true);
// 0 represents the collapsed state for the old room list, which could have been set before the new room list was enabled
window.localStorage.setItem("mx_lhs_size", "0");
getComponent();
// Verify the resize method was called with the default size (350) when stored size is below minimum
expect(mockResize).toHaveBeenCalledWith(350);
});
it("should not set localStorage to 0 when resizing lp-resizer to minimum width for new room list", async () => {
// Enable new room list feature and mock SettingsStore
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, true);
const minimumWidth = 224; // NEW_ROOM_LIST_MIN_WIDTH
// Render the component
getComponent();
// Get the callbacks that were captured during resizer creation
const callbacks = (global as any).__resizeCallbacks;
// Create a mock DOM node for isItemCollapsed to check
const domNode = {
classList: {
contains: jest.fn().mockReturnValue(true), // Simulate the error where mx_LeftPanel_minimized is present
},
} as any;
callbacks.onResized(minimumWidth);
const isCollapsed = callbacks.isItemCollapsed(domNode);
callbacks.onCollapsed(isCollapsed); // Not collapsed for new room list
callbacks.onResizeStop();
// Verify localStorage was set to the minimum width (224), not 0
expect(window.localStorage.getItem("mx_lhs_size")).toBe("224");
});
});
describe("module-rendered fullscreen view (e.g. multiroom)", () => {
// A page_type for which a module registers a custom full-screen renderer.
const modulePageType = "io.element.test_fullscreen";
beforeEach(async () => {
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, true);
beforeEach(() => {
ModuleApi.instance.navigation.registerLocationRenderer(modulePageType, () => (
<div data-testid="module-content" />
));
});
afterEach(async () => {
afterEach(() => {
ModuleApi.instance.navigation.locationRenderers.delete(modulePageType);
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, false);
});
it("renders the resizable separator for a normal room view with the new room list", () => {
@@ -650,34 +546,4 @@ describe("<LoggedInView />", () => {
expect(container.querySelector(".mx_SpacePanel")).toBeInTheDocument();
});
});
describe("create a new resizer when page_type changes", () => {
afterEach(() => {
jest.clearAllMocks();
});
it("should call loadResizer when page_type changes", () => {
const component = getComponent({ page_type: "room" });
// Re-render with different page_type
component.rerender(<LoggedInView {...defaultProps} page_type="home" />);
// Verify that detach was called (from loadResizer)
expect(mockResizerInstance.detach).toHaveBeenCalledTimes(1);
// Verify that attach was called (from loadResizer)
// 1 (when page_type = "room") + 1 (when page_type = "home")
expect(mockResizerInstance.attach).toHaveBeenCalledTimes(2);
});
it("should not call loadResizer when page_type remains the same", () => {
const component = getComponent({ page_type: "room" });
// Re-render with same page_type but different other props
component.rerender(<LoggedInView {...defaultProps} page_type="room" currentRoomId="!different:room.id" />);
// Verify that resizer methods were not called
expect(mockResizerInstance.detach).not.toHaveBeenCalled();
expect(mockResizerInstance.attach).toHaveBeenCalledTimes(1);
});
});
});
@@ -68,7 +68,6 @@ import Modal from "../../../../src/Modal.tsx";
import { SetupEncryptionStore } from "../../../../src/stores/SetupEncryptionStore.ts";
import { ShareFormat } from "../../../../src/dispatcher/payloads/SharePayload.ts";
import { clearStorage } from "../../../../src/Lifecycle";
import RoomListStore from "../../../../src/stores/room-list/RoomListStore.ts";
import UserSettingsDialog from "../../../../src/components/views/dialogs/UserSettingsDialog.tsx";
import { SDKContextClass } from "../../../../src/contexts/SDKContextClass";
import { makeDelegatedAuthConfig } from "../../../test-utils/oidc.ts";
@@ -854,9 +853,6 @@ describe("<MatrixChat />", () => {
await clearAllModals();
await getComponentAndWaitForReady();
// Mock out the old room list store
jest.spyOn(RoomListStore.instance, "manualRoomUpdate").mockImplementation(async () => {});
// Register a mock function to the dispatcher
const fn = jest.fn();
defaultDispatcher.register(fn);
@@ -11,6 +11,7 @@ import React from "react";
import { render, screen, type RenderResult } from "jest-matrix-react";
import { mocked } from "jest-mock";
import { Room, type MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
import userEvent from "@testing-library/user-event";
import { RoomResultContextMenus } from "../../../../../../src/components/views/dialogs/spotlight/RoomResultContextMenus";
import { filterConsole, stubClient } from "../../../../../test-utils";
@@ -54,4 +55,16 @@ describe("RoomResultContextMenus", () => {
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
expect(screen.queryByRole("button", { name: "Room options" })).toBeInTheDocument();
});
it("opens the notification options menu positioned relative to its button", async () => {
const user = userEvent.setup();
mocked(shouldShowComponent).mockReturnValue(true);
renderRoomResultContextMenus();
const button = screen.getByRole("button", { name: "Notification options" });
// Opening the menu runs contextMenuBelow to position it beneath the button.
await user.click(button);
expect(screen.getByRole("menu")).toBeInTheDocument();
});
});
@@ -1,53 +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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { getByRole, render } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import React, { type ComponentProps } from "react";
import ExtraTile from "../../../../../src/components/views/rooms/ExtraTile";
describe("ExtraTile", () => {
function renderComponent(props: Partial<ComponentProps<typeof ExtraTile>> = {}) {
const defaultProps: ComponentProps<typeof ExtraTile> = {
isMinimized: false,
isSelected: false,
displayName: "test",
avatar: <React.Fragment />,
onClick: () => {},
};
return render(<ExtraTile {...defaultProps} {...props} />);
}
it("renders", () => {
const { asFragment } = renderComponent();
expect(asFragment()).toMatchSnapshot();
});
it("hides text when minimized", () => {
const { container } = renderComponent({
isMinimized: true,
displayName: "testDisplayName",
});
expect(container).not.toHaveTextContent("testDisplayName");
});
it("registers clicks", async () => {
const onClick = jest.fn();
const { container } = renderComponent({
onClick,
});
const btn = getByRole(container, "treeitem");
await userEvent.click(btn);
expect(onClick).toHaveBeenCalledTimes(1);
});
});
@@ -1,273 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Mikhail Aheichyk
Copyright 2023 Nordeck IT + Consulting GmbH.
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 React, { type JSX } from "react";
import { cleanup, queryByRole, render, screen, within } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { type Room } from "matrix-js-sdk/src/matrix";
import LegacyRoomList from "../../../../../src/components/views/rooms/LegacyRoomList";
import ResizeNotifier from "../../../../../src/utils/ResizeNotifier";
import { MetaSpace } from "../../../../../src/stores/spaces";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../../src/settings/UIFeature";
import dis from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import * as testUtils from "../../../../test-utils";
import { mkSpace, stubClient } from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import RoomListStore from "../../../../../src/stores/room-list/RoomListStore";
import { type ITagMap } from "../../../../../src/stores/room-list/algorithms/models";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
jest.mock("../../../../../src/dispatcher/dispatcher");
const getUserIdForRoomId = jest.fn();
const getDMRoomsForUserId = jest.fn();
// @ts-ignore
DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId };
describe("LegacyRoomList", () => {
stubClient();
const client = MatrixClientPeg.safeGet();
const store = SpaceStore.instance;
function getComponent(props: Partial<LegacyRoomList["props"]> = {}): JSX.Element {
return (
<LegacyRoomList
onKeyDown={jest.fn()}
onFocus={jest.fn()}
onBlur={jest.fn()}
onResize={jest.fn()}
resizeNotifier={new ResizeNotifier()}
isMinimized={false}
activeSpace={MetaSpace.Home}
{...props}
/>
);
}
describe("Rooms", () => {
describe("when meta space is active", () => {
beforeEach(() => {
store.setActiveSpace(MetaSpace.Home);
});
it("does not render add room button when UIComponent customisation disables CreateRooms and ExploreRooms", () => {
const disabled: UIComponent[] = [UIComponent.CreateRooms, UIComponent.ExploreRooms];
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
expect(within(roomsList).queryByRole("button", { name: "Add room" })).not.toBeInTheDocument();
});
it("renders add room button with menu when UIComponent customisation allows CreateRooms or ExploreRooms", async () => {
let disabled: UIComponent[] = [];
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
const { rerender } = render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
const addRoomButton = within(roomsList).getByRole("button", { name: "Add room" });
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await userEvent.click(addRoomButton);
const menu = screen.getByRole("menu");
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Explore public rooms" })).toBeInTheDocument();
disabled = [UIComponent.CreateRooms];
rerender(getComponent());
expect(addRoomButton).toBeInTheDocument();
expect(menu).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "New room" })).not.toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Explore public rooms" })).toBeInTheDocument();
disabled = [UIComponent.ExploreRooms];
rerender(getComponent());
expect(addRoomButton).toBeInTheDocument();
expect(menu).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "Explore public rooms" })).not.toBeInTheDocument();
});
it("renders add room button and clicks explore public rooms", async () => {
mocked(shouldShowComponent).mockReturnValue(true);
render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
await userEvent.click(within(roomsList).getByRole("button", { name: "Add room" }));
const menu = screen.getByRole("menu");
await userEvent.click(within(menu).getByRole("menuitem", { name: "Explore public rooms" }));
expect(dis.fire).toHaveBeenCalledWith(Action.ViewRoomDirectory);
});
});
describe("when room space is active", () => {
let rooms: Room[];
const mkSpaceForRooms = (spaceId: string, children: string[] = []) =>
mkSpace(client, spaceId, rooms, children);
const space1 = "!space1:server";
beforeEach(async () => {
rooms = [];
mkSpaceForRooms(space1);
mocked(client).getRoom.mockImplementation(
(roomId) => rooms.find((room) => room.roomId === roomId) || null,
);
await testUtils.setupAsyncStoreWithClient(store, client);
store.setActiveSpace(space1);
});
it("does not render add room button when UIComponent customisation disables CreateRooms and ExploreRooms", () => {
const disabled: UIComponent[] = [UIComponent.CreateRooms, UIComponent.ExploreRooms];
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
expect(within(roomsList).queryByRole("button", { name: "Add room" })).not.toBeInTheDocument();
});
it("renders add room button with menu when UIComponent customisation allows CreateRooms or ExploreRooms", async () => {
let disabled: UIComponent[] = [];
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
const { rerender } = render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
const addRoomButton = within(roomsList).getByRole("button", { name: "Add room" });
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await userEvent.click(addRoomButton);
const menu = screen.getByRole("menu");
expect(within(menu).getByRole("menuitem", { name: "Explore rooms" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Add existing room" })).toBeInTheDocument();
disabled = [UIComponent.CreateRooms];
rerender(getComponent());
expect(addRoomButton).toBeInTheDocument();
expect(menu).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Explore rooms" })).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "New room" })).not.toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "Add existing room" })).not.toBeInTheDocument();
disabled = [UIComponent.ExploreRooms];
rerender(getComponent());
expect(addRoomButton).toBeInTheDocument();
expect(menu).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "Explore rooms" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Add existing room" })).toBeInTheDocument();
});
it("renders add room button and clicks explore rooms", async () => {
mocked(shouldShowComponent).mockReturnValue(true);
render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
await userEvent.click(within(roomsList).getByRole("button", { name: "Add room" }));
const menu = screen.getByRole("menu");
await userEvent.click(within(menu).getByRole("menuitem", { name: "Explore rooms" }));
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: space1,
});
});
});
describe("when video meta space is active", () => {
const videoRoomPrivate = "!videoRoomPrivate_server";
const videoRoomPublic = "!videoRoomPublic_server";
const videoRoomKnock = "!videoRoomKnock_server";
beforeEach(async () => {
cleanup();
const rooms: Room[] = [];
testUtils.mkRoom(client, videoRoomPrivate, rooms);
testUtils.mkRoom(client, videoRoomPublic, rooms);
testUtils.mkRoom(client, videoRoomKnock, rooms);
mocked(client).getRoom.mockImplementation(
(roomId) => rooms.find((room) => room.roomId === roomId) || null,
);
mocked(client).getRooms.mockImplementation(() => rooms);
const videoRoomKnockRoom = client.getRoom(videoRoomKnock)!;
const videoRoomPrivateRoom = client.getRoom(videoRoomPrivate)!;
const videoRoomPublicRoom = client.getRoom(videoRoomPublic)!;
[videoRoomPrivateRoom, videoRoomPublicRoom, videoRoomKnockRoom].forEach((room) => {
(room.isCallRoom as jest.Mock).mockReturnValue(true);
});
const roomLists: ITagMap = {};
roomLists[DefaultTagID.Conference] = [videoRoomKnockRoom, videoRoomPublicRoom];
roomLists[DefaultTagID.Untagged] = [videoRoomPrivateRoom];
jest.spyOn(RoomListStore.instance, "orderedLists", "get").mockReturnValue(roomLists);
await testUtils.setupAsyncStoreWithClient(store, client);
store.setActiveSpace(MetaSpace.VideoRooms);
});
it("renders Conferences and Room but no People section", () => {
const renderResult = render(getComponent({ activeSpace: MetaSpace.VideoRooms }));
const roomsEl = renderResult.getByRole("treeitem", { name: "Rooms" });
const conferenceEl = renderResult.getByRole("treeitem", { name: "Conferences" });
const noInvites = screen.queryByRole("treeitem", { name: "Invites" });
const noFavourites = screen.queryByRole("treeitem", { name: "Favourites" });
const noPeople = screen.queryByRole("treeitem", { name: "People" });
const noLowPriority = screen.queryByRole("treeitem", { name: "Low priority" });
const noHistorical = screen.queryByRole("treeitem", { name: "Historical" });
expect(roomsEl).toBeVisible();
expect(conferenceEl).toBeVisible();
expect(noInvites).toBeFalsy();
expect(noFavourites).toBeFalsy();
expect(noPeople).toBeFalsy();
expect(noLowPriority).toBeFalsy();
expect(noHistorical).toBeFalsy();
});
it("renders Public and Knock rooms in Conferences section", () => {
const renderResult = render(getComponent({ activeSpace: MetaSpace.VideoRooms }));
const conferenceList = renderResult.getByRole("group", { name: "Conferences" });
expect(queryByRole(conferenceList, "treeitem", { name: videoRoomPublic })).toBeVisible();
expect(queryByRole(conferenceList, "treeitem", { name: videoRoomKnock })).toBeVisible();
expect(queryByRole(conferenceList, "treeitem", { name: videoRoomPrivate })).toBeFalsy();
const roomsList = renderResult.getByRole("group", { name: "Rooms" });
expect(queryByRole(roomsList, "treeitem", { name: videoRoomPrivate })).toBeVisible();
expect(queryByRole(roomsList, "treeitem", { name: videoRoomPublic })).toBeFalsy();
expect(queryByRole(roomsList, "treeitem", { name: videoRoomKnock })).toBeFalsy();
});
});
});
});
@@ -1,281 +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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type MatrixClient, type Room, EventType } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { act, render, screen, fireEvent, type RenderResult } from "jest-matrix-react";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import { MetaSpace } from "../../../../../src/stores/spaces";
import _RoomListHeader from "../../../../../src/components/views/rooms/LegacyRoomListHeader";
import * as testUtils from "../../../../test-utils";
import { stubClient, mkSpace } from "../../../../test-utils";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../../src/settings/UIFeature";
const RoomListHeader = testUtils.wrapInMatrixClientContext(_RoomListHeader);
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
const blockUIComponent = (component: UIComponent): void => {
mocked(shouldShowComponent).mockImplementation((feature) => feature !== component);
};
const setupSpace = (client: MatrixClient): Room => {
const testSpace: Room = mkSpace(client, "!space:server");
testSpace.name = "Test Space";
client.getRoom = () => testSpace;
return testSpace;
};
const setupMainMenu = async (client: MatrixClient, testSpace: Room): Promise<RenderResult> => {
await testUtils.setupAsyncStoreWithClient(SpaceStore.instance, client);
act(() => {
SpaceStore.instance.setActiveSpace(testSpace.roomId);
});
const wrapper = render(<RoomListHeader />);
expect(wrapper.getByText("Test Space")).toBeInTheDocument();
wrapper.getByLabelText("Test Space menu").click();
return wrapper;
};
const setupPlusMenu = async (client: MatrixClient, testSpace: Room): Promise<RenderResult> => {
await testUtils.setupAsyncStoreWithClient(SpaceStore.instance, client);
act(() => {
SpaceStore.instance.setActiveSpace(testSpace.roomId);
});
const wrapper = render(<RoomListHeader />);
expect(wrapper.getByText("Test Space")).toBeInTheDocument();
act(() => {
wrapper.getByLabelText("Add")?.click();
});
return wrapper;
};
const checkIsDisabled = (menuItem: HTMLElement): void => {
expect(menuItem).toHaveAttribute("disabled");
expect(menuItem).toHaveAttribute("aria-disabled", "true");
};
const checkMenuLabels = (items: NodeListOf<Element>, labelArray: Array<string>) => {
expect(items).toHaveLength(labelArray.length);
const checkLabel = (item: Element, label: string) => {
expect(item.querySelector(".mx_IconizedContextMenu_label")).toHaveTextContent(label);
};
labelArray.forEach((label, index) => {
console.log("index", index, "label", label);
checkLabel(items[index], label);
});
};
describe("RoomListHeader", () => {
let client: MatrixClient;
beforeEach(async () => {
// This tests the header from the old room list/left panel, the new one is now enabled by default,
// so needs to be explicitly disabled to test the old list here.
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, false);
jest.resetAllMocks();
const dmRoomMap = {
getUserIdForRoomId: jest.fn(),
getDMRoomsForUserId: jest.fn(),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
stubClient();
client = MatrixClientPeg.safeGet();
mocked(shouldShowComponent).mockReturnValue(true); // show all UIComponents
});
it("renders a main menu for the home space", () => {
act(() => {
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
});
const { container } = render(<RoomListHeader />);
expect(container).toHaveTextContent("Home");
fireEvent.click(screen.getByLabelText("Home options"));
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
expect(items).toHaveLength(1);
expect(items[0]).toHaveTextContent("Show all rooms");
});
it("renders a main menu for spaces", async () => {
const testSpace = setupSpace(client);
await setupMainMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, ["Space home", "Manage & explore rooms", "Preferences", "Settings", "Room", "Space"]);
});
it("renders a plus menu for spaces", async () => {
const testSpace = setupSpace(client);
await setupPlusMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, ["New room", "Explore rooms", "Add existing room", "Add space"]);
});
it("closes menu if space changes from under it", async () => {
await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.DEVICE, {
[MetaSpace.Home]: true,
[MetaSpace.Favourites]: true,
});
const testSpace = setupSpace(client);
await setupMainMenu(client, testSpace);
act(() => {
SpaceStore.instance.setActiveSpace(MetaSpace.Favourites);
});
screen.getByText("Favourites");
expect(screen.queryByRole("menu")).toBeFalsy();
});
describe("UIComponents", () => {
describe("Main menu", () => {
it("does not render Add Space when user does not have permission to add spaces", async () => {
// User does not have permission to add spaces, anywhere
blockUIComponent(UIComponent.CreateSpaces);
const testSpace = setupSpace(client);
await setupMainMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, [
"Space home",
"Manage & explore rooms",
"Preferences",
"Settings",
"Room",
// no add space
]);
});
it("does not render Add Room when user does not have permission to add rooms", async () => {
// User does not have permission to add rooms
blockUIComponent(UIComponent.CreateRooms);
const testSpace = setupSpace(client);
await setupMainMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, [
"Space home",
"Explore rooms", // not Manage & explore rooms
"Preferences",
"Settings",
// no add room
"Space",
]);
});
});
describe("Plus menu", () => {
it("does not render Add Space when user does not have permission to add spaces", async () => {
// User does not have permission to add spaces, anywhere
blockUIComponent(UIComponent.CreateSpaces);
const testSpace = setupSpace(client);
await setupPlusMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, [
"New room",
"Explore rooms",
"Add existing room",
// no Add space
]);
});
it("disables Add Room when user does not have permission to add rooms", async () => {
// User does not have permission to add rooms
blockUIComponent(UIComponent.CreateRooms);
const testSpace = setupSpace(client);
await setupPlusMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll<HTMLElement>(".mx_IconizedContextMenu_item");
checkMenuLabels(items, ["New room", "Explore rooms", "Add existing room", "Add space"]);
// "Add existing room" is disabled
checkIsDisabled(items[2]);
});
});
});
describe("adding children to space", () => {
it("if user cannot add children to space, MainMenu adding buttons are hidden", async () => {
const testSpace = setupSpace(client);
mocked(testSpace.currentState.maySendStateEvent).mockImplementation(
(stateEventType, userId) => stateEventType !== EventType.SpaceChild,
);
await setupMainMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, [
"Space home",
"Explore rooms", // not Manage & explore rooms
"Preferences",
"Settings",
// no add room
// no add space
]);
});
it("if user cannot add children to space, PlusMenu add buttons are disabled", async () => {
const testSpace = setupSpace(client);
mocked(testSpace.currentState.maySendStateEvent).mockImplementation(
(stateEventType, userId) => stateEventType !== EventType.SpaceChild,
);
await setupPlusMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll<HTMLElement>(".mx_IconizedContextMenu_item");
checkMenuLabels(items, ["New room", "Explore rooms", "Add existing room", "Add space"]);
// "Add existing room" is disabled
checkIsDisabled(items[2]);
// "Add space" is disabled
checkIsDisabled(items[3]);
});
});
});
@@ -1,317 +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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, act, type RenderResult } from "jest-matrix-react";
import { mocked, type Mocked } from "jest-mock";
import {
type MatrixClient,
PendingEventOrdering,
Room,
RoomStateEvent,
type Thread,
type RoomMember,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { Widget } from "matrix-widget-api";
import {
stubClient,
mkRoomMember,
MockedCall,
useMockedCalls,
setupAsyncStoreWithClient,
filterConsole,
flushPromises,
mkMessage,
useMockMediaDevices,
} from "../../../../test-utils";
import { CallStore } from "../../../../../src/stores/CallStore";
import RoomTile from "../../../../../src/components/views/rooms/RoomTile";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import PlatformPeg from "../../../../../src/PlatformPeg";
import type BasePlatform from "../../../../../src/BasePlatform";
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
import { TestSDKContext } from "../../../TestSDKContext";
import { SDKContext } from "../../../../../src/contexts/SDKContext";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../../src/settings/UIFeature";
import { MessagePreviewStore } from "../../../../../src/stores/message-preview";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { ConnectionState } from "../../../../../src/models/Call";
import { type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
describe("RoomTile", () => {
jest.spyOn(PlatformPeg, "get").mockReturnValue({
overrideBrowserShortcuts: () => false,
} as unknown as BasePlatform);
useMockedCalls();
const renderRoomTile = (): RenderResult => {
return render(
<SDKContext.Provider value={sdkContext}>
<RoomTile
room={room}
showMessagePreview={showMessagePreview}
isMinimized={false}
tag={DefaultTagID.Untagged}
/>
</SDKContext.Provider>,
);
};
let client: Mocked<MatrixClient>;
let room: Room;
let sdkContext: TestSDKContext;
let showMessagePreview = false;
filterConsole(
// irrelevant for this test
"Room !1:example.org does not have an m.room.create event",
);
const addMessageToRoom = (ts: number) => {
const message = mkMessage({
event: true,
room: room.roomId,
msg: "test message",
user: client.getSafeUserId(),
ts,
});
room.timeline.push(message);
};
const addThreadMessageToRoom = (ts: number) => {
const message = mkMessage({
event: true,
room: room.roomId,
msg: "test thread reply",
user: client.getSafeUserId(),
ts,
});
// Mock thread reply for tests.
jest.spyOn(room, "getThreads").mockReturnValue([
// @ts-ignore
{
lastReply: () => message,
timeline: [],
} as Thread,
]);
};
beforeEach(() => {
useMockMediaDevices();
sdkContext = new TestSDKContext();
client = mocked(stubClient());
sdkContext.client = client;
DMRoomMap.makeShared(client);
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
});
afterEach(() => {
// @ts-ignore
MessagePreviewStore.instance.previews = new Map<string, Map<TagID | TAG_ANY, MessagePreview | null>>();
jest.clearAllMocks();
});
describe("when message previews are not enabled", () => {
it("should render the room", () => {
mocked(shouldShowComponent).mockReturnValue(true);
const { container } = renderRoomTile();
expect(container).toMatchSnapshot();
expect(container.querySelector(".mx_RoomTile_sticky")).not.toBeInTheDocument();
});
it("does not render the room options context menu when UIComponent customisations disable room options", () => {
mocked(shouldShowComponent).mockReturnValue(false);
renderRoomTile();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument();
});
it("renders the room options context menu when UIComponent customisations enable room options", () => {
mocked(shouldShowComponent).mockReturnValue(true);
renderRoomTile();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
expect(screen.queryByRole("button", { name: "Room options" })).toBeInTheDocument();
});
it("does not render the room options context menu when knocked to the room", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
return name === "feature_ask_to_join";
});
mocked(shouldShowComponent).mockReturnValue(true);
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Knock);
const { container } = renderRoomTile();
expect(container.querySelector(".mx_RoomTile_sticky")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument();
});
it("does not render the room options context menu when knock has been denied", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
return name === "feature_ask_to_join";
});
mocked(shouldShowComponent).mockReturnValue(true);
const roomMember = mkRoomMember(
room.roomId,
MatrixClientPeg.get()!.getSafeUserId(),
KnownMembership.Leave,
true,
{
membership: KnownMembership.Knock,
},
);
jest.spyOn(room, "getMember").mockReturnValue(roomMember);
const { container } = renderRoomTile();
expect(container.querySelector(".mx_RoomTile_sticky")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument();
});
describe("when a call starts", () => {
let call: MockedCall;
let widget: Widget;
beforeEach(() => {
setupAsyncStoreWithClient(CallStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
MockedCall.create(room, "1");
const maybeCall = CallStore.instance.getCall(room.roomId);
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
call = maybeCall;
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as WidgetMessaging);
});
afterEach(() => {
call.destroy();
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
});
it("tracks connection state", async () => {
renderRoomTile();
screen.getByText("Video");
act(() => call.setConnectionState(ConnectionState.Connected));
screen.getByText("Joined");
await act(() => call.disconnect());
screen.getByText("Video");
});
it("tracks participants", () => {
renderRoomTile();
const alice: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@alice:example.org"),
new Set(["a"]),
];
const bob: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@bob:example.org"),
new Set(["b1", "b2"]),
];
const carol: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@carol:example.org"),
new Set(["c"]),
];
expect(screen.queryByLabelText(/participant/)).toBe(null);
act(() => {
call.participants = new Map([alice]);
});
expect(screen.getByLabelText("1 person joined").textContent).toBe("1");
act(() => {
call.participants = new Map([alice, bob, carol]);
});
expect(screen.getByLabelText("4 people joined").textContent).toBe("4");
act(() => {
call.participants = new Map();
});
expect(screen.queryByLabelText(/participant/)).toBe(null);
});
});
});
describe("when message previews are enabled", () => {
beforeEach(() => {
showMessagePreview = true;
});
it("should render a room without a message as expected", async () => {
const renderResult = renderRoomTile();
// flush promises here because the preview is created asynchronously
await flushPromises();
expect(renderResult.asFragment()).toMatchSnapshot();
});
describe("and there is a message in the room", () => {
beforeEach(() => {
addMessageToRoom(23);
});
it("should render as expected", async () => {
const renderResult = renderRoomTile();
expect(await screen.findByText("test message")).toBeInTheDocument();
expect(renderResult.asFragment()).toMatchSnapshot();
});
});
describe("and there is a message in a thread", () => {
beforeEach(() => {
addThreadMessageToRoom(23);
});
it("should render as expected", async () => {
const renderResult = renderRoomTile();
expect(await screen.findByText("test thread reply")).toBeInTheDocument();
expect(renderResult.asFragment()).toMatchSnapshot();
});
});
describe("and there is a message and a thread without a reply", () => {
beforeEach(() => {
addMessageToRoom(23);
// Mock thread reply for tests.
jest.spyOn(room, "getThreads").mockReturnValue([
// @ts-ignore
{
lastReply: () => null,
timeline: [],
findEventById: () => {},
} as Thread,
]);
});
it("should render the message preview", async () => {
renderRoomTile();
expect(await screen.findByText("test message")).toBeInTheDocument();
});
});
});
});
@@ -1,39 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`ExtraTile renders 1`] = `
<DocumentFragment>
<div
aria-label="test"
class="mx_AccessibleButton mx_ExtraTile mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_RoomTile_avatarContainer"
/>
<div
class="mx_RoomTile_details"
>
<div
class="mx_RoomTile_primaryDetails"
>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title"
dir="auto"
tabindex="-1"
title="test"
>
test
</div>
</div>
<div
class="mx_RoomTile_badgeContainer"
/>
</div>
</div>
</div>
</DocumentFragment>
`;
@@ -1,378 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomTile when message previews are enabled and there is a message in a thread should render as expected 1`] = `
<DocumentFragment>
<div
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
aria-label="!1:example.org"
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_DecoratedRoomAvatar"
>
<span
class="_avatar_va14e_8 mx_BaseAvatar _avatar-imageless_va14e_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
!
</span>
</div>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title mx_RoomTile_titleWithSubtitle"
tabindex="-1"
title="!1:example.org"
>
<span
dir="auto"
>
!1:example.org
</span>
</div>
<div
class="mx_RoomTile_subtitle"
id="mx_RoomTile_messagePreview_!1:example.org"
title="test thread reply"
>
<span
class="mx_RoomTile_subtitle_text"
>
test thread reply
</span>
</div>
</div>
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Room options"
class="mx_AccessibleButton mx_RoomTile_menuButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Notification options"
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.293 17.293c.63.63.184 1.707-.707 1.707H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-7 7-7 7 7 7 7v6zM12 22a2 2 0 0 1-2-2h4a2 2 0 0 1-2 2"
/>
</svg>
</div>
</div>
</DocumentFragment>
`;
exports[`RoomTile when message previews are enabled and there is a message in the room should render as expected 1`] = `
<DocumentFragment>
<div
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
aria-label="!1:example.org Unread messages."
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_DecoratedRoomAvatar"
>
<span
class="_avatar_va14e_8 mx_BaseAvatar _avatar-imageless_va14e_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
!
</span>
</div>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title mx_RoomTile_titleWithSubtitle mx_RoomTile_titleHasUnreadEvents"
tabindex="-1"
title="!1:example.org"
>
<span
dir="auto"
>
!1:example.org
</span>
</div>
<div
class="mx_RoomTile_subtitle"
id="mx_RoomTile_messagePreview_!1:example.org"
title="test message"
>
<span
class="mx_RoomTile_subtitle_text"
>
test message
</span>
</div>
</div>
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_dot"
>
<span
class="mx_NotificationBadge_count"
/>
</div>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Room options"
class="mx_AccessibleButton mx_RoomTile_menuButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Notification options"
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.293 17.293c.63.63.184 1.707-.707 1.707H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-7 7-7 7 7 7 7v6zM12 22a2 2 0 0 1-2-2h4a2 2 0 0 1-2 2"
/>
</svg>
</div>
</div>
</DocumentFragment>
`;
exports[`RoomTile when message previews are enabled should render a room without a message as expected 1`] = `
<DocumentFragment>
<div
aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
aria-label="!1:example.org"
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_DecoratedRoomAvatar"
>
<span
class="_avatar_va14e_8 mx_BaseAvatar _avatar-imageless_va14e_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
!
</span>
</div>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title"
tabindex="-1"
title="!1:example.org"
>
<span
dir="auto"
>
!1:example.org
</span>
</div>
</div>
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Room options"
class="mx_AccessibleButton mx_RoomTile_menuButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Notification options"
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.293 17.293c.63.63.184 1.707-.707 1.707H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-7 7-7 7 7 7 7v6zM12 22a2 2 0 0 1-2-2h4a2 2 0 0 1-2 2"
/>
</svg>
</div>
</div>
</DocumentFragment>
`;
exports[`RoomTile when message previews are not enabled should render the room 1`] = `
<div>
<div
aria-label="!1:example.org"
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
tabindex="-1"
>
<div
class="mx_DecoratedRoomAvatar"
>
<span
class="_avatar_va14e_8 mx_BaseAvatar _avatar-imageless_va14e_55"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
!
</span>
</div>
<div
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title"
tabindex="-1"
title="!1:example.org"
>
<span
dir="auto"
>
!1:example.org
</span>
</div>
</div>
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Room options"
class="mx_AccessibleButton mx_RoomTile_menuButton"
role="button"
tabindex="0"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Notification options"
class="mx_AccessibleButton mx_RoomTile_notificationsButton"
role="button"
tabindex="-1"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.293 17.293c.63.63.184 1.707-.707 1.707H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-7 7-7 7 7 7 7v6zM12 22a2 2 0 0 1-2-2h4a2 2 0 0 1-2 2"
/>
</svg>
</div>
</div>
</div>
`;
@@ -4,7 +4,7 @@ exports[`<SpacePanel /> should show all activated MetaSpaces in the correct orde
<DocumentFragment>
<nav
aria-label="Spaces"
class="mx_SpacePanel collapsed newUi"
class="mx_SpacePanel collapsed"
>
<div
class="_wrapper_1yyfq_8 mx_UserMenu"
@@ -14,7 +14,6 @@ import RoomListStoreV3, {
} from "../../../src/stores/room-list-v3/RoomListStoreV3";
import { mkRoom, stubClient } from "../../test-utils/test-utils";
import { Room } from "../../../src/modules/models/Room";
import {} from "../../../src/stores/room-list/algorithms/Algorithm";
describe("StoresApi", () => {
describe("RoomListStoreApi", () => {
@@ -27,41 +27,6 @@ describe("BreadcrumbsStore", () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("does not meet room requirements if there are not enough rooms", () => {
// We don't have enough rooms, so we don't meet requirements
mocked(client.getVisibleRooms).mockReturnValue(fakeRooms(2));
expect(store.meetsRoomRequirement).toBe(false);
});
it("meets room requirements if there are enough rooms", () => {
// We do have enough rooms to show breadcrumbs
mocked(client.getVisibleRooms).mockReturnValue(fakeRooms(25));
expect(store.meetsRoomRequirement).toBe(true);
});
describe("And the feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
// Turn on feature_dynamic_room_predecessors setting
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
it("passes through the dynamic room precessors flag", () => {
mocked(client.getVisibleRooms).mockReturnValue(fakeRooms(25));
expect(store.meetsRoomRequirement).toBeTruthy();
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
});
});
describe("And the feature_dynamic_room_predecessors is not enabled", () => {
it("passes through the dynamic room precessors flag", () => {
mocked(client.getVisibleRooms).mockReturnValue(fakeRooms(25));
expect(store.meetsRoomRequirement).toBeTruthy();
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
});
});
describe("If the feature_dynamic_room_predecessors is not enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
@@ -155,17 +120,6 @@ describe("BreadcrumbsStore", () => {
await flushPromises();
}
/**
* Create as many fake rooms in an array as you ask for.
*/
function fakeRooms(howMany: number): Room[] {
const ret: Room[] = [];
for (let i = 0; i < howMany; i++) {
ret.push(fakeRoom());
}
return ret;
}
let roomIdx = 0;
function fakeRoom(): Room {
@@ -37,7 +37,7 @@ import SettingsStore from "../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Action } from "../../../src/dispatcher/actions";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import RoomListStore from "../../../src/stores/room-list/RoomListStore";
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
import { NotificationLevel } from "../../../src/stores/notifications/NotificationLevel";
@@ -141,8 +141,6 @@ describe("SpaceStore", () => {
});
afterEach(async () => {
// Disable the new room list feature flag
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, false);
await testUtils.resetAsyncStoreWithClient(store);
});
@@ -444,12 +442,6 @@ describe("SpaceStore", () => {
expect(store.isRoomInSpace(MetaSpace.Home, room1)).toBeTruthy();
});
it("favourites space does contain favourites even if they are also shown in a space", async () => {
expect(store.isRoomInSpace(MetaSpace.Favourites, fav1)).toBeTruthy();
expect(store.isRoomInSpace(MetaSpace.Favourites, fav2)).toBeTruthy();
expect(store.isRoomInSpace(MetaSpace.Favourites, fav3)).toBeTruthy();
});
it("people space does contain people even if they are also shown in a space", async () => {
expect(store.isRoomInSpace(MetaSpace.People, dm1)).toBeTruthy();
expect(store.isRoomInSpace(MetaSpace.People, dm2)).toBeTruthy();
@@ -573,27 +565,6 @@ describe("SpaceStore", () => {
});
});
it("dms are only added to Notification States for only the People Space", async () => {
[dm1, dm2, dm3].forEach((d) => {
expect(
store
.getNotificationState(MetaSpace.People)
.rooms.map((r) => r.roomId)
.includes(d),
).toBeTruthy();
});
[space1, space2, space3, MetaSpace.Home, MetaSpace.Orphans, MetaSpace.Favourites].forEach((s) => {
[dm1, dm2, dm3].forEach((d) => {
expect(
store
.getNotificationState(s)
.rooms.map((r) => r.roomId)
.includes(d),
).toBeFalsy();
});
});
});
it("orphan rooms are added to Notification States for only the Home Space", async () => {
await setShowAllRooms(false);
[orphan1, orphan2].forEach((d) => {
@@ -710,29 +681,6 @@ describe("SpaceStore", () => {
});
});
it("should add new DM Invites to the People Space Notification State", async () => {
mkRoom(dm1);
mocked(client.getRoom(dm1)!).getMyMembership.mockReturnValue(KnownMembership.Join);
mocked(client).getRoom.mockImplementation((roomId) => rooms.find((room) => room.roomId === roomId) || null);
await run();
mkRoom(dm2);
const cliDm2 = client.getRoom(dm2)!;
mocked(cliDm2).getMyMembership.mockReturnValue(KnownMembership.Invite);
mocked(client).getRoom.mockImplementation((roomId) => rooms.find((room) => room.roomId === roomId) || null);
client.emit(RoomEvent.MyMembership, cliDm2, KnownMembership.Invite);
[dm1, dm2].forEach((d) => {
expect(
store
.getNotificationState(MetaSpace.People)
.rooms.map((r) => r.roomId)
.includes(d),
).toBeTruthy();
});
});
describe("hierarchy resolution update tests", () => {
it("updates state when spaces are joined", async () => {
await run();
@@ -1199,16 +1147,15 @@ describe("SpaceStore", () => {
});
it("switch to first valid space when selected metaspace is disabled", async () => {
store.setActiveSpace(MetaSpace.People, false);
expect(store.activeSpace).toBe(MetaSpace.People);
viewRoom(room1);
store.setActiveSpace(MetaSpace.Orphans, false);
expect(store.activeSpace).toBe(MetaSpace.Orphans);
await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.DEVICE, {
[MetaSpace.Home]: false,
[MetaSpace.Favourites]: true,
[MetaSpace.People]: false,
[MetaSpace.Orphans]: true,
[MetaSpace.Home]: true,
[MetaSpace.Orphans]: false,
});
jest.runAllTimers();
expect(store.activeSpace).toBe(MetaSpace.Orphans);
expect(store.activeSpace).toBe(space1);
});
it("when switching rooms in the all rooms home space don't switch to related space", async () => {
@@ -1402,12 +1349,9 @@ describe("SpaceStore", () => {
removeListener();
});
it("Favourites and People meta spaces should not be returned when the feature_new_room_list labs flag is enabled", async () => {
// Enable the new room list
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, true);
it("Favourites and People meta spaces should not be returned", async () => {
await run();
// Favourites and People meta spaces should not be returned
// Favourites and People meta spaces are not part of the meta space order
expect(SpaceStore.instance.enabledMetaSpaces).toStrictEqual([MetaSpace.Home, MetaSpace.Orphans]);
});
@@ -1515,8 +1459,9 @@ describe("SpaceStore", () => {
const state = RoomNotificationStateStore.instance.getRoomState(room);
// @ts-ignore
state._level = NotificationLevel.Notification;
jest.spyOn(RoomListStore.instance, "orderedLists", "get").mockReturnValue({
[DefaultTagID.Untagged]: [room],
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: MetaSpace.Home,
sections: [{ tag: DefaultTagID.Untagged, rooms: [room] }],
});
// init the store
@@ -1,349 +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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
ConditionKind,
EventType,
type IPushRule,
MatrixEvent,
PendingEventOrdering,
PushRuleActionName,
Room,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked } from "jest-mock";
import defaultDispatcher, { type MatrixDispatcher } from "../../../../src/dispatcher/dispatcher";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import SettingsStore, { type CallbackFn } from "../../../../src/settings/SettingsStore";
import { ListAlgorithm, SortAlgorithm } from "../../../../src/stores/room-list/algorithms/models";
import { OrderedDefaultTagIDs, RoomUpdateCause } from "../../../../src/stores/room-list/models";
import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { flushPromises, stubClient, upsertRoomStateEvents } from "../../../test-utils";
import { DEFAULT_PUSH_RULES, makePushRule } from "../../../test-utils/pushRules";
describe("RoomListStore", () => {
const client = stubClient();
const newRoomId = "!roomid:example.com";
const roomNoPredecessorId = "!roomnopreid:example.com";
const oldRoomId = "!oldroomid:example.com";
const userId = "@user:example.com";
const createWithPredecessor = new MatrixEvent({
type: EventType.RoomCreate,
sender: userId,
room_id: newRoomId,
content: {
predecessor: { room_id: oldRoomId, event_id: "tombstone_event_id" },
},
event_id: "$create",
state_key: "",
});
const createNoPredecessor = new MatrixEvent({
type: EventType.RoomCreate,
sender: userId,
room_id: newRoomId,
content: {},
event_id: "$create",
state_key: "",
});
const predecessor = new MatrixEvent({
type: EventType.RoomPredecessor,
sender: userId,
room_id: newRoomId,
content: {
predecessor_room_id: oldRoomId,
last_known_event_id: "tombstone_event_id",
},
event_id: "$pred",
state_key: "",
});
const roomWithPredecessorEvent = new Room(newRoomId, client, userId, {});
upsertRoomStateEvents(roomWithPredecessorEvent, [predecessor]);
const roomWithCreatePredecessor = new Room(newRoomId, client, userId, {});
upsertRoomStateEvents(roomWithCreatePredecessor, [createWithPredecessor]);
const roomNoPredecessor = new Room(roomNoPredecessorId, client, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
upsertRoomStateEvents(roomNoPredecessor, [createNoPredecessor]);
const oldRoom = new Room(oldRoomId, client, userId, {});
const normalRoom = new Room("!normal:server.org", client, userId);
client.getRoom = jest.fn().mockImplementation((roomId) => {
switch (roomId) {
case newRoomId:
return roomWithCreatePredecessor;
case oldRoomId:
return oldRoom;
case normalRoom.roomId:
return normalRoom;
default:
return null;
}
});
beforeAll(async () => {
await (RoomListStore.instance as RoomListStoreClass).makeReady(client);
});
it.each(OrderedDefaultTagIDs)("defaults to importance ordering for %s=", (tagId) => {
expect(RoomListStore.instance.getTagSorting(tagId)).toBe(SortAlgorithm.Recent);
});
it.each(OrderedDefaultTagIDs)("defaults to activity ordering for %s=", (tagId) => {
expect(RoomListStore.instance.getListOrder(tagId)).toBe(ListAlgorithm.Natural);
});
function createStore(): { store: RoomListStoreClass; handleRoomUpdate: jest.Mock<any, any> } {
const fakeDispatcher = { register: jest.fn() } as unknown as MatrixDispatcher;
const store = new RoomListStoreClass(fakeDispatcher);
// @ts-ignore accessing private member to set client
store.readyStore.matrixClient = client;
const handleRoomUpdate = jest.fn();
// @ts-ignore accessing private member to mock it
store.algorithm.handleRoomUpdate = handleRoomUpdate;
return { store, handleRoomUpdate };
}
it("Removes old room if it finds a predecessor in the create event", () => {
// Given a store we can spy on
const { store, handleRoomUpdate } = createStore();
mocked(client.getRoomUpgradeHistory).mockImplementation((roomId) =>
roomId === roomWithCreatePredecessor.roomId ? [oldRoom, roomWithCreatePredecessor] : [],
);
// When we tell it we joined a new room that has an old room as
// predecessor in the create event
const payload = {
oldMembership: KnownMembership.Invite,
membership: KnownMembership.Join,
room: roomWithCreatePredecessor,
};
store.onDispatchMyMembership(payload);
// Then the old room is removed
expect(handleRoomUpdate).toHaveBeenCalledWith(oldRoom, RoomUpdateCause.RoomRemoved);
// And the new room is added
expect(handleRoomUpdate).toHaveBeenCalledWith(roomWithCreatePredecessor, RoomUpdateCause.NewRoom);
});
it("Does not remove old room if there is no predecessor in the create event", () => {
// Given a store we can spy on
const { store, handleRoomUpdate } = createStore();
// When we tell it we joined a new room with no predecessor
const payload = {
oldMembership: KnownMembership.Invite,
membership: KnownMembership.Join,
room: roomNoPredecessor,
};
store.onDispatchMyMembership(payload);
// Then the new room is added
expect(handleRoomUpdate).toHaveBeenCalledWith(roomNoPredecessor, RoomUpdateCause.NewRoom);
// And no other updates happen
expect(handleRoomUpdate).toHaveBeenCalledTimes(1);
});
it("Lists all rooms that the client says are visible", () => {
// Given 3 rooms that are visible according to the client
const room1 = new Room("!r1:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached });
const room2 = new Room("!r2:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached });
const room3 = new Room("!r3:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached });
room1.updateMyMembership(KnownMembership.Join);
room2.updateMyMembership(KnownMembership.Join);
room3.updateMyMembership(KnownMembership.Join);
DMRoomMap.makeShared(client);
const { store } = createStore();
client.getVisibleRooms = jest.fn().mockReturnValue([room1, room2, room3]);
// When we make the list of rooms
store.regenerateAllLists({ trigger: false });
// Then the list contains all 3
expect(store.orderedLists).toMatchObject({
"im.vector.fake.recent": [room1, room2, room3],
});
// We asked not to use MSC3946 when we asked the client for the visible rooms
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);
});
it("Watches the feature flag setting", () => {
jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("dyn_pred_ref");
jest.spyOn(SettingsStore, "unwatchSetting");
// When we create a store
const { store } = createStore();
// Then we watch the feature flag
expect(SettingsStore.watchSetting).toHaveBeenCalledWith(
"feature_dynamic_room_predecessors",
null,
expect.any(Function),
);
// And when we unmount it
store.componentWillUnmount();
// Then we unwatch it.
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("dyn_pred_ref");
});
it("Regenerates all lists when the feature flag is set", () => {
// Given a store allowing us to spy on any use of SettingsStore
let featureFlagValue = false;
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => featureFlagValue);
let watchCallback: CallbackFn<any> | undefined;
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_settingName, _roomId, callbackFn) => {
watchCallback = callbackFn;
return "dyn_pred_ref";
});
jest.spyOn(SettingsStore, "unwatchSetting");
const { store } = createStore();
client.getVisibleRooms = jest.fn().mockReturnValue([]);
// Sanity: no calculation has happened yet
expect(client.getVisibleRooms).toHaveBeenCalledTimes(0);
// When we calculate for the first time
store.regenerateAllLists({ trigger: false });
// Then we use the current feature flag value (false)
expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);
// But when we update the feature flag
featureFlagValue = true;
watchCallback!(
"feature_dynamic_room_predecessors",
"",
SettingLevel.DEFAULT,
featureFlagValue,
featureFlagValue,
);
// Then we recalculate and passed the updated value (true)
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
expect(client.getVisibleRooms).toHaveBeenCalledTimes(2);
});
describe("When feature_dynamic_room_predecessors = true", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
afterEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReset();
});
it("Removes old room if it finds a predecessor in the m.predecessor event", () => {
// Given a store we can spy on
const { store, handleRoomUpdate } = createStore();
// When we tell it we joined a new room that has an old room as
// predecessor in the create event
const payload = {
oldMembership: KnownMembership.Invite,
membership: KnownMembership.Join,
room: roomWithPredecessorEvent,
};
store.onDispatchMyMembership(payload);
// Then the old room is removed
expect(handleRoomUpdate).toHaveBeenCalledWith(oldRoom, RoomUpdateCause.RoomRemoved);
// And the new room is added
expect(handleRoomUpdate).toHaveBeenCalledWith(roomWithPredecessorEvent, RoomUpdateCause.NewRoom);
});
it("Passes the feature flag on to the client when asking for visible rooms", () => {
// Given a store that we can ask for a room list
DMRoomMap.makeShared(client);
const { store } = createStore();
client.getVisibleRooms = jest.fn().mockReturnValue([]);
// When we make the list of rooms
store.regenerateAllLists({ trigger: false });
// We asked to use MSC3946 when we asked the client for the visible rooms
expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);
});
});
describe("room updates", () => {
const makeStore = async () => {
const store = new RoomListStoreClass(defaultDispatcher);
await store.start();
return store;
};
describe("push rules updates", () => {
const makePushRulesEvent = (overrideRules: IPushRule[] = []): MatrixEvent => {
return new MatrixEvent({
type: EventType.PushRules,
content: {
global: {
...DEFAULT_PUSH_RULES.global,
override: overrideRules,
},
},
});
};
it("triggers a room update when room mutes have changed", async () => {
const rule = makePushRule(normalRoom.roomId, {
actions: [PushRuleActionName.DontNotify],
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }],
});
const event = makePushRulesEvent([rule]);
const previousEvent = makePushRulesEvent();
const store = await makeStore();
// @ts-ignore private property alg
const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined);
// @ts-ignore cheat and call protected fn
store.onAction({ action: "MatrixActions.accountData", event, previousEvent });
await flushPromises();
expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange);
});
it("handles when a muted room is unknown by the room list", async () => {
const rule = makePushRule(normalRoom.roomId, {
actions: [PushRuleActionName.DontNotify],
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }],
});
const unknownRoomRule = makePushRule("!unknown:server.org", {
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: "!unknown:server.org" }],
});
const event = makePushRulesEvent([unknownRoomRule, rule]);
const previousEvent = makePushRulesEvent();
const store = await makeStore();
// @ts-ignore private property alg
const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined);
// @ts-ignore cheat and call protected fn
store.onAction({ action: "MatrixActions.accountData", event, previousEvent });
await flushPromises();
// only one call to update made for normalRoom
expect(algorithmSpy).toHaveBeenCalledTimes(1);
expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange);
});
});
});
});
@@ -1,218 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
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 { mocked } from "jest-mock";
import { type Room } from "matrix-js-sdk/src/matrix";
import { SpaceWatcher } from "../../../../src/stores/room-list/SpaceWatcher";
import type { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore";
import SettingsStore from "../../../../src/settings/SettingsStore";
import SpaceStore from "../../../../src/stores/spaces/SpaceStore";
import { MetaSpace, UPDATE_HOME_BEHAVIOUR } from "../../../../src/stores/spaces";
import { stubClient, mkSpace, emitPromise, setupAsyncStoreWithClient } from "../../../test-utils";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { SpaceFilterCondition } from "../../../../src/stores/room-list/filters/SpaceFilterCondition";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
let filter: SpaceFilterCondition | null = null;
const mockRoomListStore = {
addFilter: (f: SpaceFilterCondition) => (filter = f),
removeFilter: (): void => {
filter = null;
},
} as unknown as RoomListStoreClass;
const getUserIdForRoomId = jest.fn();
const getDMRoomsForUserId = jest.fn();
// @ts-ignore
DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId };
const space1 = "!space1:server";
const space2 = "!space2:server";
describe("SpaceWatcher", () => {
stubClient();
const store = SpaceStore.instance;
const client = mocked(MatrixClientPeg.safeGet());
let rooms: Room[] = [];
const mkSpaceForRooms = (spaceId: string, children: string[] = []) => mkSpace(client, spaceId, rooms, children);
const setShowAllRooms = async (value: boolean) => {
if (store.allRoomsInHome === value) return;
await SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.DEVICE, value);
await emitPromise(store, UPDATE_HOME_BEHAVIOUR);
};
beforeEach(async () => {
filter = null;
store.removeAllListeners();
store.setActiveSpace(MetaSpace.Home);
client.getVisibleRooms.mockReturnValue((rooms = []));
mkSpaceForRooms(space1);
mkSpaceForRooms(space2);
await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.DEVICE, {
[MetaSpace.Home]: true,
[MetaSpace.Favourites]: true,
[MetaSpace.People]: true,
[MetaSpace.Orphans]: true,
});
client.getRoom.mockImplementation((roomId) => rooms.find((room) => room.roomId === roomId) || null);
await setupAsyncStoreWithClient(store, client);
});
it("initialises sanely with home behaviour", async () => {
await setShowAllRooms(false);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
});
it("initialises sanely with all behaviour", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeNull();
});
it("sets space=Home filter for all -> home transition", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
await setShowAllRooms(false);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(MetaSpace.Home);
});
it("sets filter correctly for all -> space transition", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
SpaceStore.instance.setActiveSpace(space1);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
});
it("removes filter for home -> all transition", async () => {
await setShowAllRooms(false);
new SpaceWatcher(mockRoomListStore);
await setShowAllRooms(true);
expect(filter).toBeNull();
});
it("sets filter correctly for home -> space transition", async () => {
await setShowAllRooms(false);
new SpaceWatcher(mockRoomListStore);
SpaceStore.instance.setActiveSpace(space1);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
});
it("removes filter for space -> all transition", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
SpaceStore.instance.setActiveSpace(space1);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
expect(filter).toBeNull();
});
// The new room list is now forced on, which removes the favourites and people meta spaces.
// So no need to test these transitions any more.
it("removes filter for orphans -> all transition", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
SpaceStore.instance.setActiveSpace(MetaSpace.Orphans);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(MetaSpace.Orphans);
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
expect(filter).toBeNull();
});
it("updates filter correctly for space -> home transition", async () => {
await setShowAllRooms(false);
SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(MetaSpace.Home);
});
it("updates filter correctly for space -> orphans transition", async () => {
await setShowAllRooms(false);
SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
SpaceStore.instance.setActiveSpace(MetaSpace.Orphans);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(MetaSpace.Orphans);
});
it("updates filter correctly for space -> space transition", async () => {
await setShowAllRooms(false);
SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
SpaceStore.instance.setActiveSpace(space2);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space2);
});
it("doesn't change filter when changing showAllRooms mode to true", async () => {
await setShowAllRooms(false);
SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
await setShowAllRooms(true);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
});
it("doesn't change filter when changing showAllRooms mode to false", async () => {
await setShowAllRooms(true);
SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
await setShowAllRooms(false);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter!["space"]).toBe(space1);
});
});
@@ -1,102 +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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { mocked, type MockedObject } from "jest-mock";
import { PendingEventOrdering, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { Widget } from "matrix-widget-api";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import {
stubClient,
setupAsyncStoreWithClient,
useMockedCalls,
MockedCall,
useMockMediaDevices,
} from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
import { SortAlgorithm, ListAlgorithm } from "../../../../../src/stores/room-list/algorithms/models";
import "../../../../../src/stores/room-list/RoomListStore"; // must be imported before Algorithm to avoid cycles
import { Algorithm } from "../../../../../src/stores/room-list/algorithms/Algorithm";
import { CallStore } from "../../../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
import { ConnectionState } from "../../../../../src/models/Call";
import { type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging";
describe("Algorithm", () => {
useMockedCalls();
let client: MockedObject<MatrixClient>;
let algorithm: Algorithm;
beforeEach(() => {
useMockMediaDevices();
stubClient();
client = mocked(MatrixClientPeg.safeGet());
DMRoomMap.makeShared(client);
algorithm = new Algorithm();
algorithm.start();
algorithm.populateTags(
{ [DefaultTagID.Untagged]: SortAlgorithm.Alphabetic },
{ [DefaultTagID.Untagged]: ListAlgorithm.Natural },
);
});
afterEach(() => {
algorithm.stop();
});
it("sticks rooms with calls to the top when they're connected", async () => {
const room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const roomWithCall = new Room("!2:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
client.getRoom.mockImplementation((roomId) => {
switch (roomId) {
case room.roomId:
return room;
case roomWithCall.roomId:
return roomWithCall;
default:
return null;
}
});
client.getRooms.mockReturnValue([room, roomWithCall]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
client.reEmitter.reEmit(roomWithCall, [RoomStateEvent.Events]);
for (const room of client.getRooms()) jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
algorithm.setKnownRooms(client.getRooms());
setupAsyncStoreWithClient(CallStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
MockedCall.create(roomWithCall, "1");
const call = CallStore.instance.getCall(roomWithCall.roomId);
if (!(call instanceof MockedCall)) throw new Error("Failed to create call");
const widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, roomWithCall.roomId, {
stop: () => {},
} as unknown as WidgetMessaging);
// End of setup
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
call.setConnectionState(ConnectionState.Connected);
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]);
await call.disconnect();
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
});
});
@@ -1,177 +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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mkMessage, mkRoom, stubClient } from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import "../../../../../src/stores/room-list/RoomListStore";
import { RecentAlgorithm } from "../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import { makeThreadEvent, mkThread } from "../../../../test-utils/threads";
import { DefaultTagID } from "../../../../../src/stores/room-list-v3/skip-list/tag";
describe("RecentAlgorithm", () => {
let algorithm: RecentAlgorithm;
let cli: MatrixClient;
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.safeGet();
algorithm = new RecentAlgorithm();
});
describe("getLastTs", () => {
it("returns the last ts", () => {
const room = new Room("room123", cli, "@john:matrix.org");
const event1 = mkMessage({
room: room.roomId,
msg: "Hello world!",
user: "@alice:matrix.org",
ts: 5,
event: true,
});
const event2 = mkMessage({
room: room.roomId,
msg: "Howdy!",
user: "@bob:matrix.org",
ts: 10,
event: true,
});
room.getMyMembership = () => KnownMembership.Join;
room.addLiveEvents([event1], { addToState: true });
expect(algorithm.getLastTs(room, "@jane:matrix.org")).toBe(5);
expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(5);
room.addLiveEvents([event2], { addToState: true });
expect(algorithm.getLastTs(room, "@jane:matrix.org")).toBe(10);
expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(10);
});
it("returns a fake ts for rooms without a timeline", () => {
const room = mkRoom(cli, "!new:example.org");
// @ts-ignore
room.timeline = undefined;
expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(Number.MAX_SAFE_INTEGER);
});
it("works when not a member", () => {
const room = mkRoom(cli, "!new:example.org");
room.getMyMembership.mockReturnValue(KnownMembership.Invite);
expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(Number.MAX_SAFE_INTEGER);
});
});
describe("sortRooms", () => {
it("orders rooms per last message ts", () => {
const room1 = new Room("room1", cli, "@bob:matrix.org");
const room2 = new Room("room2", cli, "@bob:matrix.org");
room1.getMyMembership = () => KnownMembership.Join;
room2.getMyMembership = () => KnownMembership.Join;
const evt = mkMessage({
room: room1.roomId,
msg: "Hello world!",
user: "@alice:matrix.org",
ts: 5,
event: true,
});
const evt2 = mkMessage({
room: room2.roomId,
msg: "Hello world!",
user: "@alice:matrix.org",
ts: 2,
event: true,
});
room1.addLiveEvents([evt], { addToState: true });
room2.addLiveEvents([evt2], { addToState: true });
expect(algorithm.sortRooms([room2, room1], DefaultTagID.Untagged)).toEqual([room1, room2]);
});
it("orders rooms without messages first", () => {
const room1 = new Room("room1", cli, "@bob:matrix.org");
const room2 = new Room("room2", cli, "@bob:matrix.org");
room1.getMyMembership = () => KnownMembership.Join;
room2.getMyMembership = () => KnownMembership.Join;
const evt = mkMessage({
room: room1.roomId,
msg: "Hello world!",
user: "@alice:matrix.org",
ts: 5,
event: true,
});
room1.addLiveEvents([evt], { addToState: true });
expect(algorithm.sortRooms([room2, room1], DefaultTagID.Untagged)).toEqual([room2, room1]);
const { events } = mkThread({
room: room1,
client: cli,
authorId: "@bob:matrix.org",
participantUserIds: ["@bob:matrix.org"],
ts: 12,
});
room1.addLiveEvents(events, { addToState: true });
});
it("orders rooms based on thread replies too", () => {
const room1 = new Room("room1", cli, "@bob:matrix.org");
const room2 = new Room("room2", cli, "@bob:matrix.org");
room1.getMyMembership = () => KnownMembership.Join;
room2.getMyMembership = () => KnownMembership.Join;
const { rootEvent, events: events1 } = mkThread({
room: room1,
client: cli,
authorId: "@bob:matrix.org",
participantUserIds: ["@bob:matrix.org"],
ts: 12,
length: 5,
});
room1.addLiveEvents(events1, { addToState: true });
const { events: events2 } = mkThread({
room: room2,
client: cli,
authorId: "@bob:matrix.org",
participantUserIds: ["@bob:matrix.org"],
ts: 14,
length: 10,
});
room2.addLiveEvents(events2, { addToState: true });
expect(algorithm.sortRooms([room1, room2], DefaultTagID.Untagged)).toEqual([room2, room1]);
const threadReply = makeThreadEvent({
user: "@bob:matrix.org",
room: room1.roomId,
event: true,
msg: `hello world`,
rootEventId: rootEvent.getId()!,
replyToEventId: rootEvent.getId()!,
// replies are 1ms after each other
ts: 50,
});
room1.addLiveEvents([threadReply], { addToState: true });
expect(algorithm.sortRooms([room1, room2], DefaultTagID.Untagged)).toEqual([room1, room2]);
});
});
});
@@ -1,363 +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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { ConditionKind, MatrixEvent, PushRuleActionName, Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { RoomNotificationStateStore } from "../../../../../../src/stores/notifications/RoomNotificationStateStore";
import { ImportanceAlgorithm } from "../../../../../../src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm";
import { SortAlgorithm } from "../../../../../../src/stores/room-list/algorithms/models";
import * as RoomNotifs from "../../../../../../src/RoomNotifs";
import { DefaultTagID } from "../../../../../../src/stores/room-list-v3/skip-list/tag";
import { RoomUpdateCause } from "../../../../../../src/stores/room-list/models";
import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel";
import { AlphabeticAlgorithm } from "../../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
import { RecentAlgorithm } from "../../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import { DEFAULT_PUSH_RULES, makePushRule } from "../../../../../test-utils/pushRules";
describe("ImportanceAlgorithm", () => {
const userId = "@alice:server.org";
const tagId = DefaultTagID.Favourite;
const makeRoom = (id: string, name: string, order?: number): Room => {
const room = new Room(id, client, userId);
room.name = name;
const tagEvent = new MatrixEvent({
type: "m.tag",
content: {
tags: {
[tagId]: {
order,
},
},
},
});
room.addTags(tagEvent);
return room;
};
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
});
const roomA = makeRoom("!aaa:server.org", "Alpha", 2);
const roomB = makeRoom("!bbb:server.org", "Bravo", 5);
const roomC = makeRoom("!ccc:server.org", "Charlie", 1);
const roomD = makeRoom("!ddd:server.org", "Delta", 4);
const roomE = makeRoom("!eee:server.org", "Echo", 3);
const roomX = makeRoom("!xxx:server.org", "Xylophone", 99);
const muteRoomARule = makePushRule(roomA.roomId, {
actions: [PushRuleActionName.DontNotify],
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomA.roomId }],
});
const muteRoomBRule = makePushRule(roomB.roomId, {
actions: [PushRuleActionName.DontNotify],
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomB.roomId }],
});
client.pushRules = {
global: {
...DEFAULT_PUSH_RULES.global,
override: [...DEFAULT_PUSH_RULES.global.override!, muteRoomARule, muteRoomBRule],
},
};
const unreadStates: Record<string, ReturnType<(typeof RoomNotifs)["determineUnreadState"]>> = {
red: { symbol: null, count: 1, level: NotificationLevel.Highlight, invited: false },
grey: { symbol: null, count: 1, level: NotificationLevel.Notification, invited: false },
none: { symbol: null, count: 0, level: NotificationLevel.None, invited: false },
};
beforeEach(() => {
jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({
symbol: null,
count: 0,
level: NotificationLevel.None,
invited: false,
});
});
const setupAlgorithm = (sortAlgorithm: SortAlgorithm, rooms?: Room[]) => {
const algorithm = new ImportanceAlgorithm(tagId, sortAlgorithm);
algorithm.setRooms(rooms || [roomA, roomB, roomC]);
return algorithm;
};
describe("When sortAlgorithm is alphabetical", () => {
const sortAlgorithm = SortAlgorithm.Alphabetic;
beforeEach(async () => {
// destroy roomMap so we can start fresh
// @ts-ignore private property
RoomNotificationStateStore.instance.roomMap = new Map<Room, RoomNotificationState>();
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
jest.spyOn(RoomNotifs, "determineUnreadState")
.mockClear()
.mockImplementation((room) => {
switch (room) {
// b and e have red notifs
case roomB:
case roomE:
return unreadStates.red;
// c is grey
case roomC:
return unreadStates.grey;
default:
return unreadStates.none;
}
});
});
it("orders rooms by alpha when they have the same notif state", () => {
jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({
symbol: null,
count: 0,
level: NotificationLevel.None,
invited: false,
});
const algorithm = setupAlgorithm(sortAlgorithm);
// sorted according to alpha
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]);
});
it("orders rooms by notification state then alpha", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
expect(algorithm.orderedRooms).toEqual([
// alpha within red
roomB,
roomE,
// grey
roomC,
// alpha within none
roomA,
roomD,
]);
});
describe("handleRoomUpdate", () => {
it("removes a room", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomB, roomC]);
// no re-sorting on a remove
expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
});
it("warns and returns without change when removing a room that is not indexed", () => {
jest.spyOn(logger, "warn").mockReturnValue(undefined);
const algorithm = setupAlgorithm(sortAlgorithm);
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved);
expect(shouldTriggerUpdate).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`);
});
it("adds a new room", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom);
expect(shouldTriggerUpdate).toBe(true);
// inserted according to notif state
expect(algorithm.orderedRooms).toEqual([roomB, roomE, roomC, roomA]);
// only sorted within category
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomB, roomE], tagId);
});
it("throws for an unhandled update cause", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
expect(() =>
algorithm.handleRoomUpdate(roomA, "something unexpected" as unknown as RoomUpdateCause),
).toThrow("Unsupported update cause: something unexpected");
});
it("ignores a mute change", () => {
// muted rooms are not pushed to the bottom when sort is alpha
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange);
expect(shouldTriggerUpdate).toBe(false);
// no sorting
expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
});
describe("time and read receipt updates", () => {
it("throws for when a room is not indexed", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
expect(() => algorithm.handleRoomUpdate(roomX, RoomUpdateCause.Timeline)).toThrow(
`Room ${roomX.roomId} has no index in ${tagId}`,
);
});
it("re-sorts category when updated room has not changed category", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.Timeline);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomB, roomE, roomC, roomA, roomD]);
// only sorted within category
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1);
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomB, roomE], tagId);
});
it("re-sorts category when updated room has changed category", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
// change roomE to unreadState.none
jest.spyOn(RoomNotifs, "determineUnreadState").mockImplementation((room) => {
switch (room) {
// b and e have red notifs
case roomB:
return unreadStates.red;
// c is grey
case roomC:
return unreadStates.grey;
case roomE:
default:
return unreadStates.none;
}
});
// @ts-ignore don't bother mocking rest of emit properties
roomE.emit(RoomEvent.Timeline, new MatrixEvent({ type: "whatever", room_id: roomE.roomId }));
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.Timeline);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomB, roomC, roomA, roomD, roomE]);
// only sorted within roomE's new category
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1);
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomD, roomE], tagId);
});
});
});
});
describe("When sortAlgorithm is recent", () => {
const sortAlgorithm = SortAlgorithm.Recent;
// mock recent algorithm sorting
const fakeRecentOrder = [roomC, roomB, roomE, roomD, roomA];
beforeEach(async () => {
// destroy roomMap so we can start fresh
// @ts-ignore private property
RoomNotificationStateStore.instance.roomMap = new Map<Room, RoomNotificationState>();
jest.spyOn(RecentAlgorithm.prototype, "sortRooms")
.mockClear()
.mockImplementation((rooms: Room[]) =>
fakeRecentOrder.filter((sortedRoom) => rooms.includes(sortedRoom)),
);
jest.spyOn(RoomNotifs, "determineUnreadState")
.mockClear()
.mockImplementation((room) => {
switch (room) {
// b, c and e have red notifs
case roomB:
case roomE:
case roomC:
return unreadStates.red;
default:
return unreadStates.none;
}
});
});
it("orders rooms by recent when they have the same notif state", () => {
jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({
symbol: null,
count: 0,
level: NotificationLevel.None,
invited: false,
});
const algorithm = setupAlgorithm(sortAlgorithm);
// sorted according to recent
expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomA]);
});
it("orders rooms by notification state then recent", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
expect(algorithm.orderedRooms).toEqual([
// recent within red
roomC,
roomE,
// recent within none
roomD,
// muted
roomB,
roomA,
]);
});
describe("handleRoomUpdate", () => {
it("removes a room", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomC, roomB]);
// no re-sorting on a remove
expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
});
it("warns and returns without change when removing a room that is not indexed", () => {
jest.spyOn(logger, "warn").mockReturnValue(undefined);
const algorithm = setupAlgorithm(sortAlgorithm);
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved);
expect(shouldTriggerUpdate).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`);
});
it("adds a new room", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom);
expect(shouldTriggerUpdate).toBe(true);
// inserted according to notif state and mute
expect(algorithm.orderedRooms).toEqual([roomC, roomE, roomB, roomA]);
// only sorted within category
expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomE, roomC], tagId);
});
it("re-sorts on a mute change", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange);
expect(shouldTriggerUpdate).toBe(true);
expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomC, roomE], tagId);
});
});
});
});
@@ -1,282 +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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { ConditionKind, EventType, MatrixEvent, PushRuleActionName, Room, ClientEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { NaturalAlgorithm } from "../../../../../../src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm";
import { SortAlgorithm } from "../../../../../../src/stores/room-list/algorithms/models";
import { DefaultTagID } from "../../../../../../src/stores/room-list-v3/skip-list/tag";
import { RoomUpdateCause } from "../../../../../../src/stores/room-list/models";
import { AlphabeticAlgorithm } from "../../../../../../src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm";
import { RecentAlgorithm } from "../../../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import { RoomNotificationStateStore } from "../../../../../../src/stores/notifications/RoomNotificationStateStore";
import * as RoomNotifs from "../../../../../../src/RoomNotifs";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../../../test-utils";
import { DEFAULT_PUSH_RULES, makePushRule } from "../../../../../test-utils/pushRules";
import { NotificationLevel } from "../../../../../../src/stores/notifications/NotificationLevel";
describe("NaturalAlgorithm", () => {
const userId = "@alice:server.org";
const tagId = DefaultTagID.Favourite;
const makeRoom = (id: string, name: string): Room => {
const room = new Room(id, client, userId);
room.name = name;
return room;
};
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
});
const roomA = makeRoom("!aaa:server.org", "Alpha");
const roomB = makeRoom("!bbb:server.org", "Bravo");
const roomC = makeRoom("!ccc:server.org", "Charlie");
const roomD = makeRoom("!ddd:server.org", "Delta");
const roomE = makeRoom("!eee:server.org", "Echo");
const roomX = makeRoom("!xxx:server.org", "Xylophone");
const muteRoomARule = makePushRule(roomA.roomId, {
actions: [PushRuleActionName.DontNotify],
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomA.roomId }],
});
const muteRoomDRule = makePushRule(roomD.roomId, {
actions: [PushRuleActionName.DontNotify],
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomD.roomId }],
});
client.pushRules = {
global: {
...DEFAULT_PUSH_RULES.global,
override: [...DEFAULT_PUSH_RULES.global!.override!, muteRoomARule, muteRoomDRule],
},
};
const setupAlgorithm = (sortAlgorithm: SortAlgorithm, rooms?: Room[]) => {
const algorithm = new NaturalAlgorithm(tagId, sortAlgorithm);
algorithm.setRooms(rooms || [roomA, roomB, roomC]);
return algorithm;
};
describe("When sortAlgorithm is alphabetical", () => {
const sortAlgorithm = SortAlgorithm.Alphabetic;
beforeEach(async () => {
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
});
it("orders rooms by alpha", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
// sorted according to alpha
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]);
});
describe("handleRoomUpdate", () => {
it("removes a room", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomB, roomC]);
});
it("warns when removing a room that is not indexed", () => {
jest.spyOn(logger, "warn").mockReturnValue(undefined);
const algorithm = setupAlgorithm(sortAlgorithm);
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved);
expect(shouldTriggerUpdate).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`);
});
it("adds a new room", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC, roomE]);
// only sorted within category
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith(
[roomA, roomB, roomC, roomE],
tagId,
);
});
it("adds a new muted room", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomA, roomB, roomE]);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.NewRoom);
expect(shouldTriggerUpdate).toBe(true);
// muted room mixed in main category
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomD, roomE]);
// only sorted within category
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1);
});
it("ignores a mute change update", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.PossibleMuteChange);
expect(shouldTriggerUpdate).toBe(false);
expect(AlphabeticAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
});
it("throws for an unhandled update cause", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
expect(() =>
algorithm.handleRoomUpdate(roomA, "something unexpected" as unknown as RoomUpdateCause),
).toThrow("Unsupported update cause: something unexpected");
});
describe("time and read receipt updates", () => {
it("handles when a room is not indexed", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomX, RoomUpdateCause.Timeline);
// for better or worse natural alg sets this to true
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]);
});
it("re-sorts rooms when timeline updates", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(AlphabeticAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.Timeline);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomA, roomB, roomC]);
// only sorted within category
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1);
expect(AlphabeticAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomB, roomC], tagId);
});
});
});
});
describe("When sortAlgorithm is recent", () => {
const sortAlgorithm = SortAlgorithm.Recent;
// mock recent algorithm sorting
const fakeRecentOrder = [roomC, roomA, roomB, roomD, roomE];
beforeEach(async () => {
// destroy roomMap so we can start fresh
// @ts-ignore private property
RoomNotificationStateStore.instance.roomMap = new Map<Room, RoomNotificationState>();
jest.spyOn(RecentAlgorithm.prototype, "sortRooms")
.mockClear()
.mockImplementation((rooms: Room[]) =>
fakeRecentOrder.filter((sortedRoom) => rooms.includes(sortedRoom)),
);
jest.spyOn(RoomNotifs, "determineUnreadState").mockReturnValue({
symbol: null,
count: 0,
level: NotificationLevel.None,
invited: false,
});
});
it("orders rooms by recent with muted rooms to the bottom", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
// sorted according to recent
expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomA]);
});
describe("handleRoomUpdate", () => {
it("removes a room", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomA, RoomUpdateCause.RoomRemoved);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([roomC, roomB]);
// no re-sorting on a remove
expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
});
it("warns and returns without change when removing a room that is not indexed", () => {
jest.spyOn(logger, "warn").mockReturnValue(undefined);
const algorithm = setupAlgorithm(sortAlgorithm);
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomD, RoomUpdateCause.RoomRemoved);
expect(shouldTriggerUpdate).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(`Tried to remove unknown room from ${tagId}: ${roomD.roomId}`);
});
it("adds a new room", () => {
const algorithm = setupAlgorithm(sortAlgorithm);
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.NewRoom);
expect(shouldTriggerUpdate).toBe(true);
// inserted according to mute then recentness
expect(algorithm.orderedRooms).toEqual([roomC, roomB, roomE, roomA]);
// only sorted within category, muted roomA is not resorted
expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomC, roomB, roomE], tagId);
});
it("does not re-sort on possible mute change when room did not change effective mutedness", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange);
expect(shouldTriggerUpdate).toBe(false);
expect(RecentAlgorithm.prototype.sortRooms).not.toHaveBeenCalled();
});
it("re-sorts on a mute change", () => {
const algorithm = setupAlgorithm(sortAlgorithm, [roomC, roomB, roomE, roomD, roomA]);
jest.spyOn(RecentAlgorithm.prototype, "sortRooms").mockClear();
// mute roomE
const muteRoomERule = makePushRule(roomE.roomId, {
actions: [PushRuleActionName.DontNotify],
conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: roomE.roomId }],
});
const pushRulesEvent = new MatrixEvent({ type: EventType.PushRules });
client.pushRules!.global!.override!.push(muteRoomERule);
client.emit(ClientEvent.AccountData, pushRulesEvent);
const shouldTriggerUpdate = algorithm.handleRoomUpdate(roomE, RoomUpdateCause.PossibleMuteChange);
expect(shouldTriggerUpdate).toBe(true);
expect(algorithm.orderedRooms).toEqual([
// unmuted, sorted by recent
roomC,
roomB,
// muted, sorted by recent
roomA,
roomD,
roomE,
]);
// only sorted muted category
expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledTimes(1);
expect(RecentAlgorithm.prototype.sortRooms).toHaveBeenCalledWith([roomA, roomD, roomE], tagId);
});
});
});
});
@@ -1,198 +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 OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { type Room } from "matrix-js-sdk/src/matrix";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { FILTER_CHANGED } from "../../../../../src/stores/room-list/filters/IFilterCondition";
import { SpaceFilterCondition } from "../../../../../src/stores/room-list/filters/SpaceFilterCondition";
import { MetaSpace, type SpaceKey } from "../../../../../src/stores/spaces";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
jest.mock("../../../../../src/settings/SettingsStore");
jest.mock("../../../../../src/stores/spaces/SpaceStore", () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const EventEmitter = require("events");
class MockSpaceStore extends EventEmitter {
isRoomInSpace = jest.fn();
getSpaceFilteredUserIds = jest.fn().mockReturnValue(new Set<string>([]));
getSpaceFilteredRoomIds = jest.fn().mockReturnValue(new Set<string>([]));
}
return { instance: new MockSpaceStore() };
});
const SettingsStoreMock = mocked(SettingsStore);
const SpaceStoreInstanceMock = mocked(SpaceStore.instance);
jest.useFakeTimers();
describe("SpaceFilterCondition", () => {
const space1 = "!space1:server";
const space2 = "!space2:server";
const room1Id = "!r1:server";
const room2Id = "!r2:server";
const room3Id = "!r3:server";
const user1Id = "@u1:server";
const user2Id = "@u2:server";
const user3Id = "@u3:server";
const makeMockGetValue =
(settings: Record<string, any> = {}) =>
(settingName: string, space: SpaceKey) =>
settings[settingName]?.[space] || false;
beforeEach(() => {
jest.resetAllMocks();
SettingsStoreMock.getValue.mockClear().mockImplementation(makeMockGetValue());
SpaceStoreInstanceMock.getSpaceFilteredUserIds.mockReturnValue(new Set([]));
SpaceStoreInstanceMock.isRoomInSpace.mockReturnValue(true);
});
const initFilter = (space: SpaceKey): SpaceFilterCondition => {
const filter = new SpaceFilterCondition();
filter.updateSpace(space);
jest.runOnlyPendingTimers();
return filter;
};
describe("isVisible", () => {
const room1 = { roomId: room1Id } as unknown as Room;
it("calls isRoomInSpace correctly", () => {
const filter = initFilter(space1);
expect(filter.isVisible(room1)).toEqual(true);
expect(SpaceStoreInstanceMock.isRoomInSpace).toHaveBeenCalledWith(space1, room1Id);
});
});
describe("onStoreUpdate", () => {
it("emits filter changed event when updateSpace is called even without changes", async () => {
const filter = new SpaceFilterCondition();
const emitSpy = jest.spyOn(filter, "emit");
filter.updateSpace(space1);
jest.runOnlyPendingTimers();
expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED);
});
describe("showPeopleInSpace setting", () => {
it("emits filter changed event when setting changes", async () => {
// init filter with setting true for space1
SettingsStoreMock.getValue.mockImplementation(
makeMockGetValue({
["Spaces.showPeopleInSpace"]: { [space1]: true },
}),
);
const filter = initFilter(space1);
const emitSpy = jest.spyOn(filter, "emit");
SettingsStoreMock.getValue.mockClear().mockImplementation(
makeMockGetValue({
["Spaces.showPeopleInSpace"]: { [space1]: false },
}),
);
SpaceStoreInstanceMock.emit(space1);
jest.runOnlyPendingTimers();
expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED);
});
it("emits filter changed event when setting is false and space changes to a meta space", async () => {
// init filter with setting true for space1
SettingsStoreMock.getValue.mockImplementation(
makeMockGetValue({
["Spaces.showPeopleInSpace"]: { [space1]: false },
}),
);
const filter = initFilter(space1);
const emitSpy = jest.spyOn(filter, "emit");
filter.updateSpace(MetaSpace.Home);
jest.runOnlyPendingTimers();
expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED);
});
});
it("does not emit filter changed event on store update when nothing changed", async () => {
const filter = initFilter(space1);
const emitSpy = jest.spyOn(filter, "emit");
SpaceStoreInstanceMock.emit(space1);
jest.runOnlyPendingTimers();
expect(emitSpy).not.toHaveBeenCalledWith(FILTER_CHANGED);
});
it("removes listener when updateSpace is called", async () => {
const filter = initFilter(space1);
filter.updateSpace(space2);
jest.runOnlyPendingTimers();
const emitSpy = jest.spyOn(filter, "emit");
// update mock so filter would emit change if it was listening to space1
SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set([room1Id]));
SpaceStoreInstanceMock.emit(space1);
jest.runOnlyPendingTimers();
// no filter changed event
expect(emitSpy).not.toHaveBeenCalledWith(FILTER_CHANGED);
});
it("removes listener when destroy is called", async () => {
const filter = initFilter(space1);
filter.destroy();
jest.runOnlyPendingTimers();
const emitSpy = jest.spyOn(filter, "emit");
// update mock so filter would emit change if it was listening to space1
SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set([room1Id]));
SpaceStoreInstanceMock.emit(space1);
jest.runOnlyPendingTimers();
// no filter changed event
expect(emitSpy).not.toHaveBeenCalledWith(FILTER_CHANGED);
});
describe("when directChildRoomIds change", () => {
beforeEach(() => {
SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set([room1Id, room2Id]));
});
const filterChangedCases = [
["room added", [room1Id, room2Id, room3Id]],
["room removed", [room1Id]],
["room swapped", [room1Id, room3Id]], // same number of rooms with changes
];
it.each(filterChangedCases)("%s", (_d, rooms) => {
const filter = initFilter(space1);
const emitSpy = jest.spyOn(filter, "emit");
SpaceStoreInstanceMock.getSpaceFilteredRoomIds.mockReturnValue(new Set(rooms));
SpaceStoreInstanceMock.emit(space1);
jest.runOnlyPendingTimers();
expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED);
});
});
describe("when user ids change", () => {
beforeEach(() => {
SpaceStoreInstanceMock.getSpaceFilteredUserIds.mockReturnValue(new Set([user1Id, user2Id]));
});
const filterChangedCases = [
["user added", [user1Id, user2Id, user3Id]],
["user removed", [user1Id]],
["user swapped", [user1Id, user3Id]], // same number of rooms with changes
];
it.each(filterChangedCases)("%s", (_d, rooms) => {
const filter = initFilter(space1);
const emitSpy = jest.spyOn(filter, "emit");
SpaceStoreInstanceMock.getSpaceFilteredUserIds.mockReturnValue(new Set(rooms));
SpaceStoreInstanceMock.emit(space1);
jest.runOnlyPendingTimers();
expect(emitSpy).toHaveBeenCalledWith(FILTER_CHANGED);
});
});
});
});