mirror of
https://github.com/vector-im/element-web.git
synced 2025-08-13 17:57:05 +02:00
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:
parent
c1f145d802
commit
da6ac36f11
@ -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", () => {
|
||||
|
@ -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,
|
||||
|
56
src/components/viewmodels/roomlist/useRoomListNavigation.ts
Normal file
56
src/components/viewmodels/roomlist/useRoomListNavigation.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user