Migrate the room list view to shared components (#31921)

* Add NotificationDecoration component

Add the NotificationDecoration component to shared-components.
This is a leaf component that renders notification badges and indicators
for rooms/items including mentions, unread counts, call indicators, etc.

* Add RoomListItem component

Add the RoomListItem component to shared-components.
Includes context menu, hover menu, notification menu, and more options menu.

* Add RoomListPrimaryFilters component

Add filter chips component for filtering the room list by
unread, people, rooms, favourites, mentions, invites, and low priority.

* Update VirtualizedList component

Update VirtualizedList to support the room list virtualization requirements.

* Add RoomList component

Add RoomList component that renders a virtualized list of room items.
Includes story mocks for testing.

* Add RoomListView component

Add RoomListView component that composes RoomList with filters,
empty states, and loading skeleton.

* Export room-list components from shared-components

Add exports for RoomListView, RoomListItem, RoomListPrimaryFilters, and RoomList.
Include i18n strings for room list components.

* Add RoomListItemViewModel

Add view model for individual room list items.
Manages per-room subscriptions and updates only when specific room data changes.

* Add RoomListViewViewModel

Add view model for the room list view.
Manages room list state, filtering, keyboard navigation, and child view models.

* Integrate shared components into RoomListView

Update RoomListView to use the new ViewModels and shared components.
Includes i18n string updates for element-web.

* Remove old room list implementation

Remove old ViewModels, hooks, and view components that are now
replaced by the shared-components implementation.

* Update sliding-sync playwright test

Update test expectations for new room list implementation.

* Add figma links

* Move viewModels to the right folder

* Rename to RoomListEmptyStateView

* Update VirtualizedRoomListView naming

* Update screenshots and snapshots

* Move viewmodel tests to the right location and fix some imports

* lint

* Use unknown as an Opaque type rather than any. It discourages property access within shared components and can still be cast back in EW.

* Update screenshots for new shared component rendering params

* Make room order tests deterministic
This commit is contained in:
David Langley
2026-02-05 21:05:14 +00:00
committed by GitHub
parent 6dba71a453
commit 6da1412de8
140 changed files with 20046 additions and 5950 deletions

View File

@@ -1,58 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { renderHook, waitFor } from "jest-matrix-react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { createTestClient, mkStubRoom } from "../../../../test-utils";
import { type MessagePreview, MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore";
import { useMessagePreviewViewModel } from "../../../../../src/components/viewmodels/roomlist/MessagePreviewViewModel";
describe("MessagePreviewViewModel", () => {
let room: Room;
beforeEach(() => {
const matrixClient = createTestClient();
room = mkStubRoom("roomId", "roomName", matrixClient);
});
it("should do an initial fetch of the message preview", async () => {
// Mock the store to return some text.
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockImplementation(async (room) => {
return { text: "Hello world!" } as MessagePreview;
});
const { result: vm } = renderHook(() => useMessagePreviewViewModel(room));
// Eventually, vm.message should have the text from the store.
await waitFor(() => {
expect(vm.current.message).toEqual("Hello world!");
});
});
it("should fetch message preview again on update from store", async () => {
// Mock the store to return the text in variable message.
let message = "Hello World!";
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockImplementation(async (room) => {
return { text: message } as MessagePreview;
});
jest.spyOn(MessagePreviewStore, "getPreviewChangedEventName").mockImplementation((room) => {
return "UPDATE";
});
const { result: vm } = renderHook(() => useMessagePreviewViewModel(room));
// Let's assume the message changed.
message = "New message!";
MessagePreviewStore.instance.emit("UPDATE");
/// vm.message should be the updated message.
await waitFor(() => {
expect(vm.current.message).toEqual(message);
});
});
});

View File

@@ -1,221 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { renderHook } from "jest-matrix-react";
import { mocked } from "jest-mock";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { mkStubRoom, stubClient, withClientContextRenderOptions } from "../../../../test-utils";
import { useRoomListItemMenuViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListItemMenuViewModel";
import {
hasAccessToNotificationMenu,
hasAccessToOptionsMenu,
} from "../../../../../src/components/viewmodels/roomlist/utils";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { DefaultTagID } from "../../../../../src/stores/room-list/models";
import { useUnreadNotifications } from "../../../../../src/hooks/useUnreadNotifications";
import { NotificationLevel } from "../../../../../src/stores/notifications/NotificationLevel";
import { clearRoomNotification, setMarkedUnreadState } from "../../../../../src/utils/notifications";
import { tagRoom } from "../../../../../src/utils/room/tagRoom";
import dispatcher from "../../../../../src/dispatcher/dispatcher";
import { useNotificationState } from "../../../../../src/hooks/useRoomNotificationState";
import { RoomNotifState } from "../../../../../src/RoomNotifs";
jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(false),
hasAccessToNotificationMenu: jest.fn().mockReturnValue(false),
}));
jest.mock("../../../../../src/hooks/useUnreadNotifications", () => ({
useUnreadNotifications: jest.fn(),
}));
jest.mock("../../../../../src/hooks/useRoomNotificationState", () => ({
useNotificationState: jest.fn(),
}));
jest.mock("../../../../../src/utils/notifications", () => ({
clearRoomNotification: jest.fn(),
setMarkedUnreadState: jest.fn(),
}));
jest.mock("../../../../../src/utils/room/tagRoom", () => ({
tagRoom: jest.fn(),
}));
describe("RoomListItemMenuViewModel", () => {
let matrixClient: MatrixClient;
let room: Room;
beforeEach(() => {
matrixClient = stubClient();
room = mkStubRoom("roomId", "roomName", matrixClient);
DMRoomMap.makeShared(matrixClient);
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined);
mocked(useUnreadNotifications).mockReturnValue({ symbol: null, count: 0, level: NotificationLevel.None });
mocked(useNotificationState).mockReturnValue([RoomNotifState.AllMessages, jest.fn()]);
jest.spyOn(dispatcher, "dispatch");
});
afterEach(() => {
jest.resetAllMocks();
});
function render() {
return renderHook(() => useRoomListItemMenuViewModel(room), withClientContextRenderOptions(matrixClient));
}
it("default", () => {
const { result } = render();
expect(result.current.showMoreOptionsMenu).toBe(false);
expect(result.current.canInvite).toBe(false);
expect(result.current.isFavourite).toBe(false);
expect(result.current.canCopyRoomLink).toBe(true);
expect(result.current.canMarkAsRead).toBe(false);
expect(result.current.canMarkAsUnread).toBe(true);
});
it("should has showMoreOptionsMenu to be true", () => {
mocked(hasAccessToOptionsMenu).mockReturnValue(true);
const { result } = render();
expect(result.current.showMoreOptionsMenu).toBe(true);
});
it("should has showNotificationMenu to be true", () => {
mocked(hasAccessToNotificationMenu).mockReturnValue(true);
const { result } = render();
expect(result.current.showNotificationMenu).toBe(true);
});
it("should be able to invite", () => {
jest.spyOn(room, "canInvite").mockReturnValue(true);
const { result } = render();
expect(result.current.canInvite).toBe(true);
});
it("should be a favourite", () => {
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
const { result } = render();
expect(result.current.isFavourite).toBe(true);
});
it("should not be able to copy the room link", () => {
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue("userId");
const { result } = render();
expect(result.current.canCopyRoomLink).toBe(false);
});
it("should be able to mark as read", () => {
// Add a notification
mocked(useUnreadNotifications).mockReturnValue({
symbol: null,
count: 1,
level: NotificationLevel.Notification,
});
const { result } = render();
expect(result.current.canMarkAsRead).toBe(true);
expect(result.current.canMarkAsUnread).toBe(false);
});
it("should has isNotificationAllMessage to be true", () => {
const { result } = render();
expect(result.current.isNotificationAllMessage).toBe(true);
});
it("should has isNotificationAllMessageLoud to be true", () => {
mocked(useNotificationState).mockReturnValue([RoomNotifState.AllMessagesLoud, jest.fn()]);
const { result } = render();
expect(result.current.isNotificationAllMessageLoud).toBe(true);
});
it("should has isNotificationMentionOnly to be true", () => {
mocked(useNotificationState).mockReturnValue([RoomNotifState.MentionsOnly, jest.fn()]);
const { result } = render();
expect(result.current.isNotificationMentionOnly).toBe(true);
});
it("should has isNotificationMute to be true", () => {
mocked(useNotificationState).mockReturnValue([RoomNotifState.Mute, jest.fn()]);
const { result } = render();
expect(result.current.isNotificationMute).toBe(true);
});
// Actions
it("should mark as read", () => {
const { result } = render();
result.current.markAsRead(new Event("click"));
expect(mocked(clearRoomNotification)).toHaveBeenCalledWith(room, matrixClient);
});
it("should mark as unread", () => {
const { result } = render();
result.current.markAsUnread(new Event("click"));
expect(mocked(setMarkedUnreadState)).toHaveBeenCalledWith(room, matrixClient, true);
});
it("should tag a room as favourite", () => {
const { result } = render();
result.current.toggleFavorite(new Event("click"));
expect(mocked(tagRoom)).toHaveBeenCalledWith(room, DefaultTagID.Favourite);
});
it("should tag a room as low priority", () => {
const { result } = render();
result.current.toggleLowPriority();
expect(mocked(tagRoom)).toHaveBeenCalledWith(room, DefaultTagID.LowPriority);
});
it("should dispatch invite action", () => {
const { result } = render();
result.current.invite(new Event("click"));
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: "view_invite",
roomId: room.roomId,
});
});
it("should dispatch a copy room action", () => {
const { result } = render();
result.current.copyRoomLink(new Event("click"));
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: "copy_room",
room_id: room.roomId,
});
});
it("should dispatch forget room action", () => {
// forget room is only available for archived rooms
room.tags = { [DefaultTagID.Archived]: { order: 0 } };
const { result } = render();
result.current.leaveRoom(new Event("click"));
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: "forget_room",
room_id: room.roomId,
});
});
it("should dispatch leave room action", () => {
const { result } = render();
result.current.leaveRoom(new Event("click"));
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: "leave_room",
room_id: room.roomId,
});
});
it("should call setRoomNotifState", () => {
const setRoomNotifState = jest.fn();
mocked(useNotificationState).mockReturnValue([RoomNotifState.AllMessages, setRoomNotifState]);
const { result } = render();
result.current.setRoomNotifState(RoomNotifState.Mute);
expect(setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.Mute);
});
});

