From da6ac36f11a0a24a6d64ddca85d0720bd20b2502 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 22 Apr 2025 10:31:12 +0200 Subject: [PATCH] New room list: add partial keyboard shortcuts support (#29783) * feat: add support to `Action.ViewRoomDelta` * test: add tests for support of `Action.ViewRoomDelta` * test(e2e): add tests for shortcuts * doc: improve comments in `useRoomListNavigation` --- .../room-list-panel/room-list.spec.ts | 41 +++++ .../viewmodels/roomlist/RoomListViewModel.tsx | 3 + .../roomlist/useRoomListNavigation.ts | 56 +++++++ .../roomlist/useRoomListNavigation-test.ts | 152 ++++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 src/components/viewmodels/roomlist/useRoomListNavigation.ts create mode 100644 test/unit-tests/components/viewmodels/roomlist/useRoomListNavigation-test.ts diff --git a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts index 8af053fd54..4d973dbb49 100644 --- a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts @@ -142,6 +142,47 @@ test.describe("Room list", () => { await filters.getByRole("option", { name: "People" }).click(); await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); }); + + test.describe("Shortcuts", () => { + test("should select the next room", async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); + await page.keyboard.press("Alt+ArrowDown"); + + await expect(page.getByRole("heading", { name: "room28", level: 1 })).toBeVisible(); + }); + + test("should select the previous room", async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await roomListView.getByRole("gridcell", { name: "Open room room28" }).click(); + await page.keyboard.press("Alt+ArrowUp"); + + await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); + }); + + test("should select the last room", async ({ page, app, user }) => { + const roomListView = getRoomList(page); + await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); + await page.keyboard.press("Alt+ArrowUp"); + + await expect(page.getByRole("heading", { name: "room0", level: 1 })).toBeVisible(); + }); + + test("should select the next unread room", async ({ page, app, user, bot }) => { + const roomListView = getRoomList(page); + + const roomId = await app.client.createRoom({ name: "1 notification" }); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + await bot.sendMessage(roomId, "I am a robot. Beep."); + + await roomListView.getByRole("gridcell", { name: "Open room room20" }).click(); + + await page.keyboard.press("Alt+Shift+ArrowDown"); + + await expect(page.getByRole("heading", { name: "1 notification", level: 1 })).toBeVisible(); + }); + }); }); test.describe("Avatar decoration", () => { diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx index 217eaefbd9..c07ac83eaa 100644 --- a/src/components/viewmodels/roomlist/RoomListViewModel.tsx +++ b/src/components/viewmodels/roomlist/RoomListViewModel.tsx @@ -19,6 +19,7 @@ import dispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useStickyRoomList } from "./useStickyRoomList"; +import { useRoomListNavigation } from "./useRoomListNavigation"; export interface RoomListViewState { /** @@ -106,6 +107,8 @@ export function useRoomListViewModel(): RoomListViewState { } = useFilteredRooms(); const { activeIndex, rooms } = useStickyRoomList(filteredRooms); + useRoomListNavigation(rooms); + const currentSpace = useEventEmitterState( SpaceStore.instance, UPDATE_SELECTED_SPACE, diff --git a/src/components/viewmodels/roomlist/useRoomListNavigation.ts b/src/components/viewmodels/roomlist/useRoomListNavigation.ts new file mode 100644 index 0000000000..5ef979e79c --- /dev/null +++ b/src/components/viewmodels/roomlist/useRoomListNavigation.ts @@ -0,0 +1,56 @@ +/* + * 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 Room } from "matrix-js-sdk/src/matrix"; + +import dispatcher from "../../../dispatcher/dispatcher"; +import { useDispatcher } from "../../../hooks/useDispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload"; +import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { SdkContextClass } from "../../../contexts/SDKContext"; +import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; + +/** + * Hook to navigate the room list using keyboard shortcuts. + * It listens to the ViewRoomDelta action and updates the room list accordingly. + * @param rooms + */ +export function useRoomListNavigation(rooms: Room[]): void { + useDispatcher(dispatcher, (payload) => { + if (payload.action !== Action.ViewRoomDelta) return; + const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); + if (!roomId) return; + + const { delta, unread } = payload as ViewRoomDeltaPayload; + const filteredRooms = unread + ? // Filter the rooms to only include unread ones and the active room + rooms.filter((room) => { + const state = RoomNotificationStateStore.instance.getRoomState(room); + return room.roomId === roomId || state.isUnread; + }) + : rooms; + + const currentIndex = filteredRooms.findIndex((room) => room.roomId === roomId); + if (currentIndex === -1) return; + + // Get the next/previous new room according to the delta + // Use slice to loop on the list + // If delta is -1 at the start of the list, it will go to the end + // If delta is 1 at the end of the list, it will go to the start + const [newRoom] = filteredRooms.slice((currentIndex + delta) % filteredRooms.length); + if (!newRoom) return; + + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: newRoom.roomId, + show_room_tile: true, // to make sure the room gets scrolled into view + metricsTrigger: "WebKeyboardShortcut", + metricsViaKeyboard: true, + }); + }); +} diff --git a/test/unit-tests/components/viewmodels/roomlist/useRoomListNavigation-test.ts b/test/unit-tests/components/viewmodels/roomlist/useRoomListNavigation-test.ts new file mode 100644 index 0000000000..c82f9aa87d --- /dev/null +++ b/test/unit-tests/components/viewmodels/roomlist/useRoomListNavigation-test.ts @@ -0,0 +1,152 @@ +/* + * 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(null); + 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, + }), + ); + }); +});