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`
This commit is contained in:
Florian Duros 2025-04-22 10:31:12 +02:00 committed by GitHub
parent c1f145d802
commit da6ac36f11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 252 additions and 0 deletions

View File

@ -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", () => {

View File

@ -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<Room | null>(
SpaceStore.instance,
UPDATE_SELECTED_SPACE,

View File

@ -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<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: newRoom.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
metricsTrigger: "WebKeyboardShortcut",
metricsViaKeyboard: true,
});
});
}

View File

@ -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,
}),
);
});
});