View File

@@ -1,280 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { renderHook, waitFor } from "jest-matrix-react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import dispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import { useRoomListItemViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel";
import { createTestClient, mkStubRoom, withClientContextRenderOptions } from "../../../../test-utils";
import {
hasAccessToNotificationMenu,
hasAccessToOptionsMenu,
} from "../../../../../src/components/viewmodels/roomlist/utils";
import { RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore";
import * as UseCallModule from "../../../../../src/hooks/useCall";
import { type MessagePreview, MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { useMessagePreviewToggle } from "../../../../../src/components/viewmodels/roomlist/useMessagePreviewToggle";
jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(false),
hasAccessToNotificationMenu: jest.fn().mockReturnValue(false),
}));
jest.mock("../../../../../src/components/viewmodels/roomlist/useMessagePreviewToggle", () => ({
useMessagePreviewToggle: jest.fn().mockReturnValue({ shouldShowMessagePreview: true }),
}));
describe("RoomListItemViewModel", () => {
let room: Room;
beforeEach(() => {
const matrixClient = createTestClient();
room = mkStubRoom("roomId", "roomName", matrixClient);
const dmRoomMap = {
getUserIdForRoomId: jest.fn(),
getDMRoomsForUserId: jest.fn(),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
mocked(useMessagePreviewToggle).mockReturnValue({
shouldShowMessagePreview: false,
toggleMessagePreview: jest.fn(),
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should dispatch view room action on openRoom", async () => {
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
const fn = jest.spyOn(dispatcher, "dispatch");
vm.current.openRoom();
expect(fn).toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "RoomList",
}),
);
});
it("should show context menu if user has access to options menu", async () => {
mocked(hasAccessToOptionsMenu).mockReturnValue(true);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showContextMenu).toBe(true);
});
it("should show hover menu if user has access to options menu", async () => {
mocked(hasAccessToOptionsMenu).mockReturnValue(true);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showHoverMenu).toBe(true);
});
it("should show hover menu if user has access to notification menu", async () => {
mocked(hasAccessToNotificationMenu).mockReturnValue(true);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showHoverMenu).toBe(true);
});
it("should not show hover menu if user has an invitation notification", async () => {
mocked(hasAccessToOptionsMenu).mockReturnValue(true);
const notificationState = new RoomNotificationState(room, false);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
jest.spyOn(notificationState, "invited", "get").mockReturnValue(false);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showHoverMenu).toBe(true);
});
it("should return a message preview if one is available and they are enabled", async () => {
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Message look like this",
} as MessagePreview);
mocked(useMessagePreviewToggle).mockReturnValue({
shouldShowMessagePreview: true,
toggleMessagePreview: jest.fn(),
});
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
await waitFor(() => expect(vm.current.messagePreview).toBe("Message look like this"));
});
it("should hide message previews when disabled", async () => {
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Message look like this",
} as MessagePreview);
const { result: vm, rerender } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
// This doesn't seem to test that the hook actually triggers an update,
// but I can't see how to test that.
rerender();
expect(vm.current.messagePreview).toBe(undefined);
});
it("should check message preview when room change", async () => {
const otherRoom = mkStubRoom("roomId2", "roomName2", room.client);
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Message look like this",
} as MessagePreview);
mocked(useMessagePreviewToggle).mockReturnValue({
shouldShowMessagePreview: true,
toggleMessagePreview: jest.fn(),
});
const { result: vm, rerender } = renderHook((props) => useRoomListItemViewModel(props), {
initialProps: room,
...withClientContextRenderOptions(room.client),
});
await waitFor(() => expect(vm.current.messagePreview).toBe("Message look like this"));
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
rerender(otherRoom);
await waitFor(() => expect(vm.current.messagePreview).toBe(undefined));
});
describe("notification", () => {
let notificationState: RoomNotificationState;
beforeEach(() => {
notificationState = new RoomNotificationState(room, false);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
});
it("should show notification decoration if there is call has participant", () => {
jest.spyOn(UseCallModule, "useParticipantCount").mockReturnValue(1);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showNotificationDecoration).toBe(true);
});
it.each([
{
label: "hasAnyNotificationOrActivity",
mock: () => jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true),
},
{ label: "muted", mock: () => jest.spyOn(notificationState, "muted", "get").mockReturnValue(true) },
])("should show notification decoration if $label=true", ({ mock }) => {
mock();
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.showNotificationDecoration).toBe(true);
});
it("should be bold if there is a notification", () => {
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.isBold).toBe(true);
});
it("should recompute notification state when room changes", () => {
const newRoom = mkStubRoom("room2", "Room 2", room.client);
const newNotificationState = new RoomNotificationState(newRoom, false);
const { result, rerender } = renderHook((room) => useRoomListItemViewModel(room), {
...withClientContextRenderOptions(room.client),
initialProps: room,
});
expect(result.current.showNotificationDecoration).toBe(false);
jest.spyOn(newNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(newNotificationState);
rerender(newRoom);
expect(result.current.showNotificationDecoration).toBe(true);
});
});
describe("a11yLabel", () => {
let notificationState: RoomNotificationState;
beforeEach(() => {
notificationState = new RoomNotificationState(room, false);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
});
it.each([
{
label: "unsent message",
mock: () => jest.spyOn(notificationState, "isUnsentMessage", "get").mockReturnValue(true),
expected: "Open room roomName with an unsent message.",
},
{
label: "invitation",
mock: () => jest.spyOn(notificationState, "invited", "get").mockReturnValue(true),
expected: "Open room roomName invitation.",
},
{
label: "mention",
mock: () => {
jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true);
jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
},
expected: "Open room roomName with 3 unread messages including mentions.",
},
{
label: "unread",
mock: () => {
jest.spyOn(notificationState, "hasUnreadCount", "get").mockReturnValue(true);
jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
},
expected: "Open room roomName with 3 unread messages.",
},
{
label: "default",
expected: "Open room roomName",
},
])("should return the $label label", ({ mock, expected }) => {
mock?.();
const { result: vm } = renderHook(
() => useRoomListItemViewModel(room),
withClientContextRenderOptions(room.client),
);
expect(vm.current.a11yLabel).toBe(expected);
});
});
});

View File

