Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions playwright/e2e/left-panel/room-list-panel/room-list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
3 changes: 3 additions & 0 deletions src/components/viewmodels/roomlist/RoomListViewModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions src/components/viewmodels/roomlist/useRoomListNavigation.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
florianduros marked this conversation as resolved.
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,
});
});
}
Original file line number Diff line number Diff line change
@@ -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,
}),
);
});
});