@@ -1,341 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { range } from "lodash";
import { act, renderHook, waitFor } from "jest-matrix-react";
import { mocked } from "jest-mock";
import RoomListStoreV3, { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list-v3/RoomListStoreV3";
import { mkStubRoom } from "../../../../test-utils";
import { useRoomListViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filters";
import { hasCreateRoomRights, createRoom } from "../../../../../src/components/viewmodels/roomlist/utils";
import dispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../../../../src/stores/spaces";
jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
hasCreateRoomRights: jest.fn().mockReturnValue(false),
createRoom: jest.fn(),
}));
describe("RoomListViewModel", () => {
function mockAndCreateRooms() {
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
const fn = jest
.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace")
.mockImplementation(() => ({ spaceId: "home", rooms: [...rooms] }));
return { rooms, fn };
}
afterEach(() => {
jest.restoreAllMocks();
});
it("should return a list of rooms", async () => {
const { rooms } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.roomsResult.rooms).toHaveLength(10);
for (const room of rooms) {
expect(vm.current.roomsResult.rooms).toContain(room);
}
});
it("should update list of rooms on event from room list store", async () => {
const { rooms } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
const newRoom = mkStubRoom("bar:matrix.org", "Bar", undefined);
rooms.push(newRoom);
await act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
await waitFor(() => {
expect(vm.current.roomsResult.rooms).toContain(newRoom);
});
});
describe("Filters", () => {
it("should provide list of available filters", () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// should have 6 filters
expect(vm.current.primaryFilters).toHaveLength(7);
// check the order
for (const [i, name] of [
"Unreads",
"People",
"Rooms",
"Favourites",
"Mentions",
"Invites",
"Low priority",
].entries()) {
expect(vm.current.primaryFilters[i].name).toEqual(name);
expect(vm.current.primaryFilters[i].active).toEqual(false);
}
});
it("should get filtered rooms from RLS on toggle", () => {
const { fn } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's say we toggle the People toggle
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
act(() => {
vm.current.primaryFilters[i].toggle();
});
expect(fn).toHaveBeenCalledWith([FilterKey.PeopleFilter]);
expect(vm.current.primaryFilters[i].active).toEqual(true);
});
it("should change active property on toggle", () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's say we toggle the People filter
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
expect(vm.current.primaryFilters[i].active).toEqual(false);
act(() => {
vm.current.primaryFilters[i].toggle();
});
expect(vm.current.primaryFilters[i].active).toEqual(true);
// Let's say that we toggle the Favourite filter
const j = vm.current.primaryFilters.findIndex((f) => f.name === "Favourites");
act(() => {
vm.current.primaryFilters[j].toggle();
});
expect(vm.current.primaryFilters[i].active).toEqual(false);
expect(vm.current.primaryFilters[j].active).toEqual(true);
});
it("should return the current active primary filter", async () => {
// Let's say that the user's preferred sorting is alphabetic
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Toggle people filter
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
expect(vm.current.primaryFilters[i].active).toEqual(false);
act(() => vm.current.primaryFilters[i].toggle());
// The active primary filter should be the People filter
expect(vm.current.activePrimaryFilter).toEqual(vm.current.primaryFilters[i]);
});
it("should not remove all filters when active space is changed", async () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's first toggle the People filter
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
act(() => {
vm.current.primaryFilters[i].toggle();
});
expect(vm.current.primaryFilters[i].active).toEqual(true);
// Simulate a space change
await act(() => SpaceStore.instance.emit(UPDATE_SELECTED_SPACE));
// Primary filter should remain unchanged
expect(vm.current.activePrimaryFilter?.name).toEqual("People");
});
});
describe("Create room and chat", () => {
it("should be canCreateRoom=false if hasCreateRoomRights=false", () => {
mocked(hasCreateRoomRights).mockReturnValue(false);
const { result } = renderHook(() => useRoomListViewModel());
expect(result.current.canCreateRoom).toBe(false);
});
it("should be canCreateRoom=true if hasCreateRoomRights=true", () => {
mocked(hasCreateRoomRights).mockReturnValue(true);
const { result } = renderHook(() => useRoomListViewModel());
expect(result.current.canCreateRoom).toBe(true);
});
it("should call createRoom", () => {
const { result } = renderHook(() => useRoomListViewModel());
result.current.createRoom();
expect(mocked(createRoom)).toHaveBeenCalled();
});
it("should dispatch Action.CreateChat", () => {
const spy = jest.spyOn(dispatcher, "fire");
const { result } = renderHook(() => useRoomListViewModel());
result.current.createChatRoom();
expect(spy).toHaveBeenCalledWith(Action.CreateChat);
});
});
describe("Sticky room and active index", () => {
function expectActiveRoom(vm: ReturnType<typeof useRoomListViewModel>, i: number, roomId: string) {
expect(vm.activeIndex).toEqual(i);
expect(vm.roomsResult.rooms[i].roomId).toEqual(roomId);
}
it("active index is calculated with the last opened room in a space", () => {
// Let's say there's two spaces: !space1:matrix.org and !space2:matrix.org
// Let's also say that the current active space is !space1:matrix.org
let currentSpace = "!space1:matrix.org";
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => currentSpace);
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
// Let's say all the rooms are in space1
const roomsInSpace1 = { spaceId: currentSpace, rooms: [...rooms] };
// Let's say all rooms with even index are in space 2
const roomsInSpace2 = { spaceId: "!space2:matrix.org", rooms: [...rooms].filter((_, i) => i % 2 === 0) };
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockImplementation(() =>
currentSpace === "!space1:matrix.org" ? roomsInSpace1 : roomsInSpace2,
);
// Let's say that the room at index 4 is currently active
const roomId = rooms[4].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.activeIndex).toEqual(4);
// Let's say that space is changed to "!space2:matrix.org"
currentSpace = "!space2:matrix.org";
// Let's say that room[6] is active in space 2
const activeRoomIdInSpace2 = rooms[6].roomId;
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockImplementation(
() => activeRoomIdInSpace2,
);
act(() => {
RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT);
});
// Active index should be 3 even without the room change event.
expectActiveRoom(vm.current, 3, activeRoomIdInSpace2);
});
it("active room and active index are retained on order change", () => {
const { rooms } = mockAndCreateRooms();
// Let's say that the room at index 5 is active
const roomId = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.activeIndex).toEqual(5);
// Let's say that room at index 9 moves to index 5
const room9 = rooms[9];
rooms.splice(9, 1);
rooms.splice(5, 0, room9);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
// Active room index should still be 5
expectActiveRoom(vm.current, 5, roomId);
// Let's add 2 new rooms from index 0
const newRoom1 = mkStubRoom("bar1:matrix.org", "Bar 1", undefined);
const newRoom2 = mkStubRoom("bar2:matrix.org", "Bar 2", undefined);
rooms.unshift(newRoom1, newRoom2);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
// Active room index should still be 5
expectActiveRoom(vm.current, 5, roomId);
});
it("active room and active index are updated when another room is opened", () => {
const { rooms } = mockAndCreateRooms();
const roomId = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expectActiveRoom(vm.current, 5, roomId);
// Let's say that room at index 9 becomes active
const room = rooms[9];
act(() => {
dispatcher.dispatch(
{
action: Action.ActiveRoomChanged,
oldRoomId: null,
newRoomId: room.roomId,
},
true,
);
});
// Active room index should change to reflect new room
expectActiveRoom(vm.current, 9, room.roomId);
});
it("active room and active index are updated when active index spills out of rooms array bounds", () => {
const { rooms } = mockAndCreateRooms();
// Let's say that the room at index 5 is active
const roomId = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expectActiveRoom(vm.current, 5, roomId);
// Let's say that we remove rooms from the start of the array
for (let i = 0; i < 4; ++i) {
// We should be able to do 4 deletions before we run out of rooms
rooms.splice(0, 1);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
expectActiveRoom(vm.current, 5, roomId);
}
// If we remove one more room from the start, there's not going to be enough rooms
// to maintain the active index.
rooms.splice(0, 1);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
expectActiveRoom(vm.current, 0, roomId);
});
it("active room and active index are retained when rooms that appear after the active room are deleted", () => {
const { rooms } = mockAndCreateRooms();
// Let's say that the room at index 5 is active
const roomId = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expectActiveRoom(vm.current, 5, roomId);
// Let's say that we remove rooms from the start of the array
for (let i = 0; i < 4; ++i) {
// Deleting rooms after index 5 (active) should not update the active index
rooms.splice(6, 1);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
expectActiveRoom(vm.current, 5, roomId);
}
});
it("active room index becomes undefined when active room is deleted", () => {
const { rooms } = mockAndCreateRooms();
// Let's say that the room at index 5 is active
let roomId: string | null = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expectActiveRoom(vm.current, 5, roomId);
// Let's remove the active room (i.e room at index 5)
rooms.splice(5, 1);
roomId = null;
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
expect(vm.current.activeIndex).toBeUndefined();
});
it("active room index is initially undefined", () => {
mockAndCreateRooms();
// Let's say that there's no active room currently
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => null);
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.activeIndex).toEqual(undefined);
});
});
});

View File

@@ -1,152 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { renderHook } from "jest-matrix-react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { waitFor } from "@testing-library/dom";
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
import dispatcher from "../../../../../src/dispatcher/dispatcher";
import { mkStubRoom, stubClient } from "../../../../test-utils";
import { useRoomListNavigation } from "../../../../../src/components/viewmodels/roomlist/useRoomListNavigation";
import { Action } from "../../../../../src/dispatcher/actions";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore";
import { type RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState";
describe("useRoomListNavigation", () => {
let rooms: Room[];
beforeEach(() => {
const matrixClient = stubClient();
rooms = [
mkStubRoom("room1", "Room 1", matrixClient),
mkStubRoom("room2", "Room 2", matrixClient),
mkStubRoom("room3", "Room 3", matrixClient),
];
DMRoomMap.makeShared(matrixClient);
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined);
jest.spyOn(dispatcher, "dispatch");
});
afterEach(() => {
jest.clearAllMocks();
});
it("should navigate to the next room based on delta", async () => {
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room1");
renderHook(() => useRoomListNavigation(rooms));
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: 1,
unread: false,
});
await waitFor(() =>
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: "room2",
show_room_tile: true,
metricsTrigger: "WebKeyboardShortcut",
metricsViaKeyboard: true,
}),
);
});
it("should navigate to the previous room based on delta", async () => {
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room2");
renderHook(() => useRoomListNavigation(rooms));
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: -1,
unread: false,
});
await waitFor(() =>
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: "room1",
show_room_tile: true,
metricsTrigger: "WebKeyboardShortcut",
metricsViaKeyboard: true,
}),
);
});
it("should wrap around to the first room when navigating past the last room", async () => {
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room3");
renderHook(() => useRoomListNavigation(rooms));
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: 1,
unread: false,
});
await waitFor(() =>
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: "room1",
show_room_tile: true,
metricsTrigger: "WebKeyboardShortcut",
metricsViaKeyboard: true,
}),
);
});
it("should wrap around to the last room when navigating before the first room", async () => {
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room1");
renderHook(() => useRoomListNavigation(rooms));
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: -1,
unread: false,
});
await waitFor(() =>
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: "room3",
show_room_tile: true,
metricsTrigger: "WebKeyboardShortcut",
metricsViaKeyboard: true,
}),
);
});
it("should filter rooms to only unread when unread=true", async () => {
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("room1");
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockImplementation(
(room) =>
({
isUnread: room.roomId !== "room1",
}) as RoomNotificationState,
);
renderHook(() => useRoomListNavigation(rooms));
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: 1,
unread: true,
});
await waitFor(() =>
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: "room2",
show_room_tile: true,
metricsTrigger: "WebKeyboardShortcut",
metricsViaKeyboard: true,
}),
);
});
});

View File

@@ -1,92 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { EmptyRoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/EmptyRoomList";
import { FilterKey } from "../../../../../../src/stores/room-list-v3/skip-list/filters";
describe("<EmptyRoomList />", () => {
let vm: RoomListViewState;
beforeEach(() => {
vm = {
isLoadingRooms: false,
roomsResult: { spaceId: "home", rooms: [] },
primaryFilters: [],
createRoom: jest.fn(),
createChatRoom: jest.fn(),
canCreateRoom: true,
activeIndex: undefined,
};
});
test("should render the default placeholder when there is no filter", async () => {
const user = userEvent.setup();
const { asFragment } = render(<EmptyRoomList vm={vm} />);
expect(screen.getByText("No chats yet")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "Start chat" }));
expect(vm.createChatRoom).toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "New room" }));
expect(vm.createRoom).toHaveBeenCalled();
});
test("should not render the new room button if the user doesn't have the rights to create a room", async () => {
const newState = { ...vm, canCreateRoom: false };
const { asFragment } = render(<EmptyRoomList vm={newState} />);
expect(screen.queryByRole("button", { name: "New room" })).toBeNull();
expect(asFragment()).toMatchSnapshot();
});
it.each([
{ key: FilterKey.UnreadFilter, name: "unread", action: "Show all chats" },
{ key: FilterKey.MentionsFilter, name: "mention", action: "See all activity" },
{ key: FilterKey.InvitesFilter, name: "invite", action: "See all activity" },
{ key: FilterKey.LowPriorityFilter, name: "low priority", action: "See all activity" },
])("should display the empty state for the $name filter", async ({ key, name, action }) => {
const user = userEvent.setup();
const activePrimaryFilter = {
toggle: jest.fn(),
active: true,
name,
key,
};
const newState = {
...vm,
activePrimaryFilter,
};
const { asFragment } = render(<EmptyRoomList vm={newState} />);
await user.click(screen.getByRole("button", { name: action }));
expect(activePrimaryFilter.toggle).toHaveBeenCalled();
expect(asFragment()).toMatchSnapshot();
});
it.each([
{ key: FilterKey.FavouriteFilter, name: "favourite" },
{ key: FilterKey.PeopleFilter, name: "people" },
{ key: FilterKey.RoomsFilter, name: "rooms" },
])("should display empty state for filter $name", ({ name, key }) => {
const activePrimaryFilter = {
toggle: jest.fn(),
active: true,
name,
key,
};
const newState = { ...vm, activePrimaryFilter };
const { asFragment } = render(<EmptyRoomList vm={newState} />);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -1,78 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { render } from "jest-matrix-react";
import { fireEvent } from "@testing-library/dom";
import { VirtuosoMockContext } from "@element-hq/web-shared-components";
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { RoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomList";
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { Landmark, LandmarkNavigation } from "../../../../../../src/accessibility/LandmarkNavigation";
import { mkRoom, stubClient } from "../../../../../test-utils";
describe("<RoomList />", () => {
let matrixClient: MatrixClient;
let vm: RoomListViewState;
beforeEach(() => {
matrixClient = stubClient();
const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`));
vm = {
isLoadingRooms: false,
roomsResult: { spaceId: "home", rooms },
primaryFilters: [],
createRoom: jest.fn(),
createChatRoom: jest.fn(),
canCreateRoom: true,
activeIndex: undefined,
};
// Needed to render a room list cell
DMRoomMap.makeShared(matrixClient);
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined);
});
it("should render a room list", () => {
const { asFragment } = render(<RoomList vm={vm} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={matrixClient}>
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 56 }}>
<>{children}</>
</VirtuosoMockContext.Provider>
</MatrixClientContext.Provider>
),
});
// At the moment the context prop on Virtuoso gets rendered in the dom as "[object Object]".
// This is a general issue with the react-virtuoso library.
// TODO: Update the snapshot when the following issue is resolved: https://github.com/petyosi/react-virtuoso/issues/1281
expect(asFragment()).toMatchSnapshot();
});
it.each([
{ shortcut: { key: "F6", ctrlKey: true, shiftKey: true }, isPreviousLandmark: true, label: "PreviousLandmark" },
{ shortcut: { key: "F6", ctrlKey: true }, isPreviousLandmark: false, label: "NextLandmark" },
])("should navigate to the landmark on NextLandmark.$label action", ({ shortcut, isPreviousLandmark }) => {
const spyFindLandmark = jest.spyOn(LandmarkNavigation, "findAndFocusNextLandmark").mockReturnValue();
const { getByTestId } = render(<RoomList vm={vm} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={matrixClient}>
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 56 }}>
<>{children}</>
</VirtuosoMockContext.Provider>
</MatrixClientContext.Provider>
),
});
const roomList = getByTestId("room-list");
fireEvent.keyDown(roomList, shortcut);
expect(spyFindLandmark).toHaveBeenCalledWith(Landmark.ROOM_LIST, isPreviousLandmark);
});
});

View File

@@ -1,144 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { mocked } from "jest-mock";
import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import {
type RoomListItemMenuViewState,
useRoomListItemMenuViewModel,
} from "../../../../../../src/components/viewmodels/roomlist/RoomListItemMenuViewModel";
import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { mkRoom, stubClient } from "../../../../../test-utils";
import { RoomListItemMenuView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListItemMenuView";
import { RoomNotifState } from "../../../../../../src/RoomNotifs";
jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListItemMenuViewModel", () => ({
useRoomListItemMenuViewModel: jest.fn(),
}));
describe("<RoomListItemMenuView />", () => {
const defaultValue: RoomListItemMenuViewState = {
showMoreOptionsMenu: true,
showNotificationMenu: true,
isFavourite: true,
isLowPriority: true,
canInvite: true,
canMarkAsUnread: true,
canMarkAsRead: true,
canCopyRoomLink: true,
isNotificationAllMessage: true,
isNotificationMentionOnly: true,
isNotificationAllMessageLoud: true,
isNotificationMute: true,
copyRoomLink: jest.fn(),
markAsUnread: jest.fn(),
markAsRead: jest.fn(),
leaveRoom: jest.fn(),
toggleLowPriority: jest.fn(),
toggleFavorite: jest.fn(),
invite: jest.fn(),
setRoomNotifState: jest.fn(),
};
let matrixClient: MatrixClient;
let room: Room;
beforeEach(() => {
mocked(useRoomListItemMenuViewModel).mockReturnValue(defaultValue);
matrixClient = stubClient();
room = mkRoom(matrixClient, "room1");
});
function renderMenu() {
return render(<RoomListItemMenuView room={room} />);
}
it("should render the more options menu", () => {
const { asFragment } = renderMenu();
expect(screen.getByRole("button", { name: "More Options" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should render the notification options menu", () => {
const { asFragment } = renderMenu();
expect(screen.getByRole("button", { name: "Notification options" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should not render the more options menu when showMoreOptionsMenu is false", () => {
mocked(useRoomListItemMenuViewModel).mockReturnValue({ ...defaultValue, showMoreOptionsMenu: false });
renderMenu();
expect(screen.queryByRole("button", { name: "More Options" })).toBeNull();
});
it("should not render the notification options menu when showNotificationMenu is false", () => {
mocked(useRoomListItemMenuViewModel).mockReturnValue({ ...defaultValue, showNotificationMenu: false });
renderMenu();
expect(screen.queryByRole("button", { name: "Notification options" })).toBeNull();
});
it("should display all the buttons and have the actions linked for the more options menu", async () => {
const user = userEvent.setup();
renderMenu();
const openMenu = screen.getByRole("button", { name: "More Options" });
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Mark as read" }));
expect(defaultValue.markAsRead).toHaveBeenCalled();
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Mark as unread" }));
expect(defaultValue.markAsUnread).toHaveBeenCalled();
await user.click(openMenu);
await user.click(screen.getByRole("menuitemcheckbox", { name: "Favourited" }));
expect(defaultValue.toggleFavorite).toHaveBeenCalled();
await user.click(openMenu);
await user.click(screen.getByRole("menuitemcheckbox", { name: "Low priority" }));
expect(defaultValue.toggleLowPriority).toHaveBeenCalled();
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Invite" }));
expect(defaultValue.invite).toHaveBeenCalled();
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Copy room link" }));
expect(defaultValue.copyRoomLink).toHaveBeenCalled();
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Leave room" }));
expect(defaultValue.leaveRoom).toHaveBeenCalled();
});
it("should display all the buttons and have the actions linked for the notification options menu", async () => {
const user = userEvent.setup();
renderMenu();
const openMenu = screen.getByRole("button", { name: "Notification options" });
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Match default settings" }));
expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessages);
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "All messages" }));
expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.AllMessagesLoud);
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Mentions and keywords" }));
expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.MentionsOnly);
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "Mute room" }));
expect(defaultValue.setRoomNotifState).toHaveBeenCalledWith(RoomNotifState.Mute);
});
});

View File

@@ -1,162 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { render, screen, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { mkRoom, stubClient, withClientContextRenderOptions } from "../../../../../test-utils";
import { RoomListItemView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListItemView";
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
import {
type RoomListItemViewState,
useRoomListItemViewModel,
} from "../../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel";
import { RoomNotificationState } from "../../../../../../src/stores/notifications/RoomNotificationState";
jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel", () => ({
useRoomListItemViewModel: jest.fn(),
}));
describe("<RoomListItemView />", () => {
let defaultValue: RoomListItemViewState;
let matrixClient: MatrixClient;
let room: Room;
const renderRoomListItem = (props: Partial<React.ComponentProps<typeof RoomListItemView>> = {}) => {
const defaultProps = {
room,
isSelected: false,
isFocused: false,
onFocus: jest.fn(),
roomIndex: 0,
roomCount: 1,
listIsScrolling: false,
};
return render(<RoomListItemView {...defaultProps} {...props} />, withClientContextRenderOptions(matrixClient));
};
beforeEach(() => {
matrixClient = stubClient();
room = mkRoom(matrixClient, "room1");
DMRoomMap.makeShared(matrixClient);
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(undefined);
const notificationState = new RoomNotificationState(room, false);
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
jest.spyOn(notificationState, "isNotification", "get").mockReturnValue(true);
jest.spyOn(notificationState, "count", "get").mockReturnValue(1);
defaultValue = {
openRoom: jest.fn(),
showContextMenu: false,
showHoverMenu: false,
notificationState,
a11yLabel: "Open room room1",
isBold: false,
isVideoRoom: false,
callConnectionState: null,
callType: CallType.Video,
hasParticipantInCall: false,
name: room.name,
showNotificationDecoration: false,
messagePreview: undefined,
};
mocked(useRoomListItemViewModel).mockReturnValue(defaultValue);
});
test("should render a room item", () => {
const onClick = jest.fn();
const { asFragment } = renderRoomListItem({
onClick,
roomCount: 0,
});
expect(asFragment()).toMatchSnapshot();
});
test("should render a room item with a message preview", () => {
defaultValue.messagePreview = "The message looks like this";
const onClick = jest.fn();
const { asFragment } = renderRoomListItem({
onClick,
});
expect(asFragment()).toMatchSnapshot();
});
test("should call openRoom when clicked", async () => {
const user = userEvent.setup();
renderRoomListItem();
await user.click(screen.getByRole("option", { name: `Open room ${room.name}` }));
expect(defaultValue.openRoom).toHaveBeenCalled();
});
test("should be selected if isSelected=true", async () => {
const { asFragment } = renderRoomListItem({
isSelected: true,
});
expect(screen.queryByRole("option", { name: `Open room ${room.name}` })).toHaveAttribute(
"aria-selected",
"true",
);
expect(asFragment()).toMatchSnapshot();
});
test("should display notification decoration", async () => {
mocked(useRoomListItemViewModel).mockReturnValue({
...defaultValue,
showNotificationDecoration: true,
});
const { asFragment } = renderRoomListItem();
expect(screen.getByTestId("notification-decoration")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
test("should not display notification decoration when hovered", async () => {
const user = userEvent.setup();
mocked(useRoomListItemViewModel).mockReturnValue({
...defaultValue,
showNotificationDecoration: true,
});
renderRoomListItem();
const listItem = screen.getByRole("option", { name: `Open room ${room.name}` });
await user.hover(listItem);
expect(screen.queryByRole("notification-decoration")).toBeNull();
});
test("should render the context menu", async () => {
const user = userEvent.setup();
mocked(useRoomListItemViewModel).mockReturnValue({
...defaultValue,
showContextMenu: true,
});
renderRoomListItem();
const button = screen.getByRole("option", { name: `Open room ${room.name}` });
await user.pointer([{ target: button }, { keys: "[MouseRight]", target: button }]);
await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument());
// Menu should close
await user.keyboard("{Escape}");
expect(screen.queryByRole("menu")).toBeNull();
});
});

View File

@@ -1,155 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { act } from "react";
import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { RoomListPrimaryFilters } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters";
import { FilterKey } from "../../../../../../src/stores/room-list-v3/skip-list/filters";
describe("<RoomListPrimaryFilters />", () => {
let vm: RoomListViewState;
const filterToggleMocks = [jest.fn(), jest.fn(), jest.fn()];
let resizeCallback: ResizeObserverCallback;
beforeEach(() => {
// Reset mocks between tests
filterToggleMocks.forEach((mock) => mock.mockClear());
// Mock ResizeObserver
global.ResizeObserver = jest.fn().mockImplementation((callback) => {
resizeCallback = callback;
return {
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
};
});
vm = {
primaryFilters: [
{ name: "People", active: true, toggle: filterToggleMocks[0], key: FilterKey.PeopleFilter },
{ name: "Rooms", active: false, toggle: filterToggleMocks[1], key: FilterKey.RoomsFilter },
{ name: "Unreads", active: false, toggle: filterToggleMocks[2], key: FilterKey.UnreadFilter },
],
} as unknown as RoomListViewState;
});
function mockFiltersOffsetLeft() {
// Use `getByText` instead of `getByRole` to bypass the aria-hidden
jest.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0);
jest.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30);
jest.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(60);
// @ts-ignore
act(() => resizeCallback([{ target: screen.getByRole("listbox", { name: "Room list filters" }) }]));
}
it("should renders all filters correctly", () => {
const { asFragment } = render(<RoomListPrimaryFilters vm={vm} />);
mockFiltersOffsetLeft();
// Check that all filters are rendered
expect(screen.getByRole("option", { name: "People" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Rooms" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Unreads" })).toBeInTheDocument();
// Check that the active filter is marked as selected
expect(screen.getByRole("option", { name: "People" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("option", { name: "Rooms" })).toHaveAttribute("aria-selected", "false");
expect(screen.getByRole("option", { name: "Unreads" })).toHaveAttribute("aria-selected", "false");
expect(asFragment()).toMatchSnapshot();
});
it("should call toggle function when a filter is clicked", async () => {
const user = userEvent.setup();
render(<RoomListPrimaryFilters vm={vm} />);
mockFiltersOffsetLeft();
// Click on an inactive filter
await user.click(screen.getByRole("option", { name: "People" }));
// Check that the toggle function was called
expect(filterToggleMocks[0]).toHaveBeenCalledTimes(1);
});
function makeUnreadWrapping() {
// Use `getByText` instead of `getByRole` to bypass the aria-hidden
jest.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0);
jest.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30);
// Unreads is wrapping
jest.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(0);
// @ts-ignore
act(() => resizeCallback([{ target: screen.getByRole("listbox", { name: "Room list filters" }) }]));
}
it("should hide or display filters if they are wrapping", async () => {
const user = userEvent.setup();
render(<RoomListPrimaryFilters vm={vm} />);
mockFiltersOffsetLeft();
// No filter is wrapping, so chevron shouldn't be visible
expect(screen.queryByRole("button", { name: "Expand filter list" })).toBeNull();
expect(screen.queryByRole("option", { name: "Unreads" })).toBeVisible();
makeUnreadWrapping();
// The Unreads filter is wrapping, it should not be visible
expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull();
// Now filters are wrapping, so chevron should be visible
await user.click(screen.getByRole("button", { name: "Expand filter list" }));
// The list is expanded, so Unreads should be visible
expect(screen.getByRole("option", { name: "Unreads" })).toBeVisible();
});
it("should move the active filter if the list is collapsed and the filter is wrapping", async () => {
vm = {
primaryFilters: [
{ name: "People", active: false, toggle: filterToggleMocks[0], key: FilterKey.PeopleFilter },
{ name: "Rooms", active: false, toggle: filterToggleMocks[1], key: FilterKey.RoomsFilter },
{ name: "Unreads", active: true, toggle: filterToggleMocks[2], key: FilterKey.UnreadFilter },
],
} as unknown as RoomListViewState;
const user = userEvent.setup();
render(<RoomListPrimaryFilters vm={vm} />);
makeUnreadWrapping();
// Unread filter should be moved to the first position
expect(screen.getByRole("listbox", { name: "Room list filters" }).children[0]).toBe(
screen.getByRole("option", { name: "Unreads" }),
);
// When the list is expanded, the Unreads filter should move to its original position
await user.click(screen.getByRole("button", { name: "Expand filter list" }));
expect(screen.getByRole("listbox", { name: "Room list filters" }).children[0]).not.toEqual(
screen.getByRole("option", { name: "Unreads" }),
);
});
it("should hide the filter is the previous is on the same vertical position", async () => {
render(<RoomListPrimaryFilters vm={vm} />);
mockFiltersOffsetLeft();
jest.spyOn(screen.getByRole("option", { name: "People" }), "offsetLeft", "get").mockReturnValue(0);
// Rooms is wrapping
jest.spyOn(screen.getByRole("option", { name: "Rooms" }), "offsetLeft", "get").mockReturnValue(0);
// @ts-ignore
act(() => resizeCallback([{ target: screen.getByRole("listbox", { name: "Room list filters" }) }]));
// The Unreads filter is wrapping, it should not be visible
expect(screen.queryByRole("option", { name: "Rooms" })).toBeNull();
// Now filters are wrapping, so chevron should be visible
expect(screen.getByRole("button", { name: "Expand filter list" })).toBeVisible();
});
});

View File

@@ -1,65 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { mocked } from "jest-mock";
import { render, screen } from "jest-matrix-react";
import React from "react";
import {
type RoomListViewState,
useRoomListViewModel,
} from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { RoomListView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListView";
import { mkRoom, stubClient } from "../../../../../test-utils";
jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListViewModel", () => ({
useRoomListViewModel: jest.fn(),
}));
describe("<RoomListView />", () => {
const defaultValue: RoomListViewState = {
isLoadingRooms: false,
roomsResult: { spaceId: "home", rooms: [] },
primaryFilters: [],
createRoom: jest.fn(),
createChatRoom: jest.fn(),
canCreateRoom: true,
activeIndex: undefined,
};
const matrixClient = stubClient();
afterEach(() => {
jest.resetAllMocks();
});
it("should render the loading room list", () => {
mocked(useRoomListViewModel).mockReturnValue({
...defaultValue,
isLoadingRooms: true,
});
const roomList = render(<RoomListView />);
expect(roomList.container.querySelector(".mx_RoomListSkeleton")).not.toBeNull();
});
it("should render an empty room list", () => {
mocked(useRoomListViewModel).mockReturnValue(defaultValue);
render(<RoomListView />);
expect(screen.getByText("No chats yet")).toBeInTheDocument();
});
it("should render a room list", () => {
mocked(useRoomListViewModel).mockReturnValue({
...defaultValue,
roomsResult: { spaceId: "home", rooms: [mkRoom(matrixClient, "testing room")] },
});
render(<RoomListView />);
expect(screen.getByRole("listbox", { name: "Room list" })).toBeInTheDocument();
});
});

View File

@@ -1,279 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<EmptyRoomList /> should display empty state for filter favourite 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
data-testid="empty-room-list"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="mx_EmptyRoomList_GenericPlaceholder_title"
>
You don't have favourite chats yet
</span>
<span
class="mx_EmptyRoomList_GenericPlaceholder_description"
>
You can add a chat to your favourites in the chat settings
</span>
</div>
</DocumentFragment>
`;
exports[`<EmptyRoomList /> should display empty state for filter people 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
data-testid="empty-room-list"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="mx_EmptyRoomList_GenericPlaceholder_title"
>
You dont have direct chats with anyone yet
</span>
<span
class="mx_EmptyRoomList_GenericPlaceholder_description"
>
You can deselect filters in order to see your other chats
</span>
</div>
</DocumentFragment>
`;
exports[`<EmptyRoomList /> should display empty state for filter rooms 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
data-testid="empty-room-list"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="mx_EmptyRoomList_GenericPlaceholder_title"
>
Youre not in any room yet
</span>
<span
class="mx_EmptyRoomList_GenericPlaceholder_description"
>
You can deselect filters in order to see your other chats
</span>
</div>
</DocumentFragment>
`;
exports[`<EmptyRoomList /> should display the empty state for the invite filter 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
data-testid="empty-room-list"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="mx_EmptyRoomList_GenericPlaceholder_title"
>
You don't have any unread invites
</span>
<button
class="_button_13vu4_8"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
See all activity
</button>
</div>
</DocumentFragment>
`;
exports[`<EmptyRoomList /> should display the empty state for the low priority filter 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
data-testid="empty-room-list"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="mx_EmptyRoomList_GenericPlaceholder_title"
>
You don't have any low priority rooms
</span>
<button
class="_button_13vu4_8"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
See all activity
</button>
</div>
</DocumentFragment>
`;
exports[`<EmptyRoomList /> should display the empty state for the mention filter 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
data-testid="empty-room-list"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="mx_EmptyRoomList_GenericPlaceholder_title"
>
You don't have any unread mentions
</span>
<button
class="_button_13vu4_8"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
See all activity
</button>
</div>
</DocumentFragment>
`;
exports[`<EmptyRoomList /> should display the empty state for the unread filter 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
data-testid="empty-room-list"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="mx_EmptyRoomList_GenericPlaceholder_title"
>
Congrats! You dont have any unread messages
</span>
<button
class="_button_13vu4_8"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
Show all chats
</button>
</div>
</DocumentFragment>
`;
exports[`<EmptyRoomList /> should not render the new room button if the user doesn't have the rights to create a room 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
data-testid="empty-room-list"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="mx_EmptyRoomList_GenericPlaceholder_title"
>
No chats yet
</span>
<span
class="mx_EmptyRoomList_GenericPlaceholder_description"
>
Get started by messaging someone
</span>
<div
class="_flex_4dswl_9 mx_EmptyRoomList_DefaultPlaceholder"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_13vu4_8 _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m1.5 21.25 1.45-4.95a10.2 10.2 0 0 1-.712-2.1A10.2 10.2 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22q-1.125 0-2.2-.238a10.2 10.2 0 0 1-2.1-.712L2.75 22.5a.94.94 0 0 1-1-.25.94.94 0 0 1-.25-1m2.45-1.2 3.2-.95a1 1 0 0 1 .275-.062q.15-.013.275-.013.225 0 .438.038.212.036.412.137a7.4 7.4 0 0 0 1.675.6Q11.1 20 12 20q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12q0 .9.2 1.775t.6 1.675q.176.325.188.688t-.088.712z"
/>
</svg>
Start chat
</button>
</div>
</div>
</DocumentFragment>
`;
exports[`<EmptyRoomList /> should render the default placeholder when there is no filter 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_EmptyRoomList_GenericPlaceholder"
data-testid="empty-room-list"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="mx_EmptyRoomList_GenericPlaceholder_title"
>
No chats yet
</span>
<span
class="mx_EmptyRoomList_GenericPlaceholder_description"
>
Get started by messaging someone or by creating a room
</span>
<div
class="_flex_4dswl_9 mx_EmptyRoomList_DefaultPlaceholder"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_13vu4_8 _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m1.5 21.25 1.45-4.95a10.2 10.2 0 0 1-.712-2.1A10.2 10.2 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22q-1.125 0-2.2-.238a10.2 10.2 0 0 1-2.1-.712L2.75 22.5a.94.94 0 0 1-1-.25.94.94 0 0 1-.25-1m2.45-1.2 3.2-.95a1 1 0 0 1 .275-.062q.15-.013.275-.013.225 0 .438.038.212.036.412.137a7.4 7.4 0 0 0 1.675.6Q11.1 20 12 20q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12q0 .9.2 1.775t.6 1.675q.176.325.188.688t-.088.712z"
/>
</svg>
Start chat
</button>
<button
class="_button_13vu4_8 _has-icon_13vu4_60"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m8.566 17-.944 4.094q-.086.406-.372.656t-.687.25q-.543 0-.887-.469a1.18 1.18 0 0 1-.2-1.031l.801-3.5H3.158q-.572 0-.916-.484a1.27 1.27 0 0 1-.2-1.078 1.12 1.12 0 0 1 1.116-.938H6.85l1.145-5h-3.12q-.57 0-.915-.484a1.27 1.27 0 0 1-.2-1.078A1.12 1.12 0 0 1 4.875 7h3.691l.945-4.094q.085-.406.372-.656.286-.25.686-.25.544 0 .887.469.345.468.2 1.031l-.8 3.5h4.578l.944-4.094q.085-.406.372-.656.286-.25.687-.25.543 0 .887.469t.2 1.031L17.723 7h3.119q.573 0 .916.484.343.485.2 1.079a1.12 1.12 0 0 1-1.116.937H17.15l-1.145 5h3.12q.57 0 .915.484.343.485.2 1.079a1.12 1.12 0 0 1-1.116.937h-3.691l-.944 4.094q-.087.406-.373.656t-.686.25q-.544 0-.887-.469a1.18 1.18 0 0 1-.2-1.031l.8-3.5zm.573-2.5h4.578l1.144-5h-4.578z"
/>
</svg>
New room
</button>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -1,155 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<RoomListItemMenuView /> should render the more options menu 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_RoomListItemMenuView"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="More Options"
aria-labelledby="_r_2_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_0_"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<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>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Notification options"
aria-labelledby="_r_9_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_7_"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m4.917 2.083 17 17a1 1 0 0 1-1.414 1.414L19.006 19H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-2.034 1.096-3.91L3.504 3.498a1 1 0 0 1 1.414-1.414M19 13.35 9.136 3.484C9.93 3.181 10.874 3 12 3c7 0 7 7 7 7z"
/>
<path
d="M10 20h4a2 2 0 0 1-4 0"
/>
</svg>
</div>
</button>
</div>
</DocumentFragment>
`;
exports[`<RoomListItemMenuView /> should render the notification options menu 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_RoomListItemMenuView"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="More Options"
aria-labelledby="_r_i_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_g_"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<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>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Notification options"
aria-labelledby="_r_p_"
class="_icon-button_1215g_8"
data-kind="primary"
data-state="closed"
id="radix-_r_n_"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m4.917 2.083 17 17a1 1 0 0 1-1.414 1.414L19.006 19H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-2.034 1.096-3.91L3.504 3.498a1 1 0 0 1 1.414-1.414M19 13.35 9.136 3.484C9.93 3.181 10.874 3 12 3c7 0 7 7 7 7z"
/>
<path
d="M10 20h4a2 2 0 0 1-4 0"
/>
</svg>
</div>
</button>
</div>
</DocumentFragment>
`;

View File

@@ -1,234 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<RoomListItemView /> should be selected if isSelected=true 1`] = `
<DocumentFragment>
<button
aria-label="Open room room1"
aria-posinset="1"
aria-selected="true"
aria-setsize="1"
class="_flex_4dswl_9 mx_RoomListItemView mx_RoomListItemView_selected"
role="option"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_zysgz_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_zysgz_43"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="_flex_4dswl_9 mx_RoomListItemView_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_RoomListItemView_text"
>
<div
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
</div>
</div>
</button>
</DocumentFragment>
`;
exports[`<RoomListItemView /> should display notification decoration 1`] = `
<DocumentFragment>
<button
aria-label="Open room room1"
aria-posinset="1"
aria-selected="false"
aria-setsize="1"
class="_flex_4dswl_9 mx_RoomListItemView"
role="option"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_zysgz_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_zysgz_43"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="_flex_4dswl_9 mx_RoomListItemView_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_RoomListItemView_text"
>
<div
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
</div>
<div
aria-hidden="true"
class="_flex_4dswl_9 mx_RoomListItemView_notificationDecoration"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
<span
class="_unread-counter_1147r_8"
>
1
</span>
</div>
</div>
</button>
</DocumentFragment>
`;
exports[`<RoomListItemView /> should render a room item 1`] = `
<DocumentFragment>
<button
aria-label="Open room room1"
aria-posinset="1"
aria-selected="false"
aria-setsize="0"
class="_flex_4dswl_9 mx_RoomListItemView"
role="option"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_zysgz_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_zysgz_43"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="_flex_4dswl_9 mx_RoomListItemView_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_RoomListItemView_text"
>
<div
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
</div>
</div>
</button>
</DocumentFragment>
`;
exports[`<RoomListItemView /> should render a room item with a message preview 1`] = `
<DocumentFragment>
<button
aria-label="Open room room1"
aria-posinset="1"
aria-selected="false"
aria-setsize="1"
class="_flex_4dswl_9 mx_RoomListItemView"
role="option"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_zysgz_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_zysgz_43"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="_flex_4dswl_9 mx_RoomListItemView_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_RoomListItemView_text"
>
<div
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
<div
class="mx_RoomListItemView_messagePreview"
title="The message looks like this"
>
The message looks like this
</div>
</div>
</div>
</button>
</DocumentFragment>
`;

View File

@@ -1,47 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<RoomListPrimaryFilters /> should renders all filters correctly 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_RoomListPrimaryFilters"
data-testid="primary-filters"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
aria-label="Room list filters"
class="_flex_4dswl_9 mx_RoomListPrimaryFilters_list"
id="_r_0_"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
>
<button
aria-hidden="false"
aria-selected="true"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
People
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Rooms
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Unreads
</button>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -23,8 +23,8 @@ import {
showSpacePreferences,
showSpaceSettings,
} from "../../../src/utils/space";
import { createRoom, hasCreateRoomRights } from "../../../src/components/viewmodels/roomlist/utils";
import { createTestClient, mkSpace } from "../../test-utils";
import { createRoom, hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
jest.mock("../../../src/PosthogTrackers", () => ({
trackInteraction: jest.fn(),
@@ -38,7 +38,7 @@ jest.mock("../../../src/utils/space", () => ({
showSpaceSettings: jest.fn(),
}));
jest.mock("../../../src/components/viewmodels/roomlist/utils", () => ({
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
createRoom: jest.fn(),
hasCreateRoomRights: jest.fn(),
}));

View File

@@ -0,0 +1,439 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, type MatrixEvent, Room, RoomEvent, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { createTestClient, flushPromises } from "../../test-utils";
import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/room-list/MessagePreviewStore";
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
import SettingsStore from "../../../src/settings/SettingsStore";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { DefaultTagID } from "../../../src/stores/room-list/models";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { CallStore } from "../../../src/stores/CallStore";
import type { Call } from "../../../src/models/Call";
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
hasAccessToNotificationMenu: jest.fn().mockReturnValue(true),
}));
jest.mock("../../../src/stores/CallStore", () => ({
__esModule: true,
CallStore: {
instance: {
getCall: jest.fn(),
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
},
CallStoreEvent: {
ConnectedCalls: "connected_calls",
},
}));
describe("RoomListItemViewModel", () => {
let matrixClient: MatrixClient;
let room: Room;
let notificationState: RoomNotificationState;
let viewModel: RoomListItemViewModel;
beforeEach(() => {
matrixClient = createTestClient();
room = new Room("!room:server", matrixClient, matrixClient.getSafeUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached,
});
// Set room name
room.name = "Test Room";
notificationState = new RoomNotificationState(room, false);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
const dmRoomMap = {
getUserIdForRoomId: jest.fn().mockReturnValue(undefined),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
if (setting === "RoomList.showMessagePreview") return false;
return false;
});
jest.spyOn(SettingsStore, "watchSetting").mockImplementation(() => "watcher-id");
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
});
afterEach(() => {
viewModel?.dispose();
jest.restoreAllMocks();
});
describe("Initialization", () => {
it("should initialize with room data", async () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// Wait for async initialization
await flushPromises();
const snapshot = viewModel.getSnapshot();
expect(snapshot.id).toBe("!room:server");
expect(snapshot.name).toBe("Test Room");
});
it("should load message preview when enabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Hello world!",
} as MessagePreview);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// Wait for async message preview load
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Hello world!");
});
it("should not load message preview when disabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBeUndefined();
});
});
describe("Notification state", () => {
it("should reflect notification state", async () => {
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
jest.spyOn(notificationState, "count", "get").mockReturnValue(5);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
const snapshot = viewModel.getSnapshot();
expect(snapshot.notification.hasAnyNotificationOrActivity).toBe(true);
expect(snapshot.notification.count).toBe(5);
});
it("should update when notification state changes", async () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.count).toBe(0);
jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
notificationState.emit(NotificationStateEvents.Update);
await flushPromises();
expect(viewModel.getSnapshot().notification.count).toBe(3);
});
it("should show bold text when has notifications", async () => {
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isBold).toBe(true);
});
it("should show mention badge", async () => {
jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.isMention).toBe(true);
});
it("should show invitation state", async () => {
jest.spyOn(notificationState, "invited", "get").mockReturnValue(true);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.invited).toBe(true);
});
});
describe("Message preview", () => {
it("should update message preview when store emits update", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Initial message",
} as MessagePreview);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Initial message");
// Update preview
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Updated message",
} as MessagePreview);
MessagePreviewStore.instance.emit(UPDATE_EVENT);
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Updated message");
});
it("should show/hide preview when setting changes", async () => {
let showPreview = false;
let watchCallback: any;
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => showPreview);
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_setting, _room, callback) => {
watchCallback = callback;
return "watcher-id";
});
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
text: "Test message",
} as MessagePreview);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBeUndefined();
// Enable previews
showPreview = true;
watchCallback(null, "device", true);
await flushPromises();
expect(viewModel.getSnapshot().messagePreview).toBe("Test message");
});
});
describe("Room tags", () => {
it("should reflect favorite tag", async () => {
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isFavourite).toBe(true);
});
it("should reflect low priority tag", async () => {
room.tags = { [DefaultTagID.LowPriority]: { order: 0 } };
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isLowPriority).toBe(true);
});
it("should update when room tags change", async () => {
room.tags = {};
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().isFavourite).toBe(false);
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
const tagEvent = {
getContent: () => ({ tags: { [DefaultTagID.Favourite]: { order: 0 } } }),
} as MatrixEvent;
room.emit(RoomEvent.Tags, tagEvent, room);
await flushPromises();
expect(viewModel.getSnapshot().isFavourite).toBe(true);
});
});
describe("Call state", () => {
it("should show voice call indicator", async () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map([[matrixClient.getUserId()!, {}]]),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
});
it("should show video call indicator", async () => {
const mockCall = {
callType: CallType.Video,
participants: new Map([[matrixClient.getUserId()!, {}]]),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.callType).toBe("video");
});
it("should not show call indicator when no participants", async () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map(),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().notification.callType).toBeUndefined();
});
});
describe("Room name updates", () => {
it("should update when room name changes", async () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().name).toBe("Test Room");
room.name = "Updated Room";
room.emit(RoomEvent.Name, room);
await flushPromises();
expect(viewModel.getSnapshot().name).toBe("Updated Room");
});
});
describe("DM detection", () => {
it("should detect DM rooms", async () => {
const dmRoomMap = DMRoomMap.shared();
jest.spyOn(dmRoomMap, "getUserIdForRoomId").mockReturnValue("@user:server");
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
// DM rooms should not show copy room link option
expect(viewModel.getSnapshot().canCopyRoomLink).toBe(false);
});
it("should detect non-DM rooms", async () => {
const dmRoomMap = DMRoomMap.shared();
jest.spyOn(dmRoomMap, "getUserIdForRoomId").mockReturnValue(undefined);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();
expect(viewModel.getSnapshot().canCopyRoomLink).toBe(true);
});
});
describe("Actions", () => {
it("should dispatch view room action on openRoom", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onOpenRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: "!room:server",
metricsTrigger: "RoomList",
});
});
it("should return room object", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().room).toBe(room);
});
it("should dispatch view_invite action when onInvite is called", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onInvite();
expect(dispatchSpy).toHaveBeenCalledWith({
action: "view_invite",
roomId: "!room:server",
});
});
it("should dispatch copy_room action when onCopyRoomLink is called", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onCopyRoomLink();
expect(dispatchSpy).toHaveBeenCalledWith({
action: "copy_room",
room_id: "!room:server",
});
});
it("should dispatch leave_room action when onLeaveRoom is called for normal room", () => {
room.tags = {};
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onLeaveRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: "leave_room",
room_id: "!room:server",
});
});
it("should dispatch forget_room action when onLeaveRoom is called for archived room", () => {
room.tags = { [DefaultTagID.Archived]: { order: 0 } };
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.onLeaveRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: "forget_room",
room_id: "!room:server",
});
});
});
describe("Cleanup", () => {
it("should unsubscribe from all events on dispose", () => {
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
const offSpy = jest.spyOn(notificationState, "off");
viewModel.dispose();
expect(offSpy).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,546 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { createTestClient, flushPromises, mkStubRoom, stubClient } from "../../test-utils";
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
import { FilterKey } from "../../../src/stores/room-list-v3/skip-list/filters";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { RoomListViewViewModel } from "../../../src/viewmodels/room-list/RoomListViewViewModel";
import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
hasCreateRoomRights: jest.fn().mockReturnValue(false),
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
hasAccessToNotificationMenu: jest.fn().mockReturnValue(true),
}));
describe("RoomListViewViewModel", () => {
let matrixClient: MatrixClient;
let room1: Room;
let room2: Room;
let room3: Room;
let viewModel: RoomListViewViewModel;
beforeEach(() => {
matrixClient = createTestClient();
room1 = mkStubRoom("!room1:server", "Room 1", matrixClient);
room2 = mkStubRoom("!room2:server", "Room 2", matrixClient);
room3 = mkStubRoom("!room3:server", "Room 3", matrixClient);
// Setup DMRoomMap
const dmRoomMap = {
getUserIdForRoomId: jest.fn().mockReturnValue(null),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1, room2, room3],
});
jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(false);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);
mocked(hasCreateRoomRights).mockReturnValue(false);
});
afterEach(() => {
viewModel?.dispose();
jest.restoreAllMocks();
});
describe("Initialization", () => {
it("should initialize with correct snapshot", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const snapshot = viewModel.getSnapshot();
expect(snapshot.roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]);
expect(snapshot.isRoomListEmpty).toBe(false);
expect(snapshot.isLoadingRooms).toBe(false);
expect(snapshot.roomListState.spaceId).toBe("home");
expect(snapshot.filterIds.length).toBeGreaterThan(0);
expect(snapshot.activeFilterId).toBeUndefined();
});
it("should initialize with empty room list", () => {
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [],
});
viewModel = new RoomListViewViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().roomIds).toEqual([]);
expect(viewModel.getSnapshot().isRoomListEmpty).toBe(true);
});
it("should set canCreateRoom based on user rights", () => {
mocked(hasCreateRoomRights).mockReturnValue(true);
viewModel = new RoomListViewViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().canCreateRoom).toBe(true);
});
});
describe("Room list updates", () => {
it("should update room list when ListsUpdate event fires", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const newRoom = mkStubRoom("!room4:server", "Room 4", matrixClient);
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1, room2, room3, newRoom],
});
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
expect(viewModel.getSnapshot().roomIds).toEqual([
"!room1:server",
"!room2:server",
"!room3:server",
"!room4:server",
]);
});
it("should update loading state when ListsLoaded event fires", () => {
jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(true);
viewModel = new RoomListViewViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().isLoadingRooms).toBe(true);
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsLoaded);
expect(viewModel.getSnapshot().isLoadingRooms).toBe(false);
});
});
describe("Space switching", () => {
it("should update room list when space changes", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const spaceRoomList = [room1, room2];
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "!space:server",
rooms: spaceRoomList,
});
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue("!room1:server");
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
expect(viewModel.getSnapshot().roomListState.spaceId).toBe("!space:server");
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server", "!room2:server"]);
});
it("should clear view models when space changes", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
// Get view models for visible rooms
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const vm2 = viewModel.getRoomItemViewModel("!room2:server");
const disposeSpy1 = jest.spyOn(vm1, "dispose");
const disposeSpy2 = jest.spyOn(vm2, "dispose");
// Change space
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "!space:server",
rooms: [room3],
});
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
expect(disposeSpy1).toHaveBeenCalled();
expect(disposeSpy2).toHaveBeenCalled();
});
});
describe("Active room tracking", () => {
it("should update active room index when room is selected", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
dispatcher.dispatch({
action: Action.ActiveRoomChanged,
oldRoomId: "!room1:server",
newRoomId: "!room2:server",
});
// Use setTimeout to allow the dispatcher callback to run
await flushPromises();
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);
});
it("should return undefined active room index when no room is selected", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);
dispatcher.dispatch({
action: Action.ActiveRoomChanged,
oldRoomId: "!room1:server",
newRoomId: null,
});
// Use setTimeout to allow the dispatcher callback to run
await flushPromises();
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBeUndefined();
});
});
describe("Sticky room behavior", () => {
it("should keep selected room at same index when room list updates", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
// Select room at index 1
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
dispatcher.dispatch({
action: Action.ActiveRoomChanged,
newRoomId: "!room2:server",
});
await flushPromises();
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);
// Simulate room list update that would move room2 to front
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room2, room1, room3], // room2 moved to front
});
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
// Active room should still be at index 1 (sticky behavior)
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);
expect(viewModel.getSnapshot().roomIds[1]).toBe("!room2:server");
});
it("should not apply sticky behavior when user changes rooms", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
// Select room at index 1
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
dispatcher.dispatch({
action: Action.ActiveRoomChanged,
newRoomId: "!room2:server",
});
await flushPromises();
// User switches to room3
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room3:server");
dispatcher.dispatch({
action: Action.ActiveRoomChanged,
oldRoomId: "!room2:server",
newRoomId: "!room3:server",
});
await flushPromises();
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(2);
});
});
describe("Filters", () => {
it("should toggle filter on", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1],
filterKeys: [FilterKey.UnreadFilter],
});
viewModel.onToggleFilter("unread");
expect(viewModel.getSnapshot().activeFilterId).toBe("unread");
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server"]);
});
it("should toggle filter off", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
// Turn filter on
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1],
filterKeys: [FilterKey.UnreadFilter],
});
viewModel.onToggleFilter("unread");
expect(viewModel.getSnapshot().activeFilterId).toBe("unread");
// Turn filter off
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room1, room2, room3],
});
viewModel.onToggleFilter("unread");
expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]);
});
it("should clear view models when filter changes", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
// Get view models
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const disposeSpy = jest.spyOn(vm1, "dispose");
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
spaceId: "home",
rooms: [room2],
filterKeys: [FilterKey.UnreadFilter],
});
viewModel.onToggleFilter("unread");
expect(disposeSpy).toHaveBeenCalled();
});
});
describe("Room item view models", () => {
it("should create room item view model on demand", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const itemViewModel = viewModel.getRoomItemViewModel("!room1:server");
expect(itemViewModel).toBeDefined();
expect(itemViewModel.getSnapshot().room).toBe(room1);
});
it("should reuse existing room item view model", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const itemViewModel1 = viewModel.getRoomItemViewModel("!room1:server");
const itemViewModel2 = viewModel.getRoomItemViewModel("!room1:server");
expect(itemViewModel1).toBe(itemViewModel2);
});
it("should throw error when requesting view model for non-existent room", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
expect(() => {
viewModel.getRoomItemViewModel("!nonexistent:server");
}).toThrow();
});
it("should dispose view models for rooms no longer visible", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const vm2 = viewModel.getRoomItemViewModel("!room2:server");
const vm3 = viewModel.getRoomItemViewModel("!room3:server");
const disposeSpy1 = jest.spyOn(vm1, "dispose");
const disposeSpy3 = jest.spyOn(vm3, "dispose");
// Update to show only middle room (index 1)
viewModel.updateVisibleRooms(1, 2);
expect(disposeSpy1).toHaveBeenCalled();
expect(disposeSpy3).toHaveBeenCalled();
// vm2 should still exist
const vm2Again = viewModel.getRoomItemViewModel("!room2:server");
expect(vm2Again).toBe(vm2);
});
});
describe("Room creation", () => {
it("should dispatch CreateChat action when createChatRoom is called", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "fire");
viewModel.createChatRoom();
expect(dispatchSpy).toHaveBeenCalledWith(Action.CreateChat);
});
it("should dispatch CreateRoom action without parent space", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.createRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.CreateRoom,
});
});
it("should dispatch CreateRoom action with parent space", () => {
const spaceRoom = mkStubRoom("!space:server", "Space", matrixClient);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(spaceRoom);
viewModel = new RoomListViewViewModel({ client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
viewModel.createRoom();
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.CreateRoom,
parent_space: spaceRoom,
});
});
});
describe("Keyboard navigation (ViewRoomDelta)", () => {
beforeEach(() => {
// stubClient sets up MatrixClientPeg which is needed when ViewRoom action is dispatched
stubClient();
});
it("should navigate to next room when delta is 1", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server");
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: 1,
unread: false,
});
await flushPromises();
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
room_id: "!room2:server",
}),
);
});
it("should navigate to previous room when delta is -1", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: -1,
unread: false,
});
await flushPromises();
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
room_id: "!room1:server",
}),
);
});
it("should wrap around to last room when navigating backwards from first room", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server");
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: -1,
unread: false,
});
await flushPromises();
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
room_id: "!room3:server",
}),
);
});
it("should not navigate when current room is not found", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!unknown:server");
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
dispatchSpy.mockClear();
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: 1,
unread: false,
});
await flushPromises();
// Should not dispatch ViewRoom since current room wasn't found
expect(dispatchSpy).not.toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
}),
);
});
it("should not navigate when no room is selected", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
dispatchSpy.mockClear();
dispatcher.dispatch({
action: Action.ViewRoomDelta,
delta: 1,
unread: false,
});
await flushPromises();
expect(dispatchSpy).not.toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
}),
);
});
});
describe("Cleanup", () => {
it("should dispose all room item view models on dispose", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient });
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const vm2 = viewModel.getRoomItemViewModel("!room2:server");
const disposeSpy1 = jest.spyOn(vm1, "dispose");
const disposeSpy2 = jest.spyOn(vm2, "dispose");
viewModel.dispose();
expect(disposeSpy1).toHaveBeenCalled();
expect(disposeSpy2).toHaveBeenCalled();
});
});
});

View File

@@ -8,22 +8,18 @@
import { mocked } from "jest-mock";
import type { MatrixClient, Room, RoomState } from "matrix-js-sdk/src/matrix";
import { createTestClient, mkStubRoom } from "../../../../test-utils";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import {
hasCreateRoomRights,
createRoom,
hasAccessToNotificationMenu,
} from "../../../../../src/components/viewmodels/roomlist/utils";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import { showCreateNewRoom } from "../../../../../src/utils/space";
import { createTestClient, mkStubRoom } from "../../test-utils";
import { shouldShowComponent } from "../../../src/customisations/helpers/UIComponents";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { showCreateNewRoom } from "../../../src/utils/space";
import { hasCreateRoomRights, createRoom, hasAccessToNotificationMenu } from "../../../src/viewmodels/room-list/utils";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
jest.mock("../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
jest.mock("../../../../../src/utils/space", () => ({
jest.mock("../../../src/utils/space", () => ({
showCreateNewRoom: jest.fn(),
}));