Move the room list to the new ListView(backed by react-virtuoso) (#30515)

* Move Room List to ListView

- Also remove Space/Enter handing from keyboard navigation we can just leave the default behaviour of those keys and handle via onClick

* Update rooms when the primary filter changes

Otherwise when changing spaces, the filter does not reset until the next update to the RVS is made.

* Fix stickyRow/scrollIntoView when switiching space or changing filters

- Also remove the rest of space/enter keyboard handling use

* Remove the rest of space/enter keyboard handling use

* Remove useCombinedRef and add @radix-ui/react-compose-refs as we already depend on it

- Also remove eact-virtualized dep

* Update RoomList unit test

* Update snapshots and unit tests

* Fix e2e tests

* Remove react-virtualized from tests

* Fix e2e flake

* Update more screenshots

* Fix e2e test case where were should scroll to the top when the active room is no longer in the list

* Move from gitpkg to package-patch

* Update to latest react virtuoso release/api.

Also pass spaceId to the room list and scroll the activeIndex into view when spaceId or primaryFilter change.

* Use listbox/option roles to improve ScreenReader experience

* Change onKeyDown e.stopPropogation to cover context menu

* lint

* Remove unneeded exposure of the listView ref

Also move scrollIntoViewOnChange to useCallback

* Update unit test and snapshot

* Fix e2e tests and update screenshots

* Fix unit test and snapshot

* Update more unit tests

* Fix keyboard shortcuts and e2e test

* Fix another e2e and unit test

* lint

* Improve the naming for RoomResult and the documentation on it's fields meaning.

Also update the login in RoomList to check for any change in filters, this is a bit more future proof for when we introduce multi select than using activePrimaryFilter.

* Put back and fix landmark tests

* Fix test import

* Add comment regarding context object getting rendered.

* onKeyDown should be optional

* Use SpaceKey type on RoomResult

* lint
This commit is contained in:
David Langley
2025-08-21 14:43:40 +00:00
committed by GitHub
parent ef3a6a9429
commit c842b615db
50 changed files with 1139 additions and 1021 deletions
+1 -3
View File
@@ -96,7 +96,6 @@
"@matrix-org/spec": "^1.7.0", "@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^10.0.0", "@sentry/browser": "^10.0.0",
"@types/png-chunks-extract": "^1.0.2", "@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^6.0.0", "@vector-im/compound-design-tokens": "^6.0.0",
"@vector-im/compound-web": "^8.1.2", "@vector-im/compound-web": "^8.1.2",
"@vector-im/matrix-wysiwyg": "2.39.0", "@vector-im/matrix-wysiwyg": "2.39.0",
@@ -153,8 +152,7 @@
"react-focus-lock": "^2.5.1", "react-focus-lock": "^2.5.1",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"react-virtualized": "^9.22.5", "react-virtuoso": "^4.14.0",
"react-virtuoso": "^4.12.6",
"rfc4648": "^1.4.0", "rfc4648": "^1.4.0",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sanitize-html": "2.17.0", "sanitize-html": "2.17.0",
@@ -68,7 +68,7 @@ test.describe("Room list filters and sort", () => {
So we expect 'Old Room' to show up in the room list. So we expect 'Old Room' to show up in the room list.
*/ */
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
const oldRoomTile = roomListView.getByRole("gridcell", { name: "Open room Old Room" }); const oldRoomTile = roomListView.getByRole("option", { name: "Open room Old Room" });
await expect(oldRoomTile).toBeVisible(); await expect(oldRoomTile).toBeVisible();
/* /*
@@ -139,8 +139,9 @@ test.describe("Room list filters and sort", () => {
// Open the non-favourite room // Open the non-favourite room
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
const tile = roomListView.getByRole("gridcell", { name: "Open room room-non-fav" }); const tile = roomListView.getByRole("option", { name: "Open room room-non-fav" });
await tile.scrollIntoViewIfNeeded(); // item may not be in the DOM using scrollListToBottom rather than scrollIntoViewIfNeeded
await app.scrollListToBottom(roomListView);
await tile.click(); await tile.click();
// Enable Favourite filter // Enable Favourite filter
@@ -151,7 +152,7 @@ test.describe("Room list filters and sort", () => {
// Ensure the room list is not scrolled // Ensure the room list is not scrolled
const isScrolledDown = await page const isScrolledDown = await page
.getByRole("grid", { name: "Room list" }) .getByRole("listbox", { name: "Room list", exact: true })
.evaluate((e) => e.scrollTop !== 0); .evaluate((e) => e.scrollTop !== 0);
expect(isScrolledDown).toStrictEqual(false); expect(isScrolledDown).toStrictEqual(false);
}); });
@@ -227,37 +228,37 @@ test.describe("Room list filters and sort", () => {
await primaryFilters.getByRole("option", { name: "Unread" }).click(); await primaryFilters.getByRole("option", { name: "Unread" }).click();
// only one room should be visible // only one room should be visible
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible(); await expect(roomList.getByRole("option", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible(); await expect(roomList.getByRole("option", { name: "unread room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(4); await expect.poll(() => roomList.locator("role=option").count()).toBe(4);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png"); await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
await primaryFilters.getByRole("option", { name: "People" }).click(); await primaryFilters.getByRole("option", { name: "People" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible(); await expect(roomList.getByRole("option", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible(); await expect(roomList.getByRole("option", { name: "invited room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(2); await expect.poll(() => roomList.locator("role=option").count()).toBe(2);
await primaryFilters.getByRole("option", { name: "Rooms" }).click(); await primaryFilters.getByRole("option", { name: "Rooms" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible(); await expect(roomList.getByRole("option", { name: "unread room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible(); await expect(roomList.getByRole("option", { name: "favourite room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible(); await expect(roomList.getByRole("option", { name: "empty room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible(); await expect(roomList.getByRole("option", { name: "room with mention" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible(); await expect(roomList.getByRole("option", { name: "Low prio room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(5); await expect.poll(() => roomList.locator("role=option").count()).toBe(5);
await getFilterExpandButton(page).click(); await getFilterExpandButton(page).click();
await primaryFilters.getByRole("option", { name: "Favourite" }).click(); await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible(); await expect(roomList.getByRole("option", { name: "favourite room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1); await expect.poll(() => roomList.locator("role=option").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Mentions" }).click(); await primaryFilters.getByRole("option", { name: "Mentions" }).click();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible(); await expect(roomList.getByRole("option", { name: "room with mention" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1); await expect.poll(() => roomList.locator("role=option").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Invites" }).click(); await primaryFilters.getByRole("option", { name: "Invites" }).click();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible(); await expect(roomList.getByRole("option", { name: "invited room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1); await expect.poll(() => roomList.locator("role=option").count()).toBe(1);
await getFilterCollapseButton(page).click(); await getFilterCollapseButton(page).click();
await expect(primaryFilters.locator("role=option").first()).toHaveText("Invites"); await expect(primaryFilters.locator("role=option").first()).toHaveText("Invites");
@@ -268,6 +269,7 @@ test.describe("Room list filters and sort", () => {
{ tag: "@screenshot" }, { tag: "@screenshot" },
async ({ page, app, bot }) => { async ({ page, app, bot }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
const primaryFilters = getPrimaryFilters(page);
// Let's configure unread dm room so that we only get notification for mentions and keywords // Let's configure unread dm room so that we only get notification for mentions and keywords
await app.viewRoomById(unReadDmId); await app.viewRoomById(unReadDmId);
@@ -276,20 +278,20 @@ test.describe("Room list filters and sort", () => {
await app.settings.closeDialog(); await app.settings.closeDialog();
// Let's open a room other than unread room or unread dm // Let's open a room other than unread room or unread dm
await roomListView.getByRole("gridcell", { name: "Open room favourite room" }).click(); await roomListView.getByRole("option", { name: "Open room favourite room" }).click();
// Let's make the bot send a new message in both rooms // Let's make the bot send a new message in both rooms
await bot.sendMessage(unReadDmId, "Hello!"); await bot.sendMessage(unReadDmId, "Hello!");
await bot.sendMessage(unReadRoomId, "Hello!"); await bot.sendMessage(unReadRoomId, "Hello!");
// Let's activate the unread filter now // Let's activate the unread filter now
await page.getByRole("option", { name: "Unread" }).click(); await primaryFilters.getByRole("option", { name: "Unread" }).click();
// Unread filter should only show unread room and not unread dm! // Unread filter should only show unread room and not unread dm!
const unreadDm = roomListView.getByRole("gridcell", { name: "Open room unread room" }); const unreadDm = roomListView.getByRole("option", { name: "Open room unread room" });
await expect(unreadDm).toBeVisible(); await expect(unreadDm).toBeVisible();
await expect(unreadDm).toMatchScreenshot("unread-dm.png"); await expect(unreadDm).toMatchScreenshot("unread-dm.png");
await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible(); await expect(roomListView.getByRole("option", { name: "Open room unread dm" })).not.toBeVisible();
}, },
); );
@@ -299,7 +301,7 @@ test.describe("Room list filters and sort", () => {
await getRoomOptionsMenu(page).click(); await getRoomOptionsMenu(page).click();
await page.getByRole("menuitemradio", { name: "A-Z" }).click(); await page.getByRole("menuitemradio", { name: "A-Z" }).click();
await expect(roomListView.getByRole("gridcell").first()).toHaveText(/empty room/); await expect(roomListView.getByRole("option").first()).toHaveText(/empty room/);
}); });
test("should move room to the top on message when sorting by activity", async ({ page, bot }) => { test("should move room to the top on message when sorting by activity", async ({ page, bot }) => {
@@ -307,7 +309,7 @@ test.describe("Room list filters and sort", () => {
await bot.sendMessage(unReadDmId, "Hello!"); await bot.sendMessage(unReadDmId, "Hello!");
await expect(roomListView.getByRole("gridcell").first()).toHaveText(/unread dm/); await expect(roomListView.getByRole("option").first()).toHaveText(/unread dm/);
}); });
}); });
@@ -38,7 +38,7 @@ test.describe("Room list panel", () => {
test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => { test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomListView(page); const roomListView = getRoomListView(page);
// Wait for the last room to be visible // Wait for the last room to be visible
await expect(roomListView.getByRole("gridcell", { name: "Open room room19" })).toBeVisible(); await expect(roomListView.getByRole("option", { name: "Open room room19" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list-panel.png"); await expect(roomListView).toMatchScreenshot("room-list-panel.png");
}); });
@@ -43,31 +43,35 @@ test.describe("Room list", () => {
test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user }) => { test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible(); await expect(roomListView.getByRole("option", { name: "Open room room29" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list.png"); await expect(roomListView).toMatchScreenshot("room-list.png");
// Put focus on the room list // Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); await roomListView.getByRole("option", { name: "Open room room29" }).click();
// Scroll to the end of the room list // Scroll to the end of the room list
await app.scrollListToBottom(page.locator(".mx_RoomList_List")); await app.scrollListToBottom(roomListView);
// scrollListToBottom seems to leave the mouse hovered over the list, move it away.
await page.getByRole("button", { name: "User menu" }).hover();
await expect(roomListView).toMatchScreenshot("room-list-scrolled.png"); await expect(roomListView).toMatchScreenshot("room-list-scrolled.png");
}); });
test("should open the room when it is clicked", async ({ page, app, user }) => { test("should open the room when it is clicked", async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); await roomListView.getByRole("option", { name: "Open room room29" }).click();
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
}); });
test("should open the context menu", { tag: "@screenshot" }, async ({ page, app, user }) => { test("should open the context menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click({ button: "right" }); await roomListView.getByRole("option", { name: "Open room room29" }).click({ button: "right" });
await expect(page.getByRole("menu", { name: "More Options" })).toBeVisible(); await expect(page.getByRole("menu", { name: "More Options" })).toBeVisible();
}); });
test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => { test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" }); const roomItem = roomListView.getByRole("option", { name: "Open room room29" });
await roomItem.hover(); await roomItem.hover();
await expect(roomItem).toMatchScreenshot("room-list-item-hover.png"); await expect(roomItem).toMatchScreenshot("room-list-item-hover.png");
@@ -97,7 +101,7 @@ test.describe("Room list", () => {
test("should open the notification options menu", { tag: "@screenshot" }, async ({ page, app, user }) => { test("should open the notification options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" }); const roomItem = roomListView.getByRole("option", { name: "Open room room29" });
await roomItem.hover(); await roomItem.hover();
await expect(roomItem).toMatchScreenshot("room-list-item-hover.png"); await expect(roomItem).toMatchScreenshot("room-list-item-hover.png");
@@ -117,10 +121,10 @@ test.describe("Room list", () => {
await expect(roomItem.getByTestId("notification-decoration")).not.toBeVisible(); await expect(roomItem.getByTestId("notification-decoration")).not.toBeVisible();
// Put focus on the room list // Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click(); await roomListView.getByRole("option", { name: "Open room room28" }).click();
// Scroll to the end of the room list // Scroll to the end of the room list
await app.scrollListToBottom(page.locator(".mx_RoomList_List")); await app.scrollListToBottom(roomListView);
// The room decoration should have the muted icon // The room decoration should have the muted icon
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible(); await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
@@ -139,25 +143,25 @@ test.describe("Room list", () => {
test("should scroll to the current room", async ({ page, app, user }) => { test("should scroll to the current room", async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
// Put focus on the room list // Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); await roomListView.getByRole("option", { name: "Open room room29" }).click();
// Scroll to the end of the room list // Scroll to the end of the room list
await app.scrollListToBottom(page.locator(".mx_RoomList_List")); await app.scrollListToBottom(roomListView);
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); await expect(roomListView.getByRole("option", { name: "Open room room0" })).toBeVisible();
await roomListView.getByRole("gridcell", { name: "Open room room0" }).click(); await roomListView.getByRole("option", { name: "Open room room0" }).click();
const filters = page.getByRole("listbox", { name: "Room list filters" }); const filters = page.getByRole("listbox", { name: "Room list filters" });
await filters.getByRole("option", { name: "People" }).click(); await filters.getByRole("option", { name: "People" }).click();
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).not.toBeVisible(); await expect(roomListView.getByRole("option", { name: "Open room room0" })).not.toBeVisible();
await filters.getByRole("option", { name: "People" }).click(); await filters.getByRole("option", { name: "People" }).click();
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible(); await expect(roomListView.getByRole("option", { name: "Open room room0" })).toBeVisible();
}); });
test.describe("Shortcuts", () => { test.describe("Shortcuts", () => {
test("should select the next room", async ({ page, app, user }) => { test("should select the next room", async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); await roomListView.getByRole("option", { name: "Open room room29" }).click();
await page.keyboard.press("Alt+ArrowDown"); await page.keyboard.press("Alt+ArrowDown");
await expect(page.getByRole("heading", { name: "room28", level: 1 })).toBeVisible(); await expect(page.getByRole("heading", { name: "room28", level: 1 })).toBeVisible();
@@ -165,7 +169,7 @@ test.describe("Room list", () => {
test("should select the previous room", async ({ page, app, user }) => { test("should select the previous room", async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click(); await roomListView.getByRole("option", { name: "Open room room28" }).click();
await page.keyboard.press("Alt+ArrowUp"); await page.keyboard.press("Alt+ArrowUp");
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible(); await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
@@ -173,7 +177,7 @@ test.describe("Room list", () => {
test("should select the last room", async ({ page, app, user }) => { test("should select the last room", async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click(); await roomListView.getByRole("option", { name: "Open room room29" }).click();
await page.keyboard.press("Alt+ArrowUp"); await page.keyboard.press("Alt+ArrowUp");
await expect(page.getByRole("heading", { name: "room0", level: 1 })).toBeVisible(); await expect(page.getByRole("heading", { name: "room0", level: 1 })).toBeVisible();
@@ -187,7 +191,7 @@ test.describe("Room list", () => {
await bot.joinRoom(roomId); await bot.joinRoom(roomId);
await bot.sendMessage(roomId, "I am a robot. Beep."); await bot.sendMessage(roomId, "I am a robot. Beep.");
await roomListView.getByRole("gridcell", { name: "Open room room20" }).click(); await roomListView.getByRole("option", { name: "Open room room20" }).click();
await page.keyboard.press("Alt+Shift+ArrowDown"); await page.keyboard.press("Alt+Shift+ArrowDown");
@@ -199,8 +203,8 @@ test.describe("Room list", () => {
test("should navigate to the room list", async ({ page, app, user }) => { test("should navigate to the room list", async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
const room29 = roomListView.getByRole("gridcell", { name: "Open room room29" }); const room29 = roomListView.getByRole("option", { name: "Open room room29" });
const room28 = roomListView.getByRole("gridcell", { name: "Open room room28" }); const room28 = roomListView.getByRole("option", { name: "Open room room28" });
// open the room // open the room
await room29.click(); await room29.click();
@@ -219,7 +223,7 @@ test.describe("Room list", () => {
test("should navigate to the notification menu", async ({ page, app, user }) => { test("should navigate to the notification menu", async ({ page, app, user }) => {
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
const room29 = roomListView.getByRole("gridcell", { name: "Open room room29" }); const room29 = roomListView.getByRole("option", { name: "Open room room29" });
const moreButton = room29.getByRole("button", { name: "More options" }); const moreButton = room29.getByRole("button", { name: "More options" });
const notificationButton = room29.getByRole("button", { name: "Notification options" }); const notificationButton = room29.getByRole("button", { name: "Notification options" });
@@ -258,7 +262,7 @@ test.describe("Room list", () => {
await page.getByRole("button", { name: "User menu" }).focus(); await page.getByRole("button", { name: "User menu" }).focus();
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
const publicRoom = roomListView.getByRole("gridcell", { name: "public room" }); const publicRoom = roomListView.getByRole("option", { name: "public room" });
await expect(publicRoom).toBeVisible(); await expect(publicRoom).toBeVisible();
await expect(publicRoom).toMatchScreenshot("room-list-item-public.png"); await expect(publicRoom).toMatchScreenshot("room-list-item-public.png");
@@ -268,7 +272,7 @@ test.describe("Room list", () => {
// @ts-ignore Visibility enum is not accessible // @ts-ignore Visibility enum is not accessible
await app.client.createRoom({ name: "low priority room", visibility: "public" }); await app.client.createRoom({ name: "low priority room", visibility: "public" });
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
const publicRoom = roomListView.getByRole("gridcell", { name: "low priority room" }); const publicRoom = roomListView.getByRole("option", { name: "low priority room" });
// Make room low priority // Make room low priority
await publicRoom.hover(); await publicRoom.hover();
@@ -293,7 +297,7 @@ test.describe("Room list", () => {
await page.getByRole("button", { name: "Create video room" }).click(); await page.getByRole("button", { name: "Create video room" }).click();
const roomListView = getRoomList(page); const roomListView = getRoomList(page);
const videoRoom = roomListView.getByRole("gridcell", { name: "video room" }); const videoRoom = roomListView.getByRole("option", { name: "video room" });
// focus the user menu to avoid to have hover decoration // focus the user menu to avoid to have hover decoration
await page.getByRole("button", { name: "User menu" }).focus(); await page.getByRole("button", { name: "User menu" }).focus();
@@ -312,7 +316,7 @@ test.describe("Room list", () => {
invite: [user.userId], invite: [user.userId],
is_direct: true, is_direct: true,
}); });
const invitedRoom = roomListView.getByRole("gridcell", { name: "invited room" }); const invitedRoom = roomListView.getByRole("option", { name: "invited room" });
await expect(invitedRoom).toBeVisible(); await expect(invitedRoom).toBeVisible();
await expect(invitedRoom).toMatchScreenshot("room-list-item-invited.png"); await expect(invitedRoom).toMatchScreenshot("room-list-item-invited.png");
}); });
@@ -327,7 +331,7 @@ test.describe("Room list", () => {
await bot.sendMessage(roomId, "I am a robot. Beep."); await bot.sendMessage(roomId, "I am a robot. Beep.");
await bot.sendMessage(roomId, "I am a robot. Beep."); await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("gridcell", { name: "2 notifications" }); const room = roomListView.getByRole("option", { name: "2 notifications" });
await expect(room).toBeVisible(); await expect(room).toBeVisible();
await expect(room.getByTestId("notification-decoration")).toHaveText("2"); await expect(room.getByTestId("notification-decoration")).toHaveText("2");
await expect(room).toMatchScreenshot("room-list-item-notification.png"); await expect(room).toMatchScreenshot("room-list-item-notification.png");
@@ -358,7 +362,7 @@ test.describe("Room list", () => {
); );
await bot.sendMessage(roomId, "I am a robot. Beep."); await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("gridcell", { name: "mention" }); const room = roomListView.getByRole("option", { name: "mention" });
await expect(room).toBeVisible(); await expect(room).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-mention.png"); await expect(room).toMatchScreenshot("room-list-item-mention.png");
}); });
@@ -379,7 +383,7 @@ test.describe("Room list", () => {
await bot.joinRoom(roomId); await bot.joinRoom(roomId);
await bot.sendMessage(roomId, "I am a robot. Beep."); await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("gridcell", { name: "activity" }); const room = roomListView.getByRole("option", { name: "activity" });
await expect(room.getByText("I am a robot. Beep.")).toBeVisible(); await expect(room.getByText("I am a robot. Beep.")).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-message-preview.png"); await expect(room).toMatchScreenshot("room-list-item-message-preview.png");
}); });
@@ -406,7 +410,7 @@ test.describe("Room list", () => {
await app.viewRoomById(otherRoomId); await app.viewRoomById(otherRoomId);
await bot.sendMessage(roomId, "I am a robot. Beep."); await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("gridcell", { name: "activity" }); const room = roomListView.getByRole("option", { name: "activity" });
await expect(room.getByTestId("notification-decoration")).toBeVisible(); await expect(room.getByTestId("notification-decoration")).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-activity.png"); await expect(room).toMatchScreenshot("room-list-item-activity.png");
}); });
@@ -418,7 +422,7 @@ test.describe("Room list", () => {
await app.client.inviteUser(roomId, bot.credentials.userId); await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId); await bot.joinRoom(roomId);
const room = roomListView.getByRole("gridcell", { name: "mark as unread" }); const room = roomListView.getByRole("option", { name: "mark as unread" });
await room.hover(); await room.hover();
await room.getByRole("button", { name: "More Options" }).click(); await room.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "mark as unread" }).click(); await page.getByRole("menuitem", { name: "mark as unread" }).click();
@@ -441,7 +445,7 @@ test.describe("Room list", () => {
await page.getByText("Off").click(); await page.getByText("Off").click();
await app.settings.closeDialog(); await app.settings.closeDialog();
const room = roomListView.getByRole("gridcell", { name: "silent" }); const room = roomListView.getByRole("option", { name: "silent" });
await expect(room.getByTestId("notification-decoration")).toBeVisible(); await expect(room.getByTestId("notification-decoration")).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-silent.png"); await expect(room).toMatchScreenshot("room-list-item-silent.png");
}); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

@@ -15,40 +15,44 @@
* |-------------------------------------------------------| * |-------------------------------------------------------|
*/ */
.mx_RoomListItemView { .mx_RoomListItemView {
all: unset; /* Remove button default style */
background: unset;
border: none;
padding: 0;
text-align: unset;
cursor: pointer; cursor: pointer;
height: 48px;
width: 100%;
.mx_RoomListItemView_container { padding-left: var(--cpd-space-3x);
padding-left: var(--cpd-space-3x); font: var(--cpd-font-body-md-regular);
font: var(--cpd-font-body-md-regular);
.mx_RoomListItemView_content {
height: 100%; height: 100%;
flex: 1;
/* The border is only under the room name and the future hover menu */
border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary);
box-sizing: border-box;
min-width: 0;
padding-right: var(--cpd-space-5x);
.mx_RoomListItemView_content { .mx_RoomListItemView_text {
height: 100%;
flex: 1;
/* The border is only under the room name and the future hover menu */
border-bottom: var(--cpd-border-width-0-5) solid var(--cpd-color-bg-subtle-secondary);
box-sizing: border-box;
min-width: 0; min-width: 0;
padding-right: var(--cpd-space-5x); }
.mx_RoomListItemView_text { .mx_RoomListItemView_roomName {
min-width: 0; white-space: nowrap;
} overflow: hidden;
text-overflow: ellipsis;
}
.mx_RoomListItemView_roomName { .mx_RoomListItemView_messagePreview {
white-space: nowrap; font: var(--cpd-font-body-sm-regular);
overflow: hidden; color: var(--cpd-color-text-secondary);
text-overflow: ellipsis; white-space: nowrap;
} overflow: hidden;
text-overflow: ellipsis;
.mx_RoomListItemView_messagePreview {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
} }
} }
} }
@@ -57,7 +61,7 @@
background-color: var(--cpd-color-bg-action-secondary-hovered); background-color: var(--cpd-color-bg-action-secondary-hovered);
} }
.mx_RoomListItemView_menu_open .mx_RoomListItemView_container .mx_RoomListItemView_content { .mx_RoomListItemView_menu_open .mx_RoomListItemView_content {
/** /**
* The figma uses 16px padding (--cpd-space-4x) but due to https://github.com/element-hq/compound-web/issues/331 * The figma uses 16px padding (--cpd-space-4x) but due to https://github.com/element-hq/compound-web/issues/331
* the icon size of the menu is 18px instead of 20px with a different internal padding * the icon size of the menu is 18px instead of 20px with a different internal padding
+9
View File
@@ -79,3 +79,12 @@ export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent)
return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey; return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
} }
} }
/**
* Checks if the given keyboard event is a modified key event (i.e., if any modifier keys are active).
* @param ev The keyboard event to check
* @returns True if the event is a modified key event, false otherwise
*/
export function isModifiedKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean {
return ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey;
}
+48 -30
View File
@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
import React, { useRef, type JSX, useCallback, useEffect, useState } from "react"; import React, { useRef, type JSX, useCallback, useEffect, useState } from "react";
import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso"; import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
import { isModifiedKeyEvent, Key } from "../../Keyboard";
/** /**
* Context object passed to each list item containing the currently focused key * Context object passed to each list item containing the currently focused key
* and any additional context data from the parent component. * and any additional context data from the parent component.
@@ -34,6 +35,7 @@ export interface IListViewProps<Item, Context>
* @param index - The index of the item in the list * @param index - The index of the item in the list
* @param item - The data item to render * @param item - The data item to render
* @param context - The context object containing the focused key and any additional data * @param context - The context object containing the focused key and any additional data
* @param onFocus - A callback that is required to be called when the item component receives focus
* @returns JSX element representing the rendered item * @returns JSX element representing the rendered item
*/ */
getItemComponent: ( getItemComponent: (
@@ -62,6 +64,14 @@ export interface IListViewProps<Item, Context>
* @return The key to use for focusing the item * @return The key to use for focusing the item
*/ */
getItemKey: (item: Item) => string; getItemKey: (item: Item) => string;
/**
* Callback function to handle key down events on the list container.
* ListView handles keyboard navigation for focus(up, down, home, end, pageUp, pageDown)
* and stops propagation otherwise the event bubbles and this callback is called for the use of the parent.
* @param e - The keyboard event
* @returns
*/
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
} }
/** /**
@@ -73,7 +83,7 @@ export interface IListViewProps<Item, Context>
*/ */
export function ListView<Item, Context = any>(props: IListViewProps<Item, Context>): React.ReactElement { export function ListView<Item, Context = any>(props: IListViewProps<Item, Context>): React.ReactElement {
// Extract our custom props to avoid conflicts with Virtuoso props // Extract our custom props to avoid conflicts with Virtuoso props
const { items, getItemComponent, isItemFocusable, getItemKey, context, ...virtuosoProps } = props; const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props;
/** Reference to the Virtuoso component for programmatic scrolling */ /** Reference to the Virtuoso component for programmatic scrolling */
const virtuosoHandleRef = useRef<VirtuosoHandle>(null); const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
/** Reference to the DOM element containing the virtualized list */ /** Reference to the DOM element containing the virtualized list */
@@ -125,7 +135,7 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
const key = getItemKey(items[clampedIndex]); const key = getItemKey(items[clampedIndex]);
setTabIndexKey(key); setTabIndexKey(key);
isScrollingToItem.current = true; isScrollingToItem.current = true;
virtuosoHandleRef?.current?.scrollIntoView({ virtuosoHandleRef.current?.scrollIntoView({
index: clampedIndex, index: clampedIndex,
align: align, align: align,
behavior: "auto", behavior: "auto",
@@ -168,40 +178,44 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
* Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space. * Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space.
*/ */
const keyDownCallback = useCallback( const keyDownCallback = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!e) return; // Guard against null/undefined events
const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined; const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined;
let handled = false; let handled = false;
if (e.code === "ArrowUp" && currentIndex !== undefined) {
scrollToItem(currentIndex - 1, false); // Guard against null/undefined events and modified keys which we don't want to handle here but do
handled = true; // at the settings level shortcuts(E.g. Select next room, etc )
} else if (e.code === "ArrowDown" && currentIndex !== undefined) { if (e || !isModifiedKeyEvent(e)) {
scrollToItem(currentIndex + 1, true); if (e.code === Key.ARROW_UP && currentIndex !== undefined) {
handled = true; scrollToItem(currentIndex - 1, false);
} else if (e.code === "Home") { handled = true;
scrollToIndex(0); } else if (e.code === Key.ARROW_DOWN && currentIndex !== undefined) {
handled = true; scrollToItem(currentIndex + 1, true);
} else if (e.code === "End") { handled = true;
scrollToIndex(items.length - 1); } else if (e.code === Key.HOME) {
handled = true; scrollToIndex(0);
} else if (e.code === "PageDown" && visibleRange && currentIndex !== undefined) { handled = true;
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex; } else if (e.code === Key.END) {
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`); scrollToIndex(items.length - 1);
handled = true; handled = true;
} else if (e.code === "PageUp" && visibleRange && currentIndex !== undefined) { } else if (e.code === Key.PAGE_DOWN && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex; const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, `start`); scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`);
handled = true; handled = true;
} else if (e.code === Key.PAGE_UP && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, `start`);
handled = true;
}
} }
if (handled) { if (handled) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
} else {
onKeyDown?.(e);
} }
}, },
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items], [scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown],
); );
/** /**
@@ -251,8 +265,12 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
[keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey], [keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey],
); );
const onBlur = useCallback((): void => { const onBlur = useCallback((event: React.FocusEvent<HTMLDivElement>): void => {
setIsFocused(false); // Only set isFocused to false if the focus is moving outside the list
// This prevents the list from losing focus when interacting with menus inside it
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsFocused(false);
}
}, []); }, []);
const listContext: ListContext<Context> = { const listContext: ListContext<Context> = {
@@ -264,8 +282,8 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
return ( return (
<Virtuoso <Virtuoso
tabIndex={props.tabIndex || undefined} // We don't need to focus the container, so leave it undefined by default tabIndex={props.tabIndex || undefined} // We don't need to focus the container, so leave it undefined by default
scrollerRef={scrollerRef}
ref={virtuosoHandleRef} ref={virtuosoHandleRef}
scrollerRef={scrollerRef}
onKeyDown={keyDownCallback} onKeyDown={keyDownCallback}
context={listContext} context={listContext}
rangeChanged={setVisibleRange} rangeChanged={setVisibleRange}
@@ -18,6 +18,7 @@ import { Action } from "../../../dispatcher/actions";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useStickyRoomList } from "./useStickyRoomList"; import { useStickyRoomList } from "./useStickyRoomList";
import { useRoomListNavigation } from "./useRoomListNavigation"; import { useRoomListNavigation } from "./useRoomListNavigation";
import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
export interface RoomListViewState { export interface RoomListViewState {
/** /**
@@ -26,9 +27,9 @@ export interface RoomListViewState {
isLoadingRooms: boolean; isLoadingRooms: boolean;
/** /**
* A list of rooms to be displayed in the left panel. * The room results to be displayed (along with the spaceId and filter keys at the time of query)
*/ */
rooms: Room[]; roomsResult: RoomsResult;
/** /**
* Create a chat room * Create a chat room
@@ -71,10 +72,10 @@ export interface RoomListViewState {
*/ */
export function useRoomListViewModel(): RoomListViewState { export function useRoomListViewModel(): RoomListViewState {
const matrixClient = useMatrixClientContext(); const matrixClient = useMatrixClientContext();
const { isLoadingRooms, primaryFilters, activePrimaryFilter, rooms: filteredRooms } = useFilteredRooms(); const { isLoadingRooms, primaryFilters, activePrimaryFilter, roomsResult: filteredRooms } = useFilteredRooms();
const { activeIndex, rooms } = useStickyRoomList(filteredRooms); const { activeIndex, roomsResult } = useStickyRoomList(filteredRooms);
useRoomListNavigation(rooms); useRoomListNavigation(roomsResult.rooms);
const currentSpace = useEventEmitterState<Room | null>( const currentSpace = useEventEmitterState<Room | null>(
SpaceStore.instance, SpaceStore.instance,
@@ -88,7 +89,7 @@ export function useRoomListViewModel(): RoomListViewState {
return { return {
isLoadingRooms, isLoadingRooms,
rooms, roomsResult,
canCreateRoom, canCreateRoom,
createRoom, createRoom,
createChatRoom, createChatRoom,
@@ -5,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters"; import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { _t, _td, type TranslationKey } from "../../../languageHandler"; import { _t, _td, type TranslationKey } from "../../../languageHandler";
import RoomListStoreV3, { LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT } from "../../../stores/room-list-v3/RoomListStoreV3"; import RoomListStoreV3, {
LISTS_LOADED_EVENT,
LISTS_UPDATE_EVENT,
type RoomsResult,
} from "../../../stores/room-list-v3/RoomListStoreV3";
import { useEventEmitter } from "../../../hooks/useEventEmitter"; import { useEventEmitter } from "../../../hooks/useEventEmitter";
import SpaceStore from "../../../stores/spaces/SpaceStore"; import SpaceStore from "../../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces"; import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
@@ -35,7 +38,7 @@ export interface PrimaryFilter {
interface FilteredRooms { interface FilteredRooms {
primaryFilters: PrimaryFilter[]; primaryFilters: PrimaryFilter[];
isLoadingRooms: boolean; isLoadingRooms: boolean;
rooms: Room[]; roomsResult: RoomsResult;
/** /**
* The currently active primary filter. * The currently active primary filter.
* If no primary filter is active, this will be undefined. * If no primary filter is active, this will be undefined.
@@ -63,12 +66,12 @@ export function useFilteredRooms(): FilteredRooms {
*/ */
const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>(); const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>();
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace()); const [roomsResult, setRoomsResult] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
const [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms); const [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms);
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => { const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters); const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
setRooms(newRooms); setRoomsResult(newRooms);
}, []); }, []);
// Reset filters when active space changes // Reset filters when active space changes
@@ -77,9 +80,15 @@ export function useFilteredRooms(): FilteredRooms {
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] => const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
array.filter((f) => f !== undefined) as FilterKey[]; array.filter((f) => f !== undefined) as FilterKey[];
const getAppliedFilters = (): FilterKey[] => { const getAppliedFilters = useCallback((): FilterKey[] => {
return filterUndefined([primaryFilter]); return filterUndefined([primaryFilter]);
}; }, [primaryFilter]);
useEffect(() => {
// Update the rooms state when the primary filter changes
const filters = getAppliedFilters();
updateRoomsFromStore(filters);
}, [getAppliedFilters, updateRoomsFromStore]);
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => { useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
const filters = getAppliedFilters(); const filters = getAppliedFilters();
@@ -122,6 +131,6 @@ export function useFilteredRooms(): FilteredRooms {
isLoadingRooms, isLoadingRooms,
primaryFilters, primaryFilters,
activePrimaryFilter, activePrimaryFilter,
rooms, roomsResult,
}; };
} }
@@ -14,6 +14,7 @@ import { Action } from "../../../dispatcher/actions";
import type { Room } from "matrix-js-sdk/src/matrix"; import type { Room } from "matrix-js-sdk/src/matrix";
import type { Optional } from "matrix-events-sdk"; import type { Optional } from "matrix-events-sdk";
import SpaceStore from "../../../stores/spaces/SpaceStore"; import SpaceStore from "../../../stores/spaces/SpaceStore";
import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
function getIndexByRoomId(rooms: Room[], roomId: Optional<string>): number | undefined { function getIndexByRoomId(rooms: Room[], roomId: Optional<string>): number | undefined {
const index = rooms.findIndex((room) => room.roomId === roomId); const index = rooms.findIndex((room) => room.roomId === roomId);
@@ -67,11 +68,11 @@ function getRoomsWithStickyRoom(
return { newIndex: oldIndex, newRooms }; return { newIndex: oldIndex, newRooms };
} }
interface StickyRoomListResult { export interface StickyRoomListResult {
/** /**
* List of rooms with sticky active room. * The rooms result with the active sticky room applied
*/ */
rooms: Room[]; roomsResult: RoomsResult;
/** /**
* Index of the active room in the room list. * Index of the active room in the room list.
*/ */
@@ -85,10 +86,10 @@ interface StickyRoomListResult {
* @param rooms list of rooms * @param rooms list of rooms
* @see {@link StickyRoomListResult} details what this hook returns.. * @see {@link StickyRoomListResult} details what this hook returns..
*/ */
export function useStickyRoomList(rooms: Room[]): StickyRoomListResult { export function useStickyRoomList(roomsResult: RoomsResult): StickyRoomListResult {
const [listState, setListState] = useState<{ index: number | undefined; roomsWithStickyRoom: Room[] }>({ const [listState, setListState] = useState<StickyRoomListResult>({
index: undefined, activeIndex: getIndexByRoomId(roomsResult.rooms, SdkContextClass.instance.roomViewStore.getRoomId()),
roomsWithStickyRoom: rooms, roomsResult: roomsResult,
}); });
const currentSpaceRef = useRef(SpaceStore.instance.activeSpace); const currentSpaceRef = useRef(SpaceStore.instance.activeSpace);
@@ -97,13 +98,18 @@ export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
(newRoomId: string | null, isRoomChange: boolean = false) => { (newRoomId: string | null, isRoomChange: boolean = false) => {
setListState((current) => { setListState((current) => {
const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId(); const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId();
const newActiveIndex = getIndexByRoomId(rooms, activeRoomId); const newActiveIndex = getIndexByRoomId(roomsResult.rooms, activeRoomId);
const oldIndex = current.index; const oldIndex = current.activeIndex;
const { newIndex, newRooms } = getRoomsWithStickyRoom(rooms, oldIndex, newActiveIndex, isRoomChange); const { newIndex, newRooms } = getRoomsWithStickyRoom(
return { index: newIndex, roomsWithStickyRoom: newRooms }; roomsResult.rooms,
oldIndex,
newActiveIndex,
isRoomChange,
);
return { activeIndex: newIndex, roomsResult: { ...roomsResult, rooms: newRooms } };
}); });
}, },
[rooms], [roomsResult],
); );
// Re-calculate the index when the active room has changed. // Re-calculate the index when the active room has changed.
@@ -115,20 +121,19 @@ export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
useEffect(() => { useEffect(() => {
let newRoomId: string | null = null; let newRoomId: string | null = null;
let isRoomChange = false; let isRoomChange = false;
const newSpace = SpaceStore.instance.activeSpace; if (currentSpaceRef.current !== roomsResult.spaceId) {
if (currentSpaceRef.current !== newSpace) {
/* /*
If the space has changed, we check if we can immediately set the active If the space has changed, we check if we can immediately set the active
index to the last opened room in that space. Otherwise, we might see a index to the last opened room in that space. Otherwise, we might see a
flicker because of the delay between the space change event and flicker because of the delay between the space change event and
active room change dispatch. active room change dispatch.
*/ */
newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpace); newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(roomsResult.spaceId);
isRoomChange = true; isRoomChange = true;
currentSpaceRef.current = newSpace; currentSpaceRef.current = roomsResult.spaceId;
} }
updateRoomsAndIndex(newRoomId, isRoomChange); updateRoomsAndIndex(newRoomId, isRoomChange);
}, [rooms, updateRoomsAndIndex]); }, [roomsResult, updateRoomsAndIndex]);
return { activeIndex: listState.index, rooms: listState.roomsWithStickyRoom }; return listState;
} }
@@ -5,13 +5,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import React, { useCallback, type JSX } from "react"; import React, { useCallback, useRef, type JSX } from "react";
import { AutoSizer, List, type ListRowProps } from "react-virtualized"; import { type Room } from "matrix-js-sdk/src/matrix";
import { type ScrollIntoViewLocation } from "react-virtuoso";
import { isEqual } from "lodash";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel"; import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
import { RoomListItemView } from "./RoomListItemView"; import { RoomListItemView } from "./RoomListItemView";
import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex"; import { type ListContext, ListView } from "../../../utils/ListView";
import { type FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation"; import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation";
@@ -26,55 +29,93 @@ interface RoomListProps {
/** /**
* A virtualized list of rooms. * A virtualized list of rooms.
*/ */
export function RoomList({ vm: { rooms, activeIndex } }: RoomListProps): JSX.Element { export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): JSX.Element {
const roomRendererMemoized = useCallback( const lastSpaceId = useRef<string | undefined>(undefined);
({ key, index, style }: ListRowProps) => ( const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
<RoomListItemView room={rooms[index]} key={key} style={style} isSelected={activeIndex === index} /> const roomCount = roomsResult.rooms.length;
), const getItemComponent = useCallback(
[rooms, activeIndex], (
index: number,
item: Room,
context: ListContext<{
spaceId: string;
filterKeys: FilterKey[] | undefined;
}>,
onFocus: (e: React.FocusEvent) => void,
): JSX.Element => {
const itemKey = item.roomId;
const isRovingItem = itemKey === context.tabIndexKey;
const isFocused = isRovingItem && context.focused;
const isSelected = activeIndex === index;
return (
<RoomListItemView
room={item}
key={itemKey}
isSelected={isSelected}
isFocused={isFocused}
tabIndex={isRovingItem ? 0 : -1}
roomIndex={index}
roomCount={roomCount}
onFocus={onFocus}
/>
);
},
[activeIndex, roomCount],
); );
// The first div is needed to make the virtualized list take all the remaining space and scroll correctly const getItemKey = useCallback((item: Room): string => {
return item.roomId;
}, []);
const scrollIntoViewOnChange = useCallback(
(params: {
context: ListContext<{ spaceId: string; filterKeys: FilterKey[] | undefined }>;
}): ScrollIntoViewLocation | null | undefined | false | void => {
const { spaceId, filterKeys } = params.context.context;
const shouldScrollIndexIntoView =
lastSpaceId.current !== spaceId || !isEqual(lastFilterKeys.current, filterKeys);
lastFilterKeys.current = filterKeys;
lastSpaceId.current = spaceId;
if (shouldScrollIndexIntoView) {
return {
align: `start`,
index: activeIndex || 0,
behavior: "auto",
};
}
return false;
},
[activeIndex],
);
const keyDownCallback = useCallback((ev: React.KeyboardEvent) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (navAction === KeyBindingAction.NextLandmark || navAction === KeyBindingAction.PreviousLandmark) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_LIST,
navAction === KeyBindingAction.PreviousLandmark,
);
ev.stopPropagation();
ev.preventDefault();
return;
}
}, []);
return ( return (
<RovingTabIndexProvider handleHomeEnd={true} handleUpDown={true}> <ListView
{({ onKeyDownHandler }) => ( context={{ spaceId: roomsResult.spaceId, filterKeys: roomsResult.filterKeys }}
<div scrollIntoViewOnChange={scrollIntoViewOnChange}
className="mx_RoomList" initialTopMostItemIndex={activeIndex}
data-testid="room-list" data-testid="room-list"
onKeyDown={(ev) => { role="listbox"
const navAction = getKeyBindingsManager().getNavigationAction(ev); aria-label={_t("room_list|list_title")}
if ( fixedItemHeight={48}
navAction === KeyBindingAction.NextLandmark || items={roomsResult.rooms}
navAction === KeyBindingAction.PreviousLandmark getItemComponent={getItemComponent}
) { getItemKey={getItemKey}
LandmarkNavigation.findAndFocusNextLandmark( isItemFocusable={() => true}
Landmark.ROOM_LIST, onKeyDown={keyDownCallback}
navAction === KeyBindingAction.PreviousLandmark, />
);
ev.stopPropagation();
ev.preventDefault();
return;
}
onKeyDownHandler(ev);
}}
>
<AutoSizer>
{({ height, width }) => (
<List
aria-label={_t("room_list|list_title")}
className="mx_RoomList_List"
rowRenderer={roomRendererMemoized}
rowCount={rooms.length}
rowHeight={48}
height={height}
width={width}
scrollToIndex={activeIndex ?? 0}
tabIndex={-1}
/>
)}
</AutoSizer>
</div>
)}
</RovingTabIndexProvider>
); );
} }
@@ -97,7 +97,10 @@ interface MoreOptionContentProps {
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element { export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
return ( return (
<> <div
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
>
{vm.canMarkAsRead && ( {vm.canMarkAsRead && (
<MenuItem <MenuItem
Icon={MarkAsReadIcon} Icon={MarkAsReadIcon}
@@ -157,7 +160,7 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
onClick={(evt) => evt.stopPropagation()} onClick={(evt) => evt.stopPropagation()}
hideChevron={true} hideChevron={true}
/> />
</> </div>
); );
} }
@@ -196,54 +199,59 @@ function NotificationMenu({ vm, setMenuOpen }: NotificationMenuProps): JSX.Eleme
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />; const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
return ( return (
<Menu <div
open={open} // We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onOpenChange={(isOpen) => { onKeyDown={(e) => e.stopPropagation()}
setOpen(isOpen);
setMenuOpen(isOpen);
}}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={<NotificationButton isRoomMuted={vm.isNotificationMute} size="24px" />}
> >
<MenuItem <Menu
aria-selected={vm.isNotificationAllMessage} open={open}
hideChevron={true} onOpenChange={(isOpen) => {
label={_t("notifications|default_settings")} setOpen(isOpen);
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessages)} setMenuOpen(isOpen);
onClick={(evt) => evt.stopPropagation()} }}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={<NotificationButton isRoomMuted={vm.isNotificationMute} size="24px" />}
> >
{vm.isNotificationAllMessage && checkComponent} <MenuItem
</MenuItem> aria-selected={vm.isNotificationAllMessage}
<MenuItem hideChevron={true}
aria-selected={vm.isNotificationAllMessageLoud} label={_t("notifications|default_settings")}
hideChevron={true} onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessages)}
label={_t("notifications|all_messages")} onClick={(evt) => evt.stopPropagation()}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)} >
onClick={(evt) => evt.stopPropagation()} {vm.isNotificationAllMessage && checkComponent}
> </MenuItem>
{vm.isNotificationAllMessageLoud && checkComponent} <MenuItem
</MenuItem> aria-selected={vm.isNotificationAllMessageLoud}
<MenuItem hideChevron={true}
aria-selected={vm.isNotificationMentionOnly} label={_t("notifications|all_messages")}
hideChevron={true} onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
label={_t("notifications|mentions_keywords")} onClick={(evt) => evt.stopPropagation()}
onSelect={() => vm.setRoomNotifState(RoomNotifState.MentionsOnly)} >
onClick={(evt) => evt.stopPropagation()} {vm.isNotificationAllMessageLoud && checkComponent}
> </MenuItem>
{vm.isNotificationMentionOnly && checkComponent} <MenuItem
</MenuItem> aria-selected={vm.isNotificationMentionOnly}
<MenuItem hideChevron={true}
aria-selected={vm.isNotificationMute} label={_t("notifications|mentions_keywords")}
hideChevron={true} onSelect={() => vm.setRoomNotifState(RoomNotifState.MentionsOnly)}
label={_t("notifications|mute_room")} onClick={(evt) => evt.stopPropagation()}
onSelect={() => vm.setRoomNotifState(RoomNotifState.Mute)} >
onClick={(evt) => evt.stopPropagation()} {vm.isNotificationMentionOnly && checkComponent}
> </MenuItem>
{vm.isNotificationMute && checkComponent} <MenuItem
</MenuItem> aria-selected={vm.isNotificationMute}
</Menu> hideChevron={true}
label={_t("notifications|mute_room")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.Mute)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMute && checkComponent}
</MenuItem>
</Menu>
</div>
); );
} }
@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, memo, useCallback, useRef, useState } from "react"; import React, { type JSX, memo, useCallback, useEffect, useRef, useState } from "react";
import { type Room } from "matrix-js-sdk/src/matrix"; import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames"; import classNames from "classnames";
@@ -14,7 +14,6 @@ import { Flex } from "../../../../shared-components/utils/Flex";
import { RoomListItemMenuView } from "./RoomListItemMenuView"; import { RoomListItemMenuView } from "./RoomListItemMenuView";
import { NotificationDecoration } from "../NotificationDecoration"; import { NotificationDecoration } from "../NotificationDecoration";
import { RoomAvatarView } from "../../avatars/RoomAvatarView"; import { RoomAvatarView } from "../../avatars/RoomAvatarView";
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
import { RoomListItemContextMenuView } from "./RoomListItemContextMenuView"; import { RoomListItemContextMenuView } from "./RoomListItemContextMenuView";
interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement> { interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement> {
@@ -26,6 +25,22 @@ interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement>
* Whether the room is selected * Whether the room is selected
*/ */
isSelected: boolean; isSelected: boolean;
/**
* Whether the room is focused
*/
isFocused: boolean;
/**
* A callback that indicates the item has received focus
*/
onFocus: (e: React.FocusEvent) => void;
/**
* The index of the room in the list
*/
roomIndex: number;
/**
* The total number of rooms in the list
*/
roomCount: number;
} }
/** /**
@@ -34,18 +49,19 @@ interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement>
export const RoomListItemView = memo(function RoomListItemView({ export const RoomListItemView = memo(function RoomListItemView({
room, room,
isSelected, isSelected,
isFocused,
onFocus,
roomIndex: index,
roomCount: count,
...props ...props
}: RoomListItemViewProps): JSX.Element { }: RoomListItemViewProps): JSX.Element {
const buttonRef = useRef<HTMLButtonElement>(null); const ref = useRef<HTMLButtonElement>(null);
const [onFocus, isActive, ref] = useRovingTabIndex(buttonRef);
const vm = useRoomListItemViewModel(room); const vm = useRoomListItemViewModel(room);
const [isHover, setHover] = useState(false);
const [isHover, setIsHoverWithDelay] = useIsHover();
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
// The compound menu in RoomListItemMenuView needs to be rendered when the hover menu is shown // The compound menu in RoomListItemMenuView needs to be rendered when the hover menu is shown
// Using display: none; and then display:flex when hovered in CSS causes the menu to be misaligned // Using display: none; and then display:flex when hovered in CSS causes the menu to be misaligned
const showHoverDecoration = isMenuOpen || isHover; const showHoverDecoration = isMenuOpen || isFocused || isHover;
const showHoverMenu = showHoverDecoration && vm.showHoverMenu; const showHoverMenu = showHoverDecoration && vm.showHoverMenu;
const closeMenu = useCallback(() => { const closeMenu = useCallback(() => {
@@ -54,8 +70,15 @@ export const RoomListItemView = memo(function RoomListItemView({
setTimeout(() => setIsMenuOpen(false), 10); setTimeout(() => setIsMenuOpen(false), 10);
}, []); }, []);
useEffect(() => {
if (isFocused) {
ref.current?.focus({ preventScroll: true, focusVisible: true });
}
}, [isFocused]);
const content = ( const content = (
<button <Flex
as="button"
ref={ref} ref={ref}
className={classNames("mx_RoomListItemView", { className={classNames("mx_RoomListItemView", {
mx_RoomListItemView_hover: showHoverDecoration, mx_RoomListItemView_hover: showHoverDecoration,
@@ -63,63 +86,59 @@ export const RoomListItemView = memo(function RoomListItemView({
mx_RoomListItemView_selected: isSelected, mx_RoomListItemView_selected: isSelected,
mx_RoomListItemView_bold: vm.isBold, mx_RoomListItemView_bold: vm.isBold,
})} })}
gap="var(--cpd-space-3x)"
align="center"
type="button" type="button"
role="option"
aria-posinset={index + 1}
aria-setsize={count}
aria-selected={isSelected} aria-selected={isSelected}
aria-label={vm.a11yLabel} aria-label={vm.a11yLabel}
onClick={() => vm.openRoom()} onClick={() => vm.openRoom()}
onMouseOver={() => setIsHoverWithDelay(true)} onFocus={onFocus}
onMouseOut={() => setIsHoverWithDelay(false)} onMouseOver={() => setHover(true)}
onFocus={() => { onMouseOut={() => setHover(false)}
setIsHoverWithDelay(true); onBlur={() => setHover(false)}
onFocus(); tabIndex={isFocused ? 0 : -1}
}}
// Adding a timeout because when tabbing to go to the more options and notification menu, the focus moves out of the button
// The blur makes the button lose the hover state and these menu are not shown
// We delay the blur event to give time to the focus to move to the menu
onBlur={() => setIsHoverWithDelay(false, 10)}
tabIndex={isActive ? 0 : -1}
{...props} {...props}
> >
{/* We need this extra div between the button and the content in order to add a padding which is not messing with the virtualized list */} <RoomAvatarView room={room} />
<Flex className="mx_RoomListItemView_container" gap="var(--cpd-space-3x)" align="center"> <Flex
<RoomAvatarView room={room} /> className="mx_RoomListItemView_content"
<Flex gap="var(--cpd-space-2x)"
className="mx_RoomListItemView_content" align="center"
gap="var(--cpd-space-2x)" justify="space-between"
align="center" >
justify="space-between" {/* We truncate the room name when too long. Title here is to show the full name on hover */}
> <div className="mx_RoomListItemView_text">
{/* We truncate the room name when too long. Title here is to show the full name on hover */} <div className="mx_RoomListItemView_roomName" title={vm.name}>
<div className="mx_RoomListItemView_text"> {vm.name}
<div className="mx_RoomListItemView_roomName" title={vm.name}>
{vm.name}
</div>
{vm.messagePreview && (
<div className="mx_RoomListItemView_messagePreview" title={vm.messagePreview}>
{vm.messagePreview}
</div>
)}
</div> </div>
{showHoverMenu ? ( {vm.messagePreview && (
<RoomListItemMenuView <div className="mx_RoomListItemView_messagePreview" title={vm.messagePreview}>
room={room} {vm.messagePreview}
setMenuOpen={(isOpen) => (isOpen ? setIsMenuOpen(true) : closeMenu())} </div>
/>
) : (
<>
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
{vm.showNotificationDecoration && (
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
/>
)}
</>
)} )}
</Flex> </div>
{showHoverMenu ? (
<RoomListItemMenuView
room={room}
setMenuOpen={(isOpen) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
/>
) : (
<>
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
{vm.showNotificationDecoration && (
<NotificationDecoration
notificationState={vm.notificationState}
aria-hidden={true}
hasVideoCall={vm.hasParticipantInCall}
/>
)}
</>
)}
</Flex> </Flex>
</button> </Flex>
); );
if (!vm.showContextMenu) return content; if (!vm.showContextMenu) return content;
@@ -140,33 +159,3 @@ export const RoomListItemView = memo(function RoomListItemView({
</RoomListItemContextMenuView> </RoomListItemContextMenuView>
); );
}); });
/**
* Custom hook to manage the hover state of the room list item
* If the timeout is set, it will set the hover state after the timeout
* If the timeout is not set, it will set the hover state immediately
* When the set method is called, it will clear any existing timeout
*
* @returns {boolean} isHover - The hover state
*/
function useIsHover(): [boolean, (value: boolean, timeout?: number) => void] {
const [isHover, setIsHover] = useState(false);
// Store the timeout ID
const timeoutRef = useRef<number | undefined>(undefined);
const setIsHoverWithDelay = useCallback((value: boolean, timeout?: number): void => {
// Clear the timeout if it exists
clearTimeout(timeoutRef.current);
// No delay, set the value immediately
if (timeout === undefined) {
setIsHover(value);
return;
}
// Set a timeout to set the value after the delay
timeoutRef.current = setTimeout(() => setIsHover(value), timeout);
}, []);
return [isHover, setIsHoverWithDelay];
}
@@ -17,7 +17,7 @@ import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
*/ */
export function RoomListView(): JSX.Element { export function RoomListView(): JSX.Element {
const vm = useRoomListViewModel(); const vm = useRoomListViewModel();
const isRoomListEmpty = vm.rooms.length === 0; const isRoomListEmpty = vm.roomsResult.rooms.length === 0;
let listBody; let listBody;
if (vm.isLoadingRooms) { if (vm.isLoadingRooms) {
listBody = <div className="mx_RoomListSkeleton" />; listBody = <div className="mx_RoomListSkeleton" />;
+20 -4
View File
@@ -22,7 +22,7 @@ import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter";
import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { EffectiveMembership, getEffectiveMembership, getEffectiveMembershipTag } from "../../utils/membership"; import { EffectiveMembership, getEffectiveMembership, getEffectiveMembershipTag } from "../../utils/membership";
import SpaceStore from "../spaces/SpaceStore"; import SpaceStore from "../spaces/SpaceStore";
import { UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces"; import { type SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces";
import { FavouriteFilter } from "./skip-list/filters/FavouriteFilter"; import { FavouriteFilter } from "./skip-list/filters/FavouriteFilter";
import { UnreadFilter } from "./skip-list/filters/UnreadFilter"; import { UnreadFilter } from "./skip-list/filters/UnreadFilter";
import { PeopleFilter } from "./skip-list/filters/PeopleFilter"; import { PeopleFilter } from "./skip-list/filters/PeopleFilter";
@@ -56,6 +56,16 @@ export enum RoomListStoreV3Event {
ListsLoaded = "lists_loaded", ListsLoaded = "lists_loaded",
} }
// The result object for returning rooms from the store
export type RoomsResult = {
// The ID of the active space queried
spaceId: SpaceKey;
// The filter queried
filterKeys?: FilterKey[];
// The resulting list of rooms
rooms: Room[];
};
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate; export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded; export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
/** /**
@@ -107,9 +117,15 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
* @param filterKeys Optional array of filters that the rooms must match against. * @param filterKeys Optional array of filters that the rooms must match against.
*/ */
public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): Room[] { public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): RoomsResult {
if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys)); const spaceId = SpaceStore.instance.activeSpace;
else return []; if (this.roomSkipList?.initialized)
return {
spaceId: spaceId,
filterKeys,
rooms: Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys)),
};
else return { spaceId: spaceId, filterKeys, rooms: [] };
} }
/** /**
@@ -209,7 +209,7 @@ describe("useRoomListHeaderViewModel", () => {
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined)); const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
const fn = jest const fn = jest
.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace") .spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace")
.mockImplementation(() => [...rooms]); .mockImplementation(() => ({ spaceId: "home", rooms: [...rooms] }));
return { rooms, fn }; return { rooms, fn };
} }
@@ -30,7 +30,7 @@ describe("RoomListViewModel", () => {
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined)); const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
const fn = jest const fn = jest
.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace") .spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace")
.mockImplementation(() => [...rooms]); .mockImplementation(() => ({ spaceId: "home", rooms: [...rooms] }));
return { rooms, fn }; return { rooms, fn };
} }
@@ -42,9 +42,9 @@ describe("RoomListViewModel", () => {
const { rooms } = mockAndCreateRooms(); const { rooms } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel()); const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.rooms).toHaveLength(10); expect(vm.current.roomsResult.rooms).toHaveLength(10);
for (const room of rooms) { for (const room of rooms) {
expect(vm.current.rooms).toContain(room); expect(vm.current.roomsResult.rooms).toContain(room);
} }
}); });
@@ -57,7 +57,7 @@ describe("RoomListViewModel", () => {
await act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT)); await act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
await waitFor(() => { await waitFor(() => {
expect(vm.current.rooms).toContain(newRoom); expect(vm.current.roomsResult.rooms).toContain(newRoom);
}); });
}); });
@@ -176,7 +176,7 @@ describe("RoomListViewModel", () => {
describe("Sticky room and active index", () => { describe("Sticky room and active index", () => {
function expectActiveRoom(vm: ReturnType<typeof useRoomListViewModel>, i: number, roomId: string) { function expectActiveRoom(vm: ReturnType<typeof useRoomListViewModel>, i: number, roomId: string) {
expect(vm.activeIndex).toEqual(i); expect(vm.activeIndex).toEqual(i);
expect(vm.rooms[i].roomId).toEqual(roomId); expect(vm.roomsResult.rooms[i].roomId).toEqual(roomId);
} }
it("active index is calculated with the last opened room in a space", () => { it("active index is calculated with the last opened room in a space", () => {
@@ -187,9 +187,9 @@ describe("RoomListViewModel", () => {
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined)); const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
// Let's say all the rooms are in space1 // Let's say all the rooms are in space1
const roomsInSpace1 = [...rooms]; const roomsInSpace1 = { spaceId: currentSpace, rooms: [...rooms] };
// Let's say all rooms with even index are in space 2 // Let's say all rooms with even index are in space 2
const roomsInSpace2 = [...rooms].filter((_, i) => i % 2 === 0); const roomsInSpace2 = { spaceId: "!space2:matrix.org", rooms: [...rooms].filter((_, i) => i % 2 === 0) };
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockImplementation(() => jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockImplementation(() =>
currentSpace === "!space1:matrix.org" ? roomsInSpace1 : roomsInSpace2, currentSpace === "!space1:matrix.org" ? roomsInSpace1 : roomsInSpace2,
); );
@@ -19,7 +19,7 @@ describe("<EmptyRoomList />", () => {
beforeEach(() => { beforeEach(() => {
vm = { vm = {
isLoadingRooms: false, isLoadingRooms: false,
rooms: [], roomsResult: { spaceId: "home", rooms: [] },
primaryFilters: [], primaryFilters: [],
createRoom: jest.fn(), createRoom: jest.fn(),
createChatRoom: jest.fn(), createChatRoom: jest.fn(),
@@ -9,28 +9,25 @@ import React from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { render } from "jest-matrix-react"; import { render } from "jest-matrix-react";
import { fireEvent } from "@testing-library/dom"; import { fireEvent } from "@testing-library/dom";
import { VirtuosoMockContext } from "react-virtuoso";
import { mkRoom, stubClient, withClientContextRenderOptions } from "../../../../../test-utils";
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel"; import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { RoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomList"; import { RoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomList";
import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { Landmark, LandmarkNavigation } from "../../../../../../src/accessibility/LandmarkNavigation"; import { Landmark, LandmarkNavigation } from "../../../../../../src/accessibility/LandmarkNavigation";
import { mkRoom, stubClient } from "../../../../../test-utils";
describe("<RoomList />", () => { describe("<RoomList />", () => {
let matrixClient: MatrixClient; let matrixClient: MatrixClient;
let vm: RoomListViewState; let vm: RoomListViewState;
beforeEach(() => { beforeEach(() => {
// Needed to render the virtualized list in rtl tests
// https://github.com/bvaughn/react-virtualized/issues/493#issuecomment-640084107
jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(1500);
jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(1500);
matrixClient = stubClient(); matrixClient = stubClient();
const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`)); const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`));
vm = { vm = {
isLoadingRooms: false, isLoadingRooms: false,
rooms, roomsResult: { spaceId: "home", rooms },
primaryFilters: [], primaryFilters: [],
createRoom: jest.fn(), createRoom: jest.fn(),
createChatRoom: jest.fn(), createChatRoom: jest.fn(),
@@ -44,7 +41,18 @@ describe("<RoomList />", () => {
}); });
it("should render a room list", () => { it("should render a room list", () => {
const { asFragment } = render(<RoomList vm={vm} />, withClientContextRenderOptions(matrixClient)); 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(); expect(asFragment()).toMatchSnapshot();
}); });
@@ -53,7 +61,15 @@ describe("<RoomList />", () => {
{ shortcut: { key: "F6", ctrlKey: true }, isPreviousLandmark: false, label: "NextLandmark" }, { shortcut: { key: "F6", ctrlKey: true }, isPreviousLandmark: false, label: "NextLandmark" },
])("should navigate to the landmark on NextLandmark.$label action", ({ shortcut, isPreviousLandmark }) => { ])("should navigate to the landmark on NextLandmark.$label action", ({ shortcut, isPreviousLandmark }) => {
const spyFindLandmark = jest.spyOn(LandmarkNavigation, "findAndFocusNextLandmark").mockReturnValue(); const spyFindLandmark = jest.spyOn(LandmarkNavigation, "findAndFocusNextLandmark").mockReturnValue();
const { getByTestId } = render(<RoomList vm={vm} />, withClientContextRenderOptions(matrixClient)); 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"); const roomList = getByTestId("room-list");
fireEvent.keyDown(roomList, shortcut); fireEvent.keyDown(roomList, shortcut);
@@ -28,6 +28,20 @@ describe("<RoomListItemView />", () => {
let defaultValue: RoomListItemViewState; let defaultValue: RoomListItemViewState;
let matrixClient: MatrixClient; let matrixClient: MatrixClient;
let room: Room; 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,
};
return render(<RoomListItemView {...defaultProps} {...props} />, withClientContextRenderOptions(matrixClient));
};
beforeEach(() => { beforeEach(() => {
matrixClient = stubClient(); matrixClient = stubClient();
room = mkRoom(matrixClient, "room1"); room = mkRoom(matrixClient, "room1");
@@ -60,7 +74,10 @@ describe("<RoomListItemView />", () => {
test("should render a room item", () => { test("should render a room item", () => {
const onClick = jest.fn(); const onClick = jest.fn();
const { asFragment } = render(<RoomListItemView room={room} onClick={onClick} isSelected={false} />); const { asFragment } = renderRoomListItem({
onClick,
roomCount: 0,
});
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
@@ -68,15 +85,17 @@ describe("<RoomListItemView />", () => {
defaultValue.messagePreview = "The message looks like this"; defaultValue.messagePreview = "The message looks like this";
const onClick = jest.fn(); const onClick = jest.fn();
const { asFragment } = render(<RoomListItemView room={room} onClick={onClick} isSelected={false} />); const { asFragment } = renderRoomListItem({
onClick,
});
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
test("should call openRoom when clicked", async () => { test("should call openRoom when clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<RoomListItemView room={room} isSelected={false} />); renderRoomListItem();
await user.click(screen.getByRole("button", { name: `Open room ${room.name}` })); await user.click(screen.getByRole("option", { name: `Open room ${room.name}` }));
expect(defaultValue.openRoom).toHaveBeenCalled(); expect(defaultValue.openRoom).toHaveBeenCalled();
}); });
@@ -84,8 +103,9 @@ describe("<RoomListItemView />", () => {
mocked(useRoomListItemViewModel).mockReturnValue({ ...defaultValue, showHoverMenu: true }); mocked(useRoomListItemViewModel).mockReturnValue({ ...defaultValue, showHoverMenu: true });
const user = userEvent.setup(); const user = userEvent.setup();
render(<RoomListItemView room={room} isSelected={false} />, withClientContextRenderOptions(matrixClient)); renderRoomListItem();
const listItem = screen.getByRole("button", { name: `Open room ${room.name}` });
const listItem = screen.getByRole("option", { name: `Open room ${room.name}` });
expect(screen.queryByRole("button", { name: "More Options" })).toBeNull(); expect(screen.queryByRole("button", { name: "More Options" })).toBeNull();
await user.hover(listItem); await user.hover(listItem);
@@ -93,19 +113,33 @@ describe("<RoomListItemView />", () => {
}); });
test("should hover decoration if focused", async () => { test("should hover decoration if focused", async () => {
const user = userEvent.setup(); const { rerender } = renderRoomListItem({
render(<RoomListItemView room={room} isSelected={false} />, withClientContextRenderOptions(matrixClient)); isFocused: true,
const listItem = screen.getByRole("button", { name: `Open room ${room.name}` }); });
await user.click(listItem);
expect(listItem).toHaveClass("mx_RoomListItemView_hover");
await user.tab(); const listItem = screen.getByRole("option", { name: `Open room ${room.name}` });
await waitFor(() => expect(listItem).not.toHaveClass("mx_RoomListItemView_hover")); expect(listItem).toHaveClass("flex mx_RoomListItemView mx_RoomListItemView_hover");
rerender(
<RoomListItemView
room={room}
isSelected={false}
isFocused={false}
onFocus={jest.fn()}
roomIndex={0}
roomCount={1}
/>,
);
await waitFor(() => expect(listItem).not.toHaveClass("flex mx_RoomListItemView mx_RoomListItemView_hover"));
}); });
test("should be selected if isSelected=true", async () => { test("should be selected if isSelected=true", async () => {
const { asFragment } = render(<RoomListItemView room={room} isSelected={true} />); const { asFragment } = renderRoomListItem({
expect(screen.queryByRole("button", { name: `Open room ${room.name}` })).toHaveAttribute( isSelected: true,
});
expect(screen.queryByRole("option", { name: `Open room ${room.name}` })).toHaveAttribute(
"aria-selected", "aria-selected",
"true", "true",
); );
@@ -118,7 +152,8 @@ describe("<RoomListItemView />", () => {
showNotificationDecoration: true, showNotificationDecoration: true,
}); });
const { asFragment } = render(<RoomListItemView room={room} isSelected={false} />); const { asFragment } = renderRoomListItem();
expect(screen.getByTestId("notification-decoration")).toBeInTheDocument(); expect(screen.getByTestId("notification-decoration")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
@@ -131,8 +166,9 @@ describe("<RoomListItemView />", () => {
showNotificationDecoration: true, showNotificationDecoration: true,
}); });
render(<RoomListItemView room={room} isSelected={false} />); renderRoomListItem();
const listItem = screen.getByRole("button", { name: `Open room ${room.name}` });
const listItem = screen.getByRole("option", { name: `Open room ${room.name}` });
await user.hover(listItem); await user.hover(listItem);
expect(screen.queryByRole("notification-decoration")).toBeNull(); expect(screen.queryByRole("notification-decoration")).toBeNull();
@@ -146,8 +182,9 @@ describe("<RoomListItemView />", () => {
showContextMenu: true, showContextMenu: true,
}); });
render(<RoomListItemView room={room} isSelected={false} />, withClientContextRenderOptions(matrixClient)); renderRoomListItem();
const button = screen.getByRole("button", { name: `Open room ${room.name}` });
const button = screen.getByRole("option", { name: `Open room ${room.name}` });
await user.pointer([{ target: button }, { keys: "[MouseRight]", target: button }]); await user.pointer([{ target: button }, { keys: "[MouseRight]", target: button }]);
await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument());
// Menu should close // Menu should close
@@ -23,7 +23,7 @@ jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListViewMode
describe("<RoomListView />", () => { describe("<RoomListView />", () => {
const defaultValue: RoomListViewState = { const defaultValue: RoomListViewState = {
isLoadingRooms: false, isLoadingRooms: false,
rooms: [], roomsResult: { spaceId: "home", rooms: [] },
primaryFilters: [], primaryFilters: [],
createRoom: jest.fn(), createRoom: jest.fn(),
createChatRoom: jest.fn(), createChatRoom: jest.fn(),
@@ -56,10 +56,10 @@ describe("<RoomListView />", () => {
it("should render a room list", () => { it("should render a room list", () => {
mocked(useRoomListViewModel).mockReturnValue({ mocked(useRoomListViewModel).mockReturnValue({
...defaultValue, ...defaultValue,
rooms: [mkRoom(matrixClient, "testing room")], roomsResult: { spaceId: "home", rooms: [mkRoom(matrixClient, "testing room")] },
}); });
render(<RoomListView />); render(<RoomListView />);
expect(screen.getByRole("grid", { name: "Room list" })).toBeInTheDocument(); expect(screen.getByRole("listbox", { name: "Room list" })).toBeInTheDocument();
}); });
}); });
@@ -3,531 +3,556 @@
exports[`<RoomList /> should render a room list 1`] = ` exports[`<RoomList /> should render a room list 1`] = `
<DocumentFragment> <DocumentFragment>
<div <div
class="mx_RoomList" aria-label="Room list"
context="[object Object]"
data-testid="room-list" data-testid="room-list"
data-virtuoso-scroller="true"
role="listbox"
style="height: 100%; outline: none; overflow-y: auto; position: relative;"
> >
<div <div
style="overflow: visible; height: 0px; width: 0px;" data-viewport-type="element"
style="height: 100%; position: absolute; top: 0px; width: 100%;"
> >
<div <div
aria-label="Room list" data-testid="virtuoso-item-list"
aria-readonly="true" style="box-sizing: border-box; margin-top: 0px; padding-bottom: 0px; padding-top: 0px;"
class="ReactVirtualized__Grid ReactVirtualized__List mx_RoomList_List"
role="grid"
style="box-sizing: border-box; direction: ltr; height: 1500px; position: relative; width: 1500px; will-change: transform; overflow-x: hidden; overflow-y: hidden;"
tabindex="-1"
> >
<div <div
class="ReactVirtualized__Grid__innerScrollContainer" data-index="0"
role="row" data-item-index="0"
style="width: auto; height: 480px; max-width: 1500px; max-height: 480px; overflow: hidden; position: relative;" data-known-size="48"
> >
<button <button
aria-haspopup="menu" aria-haspopup="menu"
aria-label="Open room room0" aria-label="Open room room0"
aria-posinset="1"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="10"
class="flex mx_RoomListItemView"
data-state="closed" data-state="closed"
role="gridcell" role="option"
style="height: 48px; left: 0px; position: absolute; top: 0px; width: 100%;" 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="0" tabindex="0"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="2"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="2"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room0"
> >
<div room0
class="mx_RoomListItemView_roomName"
title="room0"
>
room0
</div>
</div> </div>
</div> </div>
</div> </div>
</button> </button>
</div>
<div
data-index="1"
data-item-index="1"
data-known-size="48"
>
<button <button
aria-haspopup="menu" aria-haspopup="menu"
aria-label="Open room room1" aria-label="Open room room1"
aria-posinset="2"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="10"
class="flex mx_RoomListItemView"
data-state="closed" data-state="closed"
role="gridcell" role="option"
style="height: 48px; left: 0px; position: absolute; top: 48px; width: 100%;" 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="3"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room1"
> >
<div room1
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
</div> </div>
</div> </div>
</div> </div>
</button> </button>
</div>
<div
data-index="2"
data-item-index="2"
data-known-size="48"
>
<button <button
aria-haspopup="menu" aria-haspopup="menu"
aria-label="Open room room2" aria-label="Open room room2"
aria-posinset="3"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="10"
class="flex mx_RoomListItemView"
data-state="closed" data-state="closed"
role="gridcell" role="option"
style="height: 48px; left: 0px; position: absolute; top: 96px; width: 100%;" 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="4"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="4"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room2"
> >
<div room2
class="mx_RoomListItemView_roomName"
title="room2"
>
room2
</div>
</div> </div>
</div> </div>
</div> </div>
</button> </button>
</div>
<div
data-index="3"
data-item-index="3"
data-known-size="48"
>
<button <button
aria-haspopup="menu" aria-haspopup="menu"
aria-label="Open room room3" aria-label="Open room room3"
aria-posinset="4"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="10"
class="flex mx_RoomListItemView"
data-state="closed" data-state="closed"
role="gridcell" role="option"
style="height: 48px; left: 0px; position: absolute; top: 144px; width: 100%;" 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="5"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="5"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room3"
> >
<div room3
class="mx_RoomListItemView_roomName"
title="room3"
>
room3
</div>
</div> </div>
</div> </div>
</div> </div>
</button> </button>
</div>
<div
data-index="4"
data-item-index="4"
data-known-size="48"
>
<button <button
aria-haspopup="menu" aria-haspopup="menu"
aria-label="Open room room4" aria-label="Open room room4"
aria-posinset="5"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="10"
class="flex mx_RoomListItemView"
data-state="closed" data-state="closed"
role="gridcell" role="option"
style="height: 48px; left: 0px; position: absolute; top: 192px; width: 100%;" 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="6"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="6"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room4"
> >
<div room4
class="mx_RoomListItemView_roomName"
title="room4"
>
room4
</div>
</div> </div>
</div> </div>
</div> </div>
</button> </button>
</div>
<div
data-index="5"
data-item-index="5"
data-known-size="48"
>
<button <button
aria-haspopup="menu" aria-haspopup="menu"
aria-label="Open room room5" aria-label="Open room room5"
aria-posinset="6"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="10"
class="flex mx_RoomListItemView"
data-state="closed" data-state="closed"
role="gridcell" role="option"
style="height: 48px; left: 0px; position: absolute; top: 240px; width: 100%;" 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="1"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="1"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room5"
> >
<div room5
class="mx_RoomListItemView_roomName"
title="room5"
>
room5
</div>
</div> </div>
</div> </div>
</div> </div>
</button> </button>
</div>
<div
data-index="6"
data-item-index="6"
data-known-size="48"
>
<button <button
aria-haspopup="menu" aria-haspopup="menu"
aria-label="Open room room6" aria-label="Open room room6"
aria-posinset="7"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="10"
class="flex mx_RoomListItemView"
data-state="closed" data-state="closed"
role="gridcell" role="option"
style="height: 48px; left: 0px; position: absolute; top: 288px; width: 100%;" 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="2"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="2"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room6"
> >
<div room6
class="mx_RoomListItemView_roomName"
title="room6"
>
room6
</div>
</div> </div>
</div> </div>
</div> </div>
</button> </button>
</div>
<div
data-index="7"
data-item-index="7"
data-known-size="48"
>
<button <button
aria-haspopup="menu" aria-haspopup="menu"
aria-label="Open room room7" aria-label="Open room room7"
aria-posinset="8"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="10"
class="flex mx_RoomListItemView"
data-state="closed" data-state="closed"
role="gridcell" role="option"
style="height: 48px; left: 0px; position: absolute; top: 336px; width: 100%;" 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="3"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room7"
> >
<div room7
class="mx_RoomListItemView_roomName"
title="room7"
>
room7
</div>
</div> </div>
</div> </div>
</div> </div>
</button> </button>
</div>
<div
data-index="8"
data-item-index="8"
data-known-size="48"
>
<button <button
aria-haspopup="menu" aria-haspopup="menu"
aria-label="Open room room8" aria-label="Open room room8"
aria-posinset="9"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="10"
class="flex mx_RoomListItemView"
data-state="closed" data-state="closed"
role="gridcell" role="option"
style="height: 48px; left: 0px; position: absolute; top: 384px; width: 100%;" 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="4"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="4"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room8"
> >
<div room8
class="mx_RoomListItemView_roomName"
title="room8"
>
room8
</div>
</div> </div>
</div> </div>
</div> </div>
</button> </button>
</div>
<div
data-index="9"
data-item-index="9"
data-known-size="48"
>
<button <button
aria-haspopup="menu" aria-haspopup="menu"
aria-label="Open room room9" aria-label="Open room room9"
aria-posinset="10"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="10"
class="flex mx_RoomListItemView"
data-state="closed" data-state="closed"
role="gridcell" role="option"
style="height: 48px; left: 0px; position: absolute; top: 432px; width: 100%;" 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="5"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="5"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room9"
> >
<div room9
class="mx_RoomListItemView_roomName"
title="room9"
>
room9
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -535,20 +560,6 @@ exports[`<RoomList /> should render a room list 1`] = `
</div> </div>
</div> </div>
</div> </div>
<div
class="resize-triggers"
>
<div
class="expand-trigger"
>
<div
style="width: 1501px; height: 1501px;"
/>
</div>
<div
class="contract-trigger"
/>
</div>
</div> </div>
</DocumentFragment> </DocumentFragment>
`; `;
@@ -38,41 +38,43 @@ exports[`<RoomListItemMenuView /> should render the more options menu 1`] = `
</svg> </svg>
</div> </div>
</button> </button>
<button <div>
aria-disabled="false" <button
aria-expanded="false" aria-disabled="false"
aria-haspopup="menu" aria-expanded="false"
aria-label="Notification options" aria-haspopup="menu"
aria-labelledby="«r9»" aria-label="Notification options"
class="_icon-button_1pz9o_8" aria-labelledby="«r9»"
data-kind="primary" class="_icon-button_1pz9o_8"
data-state="closed" data-kind="primary"
id="radix-«r7»" data-state="closed"
role="button" id="radix-«r7»"
style="--cpd-icon-button-size: 24px;" role="button"
tabindex="0" style="--cpd-icon-button-size: 24px;"
type="button" tabindex="0"
> type="button"
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
> >
<svg <div
fill="currentColor" class="_indicator-icon_zr2a0_17"
height="1em" style="--cpd-icon-button-size: 100%;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
> >
<path <svg
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" fill="currentColor"
/> height="1em"
<path viewBox="0 0 24 24"
d="M10 20h4a2 2 0 0 1-4 0" width="1em"
/> xmlns="http://www.w3.org/2000/svg"
</svg> >
</div> <path
</button> 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>
</div> </div>
</DocumentFragment> </DocumentFragment>
`; `;
@@ -115,41 +117,43 @@ exports[`<RoomListItemMenuView /> should render the notification options menu 1`
</svg> </svg>
</div> </div>
</button> </button>
<button <div>
aria-disabled="false" <button
aria-expanded="false" aria-disabled="false"
aria-haspopup="menu" aria-expanded="false"
aria-label="Notification options" aria-haspopup="menu"
aria-labelledby="«rp»" aria-label="Notification options"
class="_icon-button_1pz9o_8" aria-labelledby="«rp»"
data-kind="primary" class="_icon-button_1pz9o_8"
data-state="closed" data-kind="primary"
id="radix-«rn»" data-state="closed"
role="button" id="radix-«rn»"
style="--cpd-icon-button-size: 24px;" role="button"
tabindex="0" style="--cpd-icon-button-size: 24px;"
type="button" tabindex="0"
> type="button"
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
> >
<svg <div
fill="currentColor" class="_indicator-icon_zr2a0_17"
height="1em" style="--cpd-icon-button-size: 100%;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
> >
<path <svg
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" fill="currentColor"
/> height="1em"
<path viewBox="0 0 24 24"
d="M10 20h4a2 2 0 0 1-4 0" width="1em"
/> xmlns="http://www.w3.org/2000/svg"
</svg> >
</div> <path
</button> 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>
</div> </div>
</DocumentFragment> </DocumentFragment>
`; `;
@@ -4,47 +4,46 @@ exports[`<RoomListItemView /> should be selected if isSelected=true 1`] = `
<DocumentFragment> <DocumentFragment>
<button <button
aria-label="Open room room1" aria-label="Open room room1"
aria-posinset="1"
aria-selected="true" aria-selected="true"
class="mx_RoomListItemView mx_RoomListItemView_selected" aria-setsize="1"
class="flex 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="3"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room1"
> >
<div room1
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -56,60 +55,59 @@ exports[`<RoomListItemView /> should display notification decoration 1`] = `
<DocumentFragment> <DocumentFragment>
<button <button
aria-label="Open room room1" aria-label="Open room room1"
aria-posinset="1"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="1"
class="flex 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="3"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room1"
> >
<div room1
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
</div> </div>
<div </div>
aria-hidden="true" <div
class="flex" aria-hidden="true"
data-testid="notification-decoration" class="flex"
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;" 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;"
>
<span
class="_unread-counter_9mg0k_8"
> >
<span 1
class="_unread-counter_9mg0k_8" </span>
>
1
</span>
</div>
</div> </div>
</div> </div>
</button> </button>
@@ -120,47 +118,46 @@ exports[`<RoomListItemView /> should render a room item 1`] = `
<DocumentFragment> <DocumentFragment>
<button <button
aria-label="Open room room1" aria-label="Open room room1"
aria-posinset="1"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="0"
class="flex 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="3"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room1"
> >
<div room1
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -172,53 +169,52 @@ exports[`<RoomListItemView /> should render a room item with a message preview 1
<DocumentFragment> <DocumentFragment>
<button <button
aria-label="Open room room1" aria-label="Open room room1"
aria-posinset="1"
aria-selected="false" aria-selected="false"
class="mx_RoomListItemView" aria-setsize="1"
class="flex 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" tabindex="-1"
type="button" type="button"
> >
<div <span
class="flex mx_RoomListItemView_container" aria-label="Avatar"
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;" class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
> >
<span <img
aria-label="Avatar" alt=""
class="_avatar_1qbcf_8 mx_BaseAvatar" class="_image_1qbcf_41"
data-color="3"
data-testid="avatar-img"
data-type="round" data-type="round"
style="--cpd-avatar-size: 32px;" height="32px"
> loading="lazy"
<img referrerpolicy="no-referrer"
alt="" src="http://this.is.a.url/avatar.url/room.png"
class="_image_1qbcf_41" width="32px"
data-type="round" />
height="32px" </span>
loading="lazy" <div
referrerpolicy="no-referrer" class="flex mx_RoomListItemView_content"
src="http://this.is.a.url/avatar.url/room.png" 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;"
width="32px" >
/>
</span>
<div <div
class="flex mx_RoomListItemView_content" class="mx_RoomListItemView_text"
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 <div
class="mx_RoomListItemView_text" class="mx_RoomListItemView_roomName"
title="room1"
> >
<div room1
class="mx_RoomListItemView_roomName" </div>
title="room1" <div
> class="mx_RoomListItemView_messagePreview"
room1 title="The message looks like this"
</div> >
<div The message looks like this
class="mx_RoomListItemView_messagePreview"
title="The message looks like this"
>
The message looks like this
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -11,7 +11,6 @@ import { act, fireEvent, screen, waitFor } from "jest-matrix-react";
import { RoomMember, User, RoomEvent } from "matrix-js-sdk/src/matrix"; import { RoomMember, User, RoomEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types"; import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { type JSX } from "react";
import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents"; import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents";
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher"; import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
@@ -21,14 +20,6 @@ jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(), shouldShowComponent: jest.fn(),
})); }));
type Children = (args: { height: number; width: number }) => JSX.Element;
jest.mock("react-virtualized", () => {
const ReactVirtualized = jest.requireActual("react-virtualized");
return {
...ReactVirtualized,
AutoSizer: ({ children }: { children: Children }) => children({ height: 1000, width: 1000 }),
};
});
jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(1500); jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(1500);
jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(1500); jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(1500);
@@ -10,7 +10,6 @@ Please see LICENSE files in the repository root for full details.
import { act } from "react"; import { act } from "react";
import { waitFor, fireEvent } from "jest-matrix-react"; import { waitFor, fireEvent } from "jest-matrix-react";
import { type Room, type RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { type Room, type RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { type JSX } from "react";
import { filterConsole } from "../../../../../test-utils"; import { filterConsole } from "../../../../../test-utils";
import { type Rendered, renderMemberList } from "./common"; import { type Rendered, renderMemberList } from "./common";
@@ -19,14 +18,6 @@ jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(), shouldShowComponent: jest.fn(),
})); }));
type Children = (args: { height: number; width: number }) => JSX.Element;
jest.mock("react-virtualized", () => {
const ReactVirtualized = jest.requireActual("react-virtualized");
return {
...ReactVirtualized,
AutoSizer: ({ children }: { children: Children }) => children({ height: 1000, width: 1000 }),
};
});
jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(1500); jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(1500);
jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(1500); jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(1500);
@@ -125,7 +125,7 @@ export async function renderMemberList(
{ {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 56 }}> <VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 56 }}>
{children} <>{children}</>
</VirtuosoMockContext.Provider> </VirtuosoMockContext.Provider>
), ),
}, },
@@ -48,7 +48,7 @@ describe("ListView", () => {
return render(getListViewComponent(mergedProps), { return render(getListViewComponent(mergedProps), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 400, itemHeight: 56 }}> <VirtuosoMockContext.Provider value={{ viewportHeight: 400, itemHeight: 56 }}>
{children} <>{children}</>
</VirtuosoMockContext.Provider> </VirtuosoMockContext.Provider>
), ),
}); });
@@ -460,7 +460,7 @@ describe("RoomListStoreV3", () => {
store.on(LISTS_UPDATE_EVENT, fn); store.on(LISTS_UPDATE_EVENT, fn);
// The rooms which belong to the space should not be shown // The rooms which belong to the space should not be shown
const result = store.getSortedRoomsInActiveSpace().map((r) => r.roomId); const result = store.getSortedRoomsInActiveSpace().rooms.map((r) => r.roomId);
for (const id of roomIds) { for (const id of roomIds) {
expect(result).not.toContain(id); expect(result).not.toContain(id);
} }
@@ -469,7 +469,7 @@ describe("RoomListStoreV3", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoom.roomId); jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoom.roomId);
SpaceStore.instance.emit(UPDATE_SELECTED_SPACE); SpaceStore.instance.emit(UPDATE_SELECTED_SPACE);
expect(fn).toHaveBeenCalled(); expect(fn).toHaveBeenCalled();
const result2 = store.getSortedRoomsInActiveSpace().map((r) => r.roomId); const result2 = store.getSortedRoomsInActiveSpace().rooms.map((r) => r.roomId);
for (const id of roomIds) { for (const id of roomIds) {
expect(result2).toContain(id); expect(result2).toContain(id);
} }
@@ -492,7 +492,7 @@ describe("RoomListStoreV3", () => {
await store.start(); await store.start();
// Sorted, filtered rooms should be 8, 27 and 75 // Sorted, filtered rooms should be 8, 27 and 75
const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]); const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]).rooms;
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
for (const i of [8, 27, 75]) { for (const i of [8, 27, 75]) {
expect(result).toContain(rooms[i]); expect(result).toContain(rooms[i]);
@@ -527,7 +527,7 @@ describe("RoomListStoreV3", () => {
expect(fn).toHaveBeenCalled(); expect(fn).toHaveBeenCalled();
// Sorted, filtered rooms should be 27 and 75 // Sorted, filtered rooms should be 27 and 75
const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]); const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]).rooms;
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
for (const i of [8, 75]) { for (const i of [8, 75]) {
expect(result).toContain(rooms[i]); expect(result).toContain(rooms[i]);
@@ -552,7 +552,7 @@ describe("RoomListStoreV3", () => {
await store.start(); await store.start();
// Should only give us rooms at index 8 and 27 // Should only give us rooms at index 8 and 27
const result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]); const result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms;
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
for (const i of [8, 27]) { for (const i of [8, 27]) {
expect(result).toContain(rooms[i]); expect(result).toContain(rooms[i]);
@@ -569,7 +569,7 @@ describe("RoomListStoreV3", () => {
await store.start(); await store.start();
// Since there's no unread yet, we expect zero results // Since there's no unread yet, we expect zero results
let result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]); let result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms;
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
// Mock so that room at index 8 is marked as unread // Mock so that room at index 8 is marked as unread
@@ -584,7 +584,7 @@ describe("RoomListStoreV3", () => {
); );
// Now we expect room at index 8 to show as unread // Now we expect room at index 8 to show as unread
result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]); result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms;
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result).toContain(rooms[8]); expect(result).toContain(rooms[8]);
}); });
@@ -607,14 +607,14 @@ describe("RoomListStoreV3", () => {
await store.start(); await store.start();
// Should only give us rooms at index 8 and 27 // Should only give us rooms at index 8 and 27
const peopleRooms = store.getSortedRoomsInActiveSpace([FilterKey.PeopleFilter]); const peopleRooms = store.getSortedRoomsInActiveSpace([FilterKey.PeopleFilter]).rooms;
expect(peopleRooms).toHaveLength(2); expect(peopleRooms).toHaveLength(2);
for (const i of [8, 27]) { for (const i of [8, 27]) {
expect(peopleRooms).toContain(rooms[i]); expect(peopleRooms).toContain(rooms[i]);
} }
// Rest are normal rooms // Rest are normal rooms
const nonDms = store.getSortedRoomsInActiveSpace([FilterKey.RoomsFilter]); const nonDms = store.getSortedRoomsInActiveSpace([FilterKey.RoomsFilter]).rooms;
expect(nonDms).toHaveLength(3); expect(nonDms).toHaveLength(3);
for (const i of [6, 13, 75]) { for (const i of [6, 13, 75]) {
expect(nonDms).toContain(rooms[i]); expect(nonDms).toContain(rooms[i]);
@@ -638,7 +638,7 @@ describe("RoomListStoreV3", () => {
const store = new RoomListStoreV3Class(dispatcher); const store = new RoomListStoreV3Class(dispatcher);
await store.start(); await store.start();
const result = store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]); const result = store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]).rooms;
expect(result).toHaveLength(5); expect(result).toHaveLength(5);
for (const room of invitedRooms) { for (const room of invitedRooms) {
expect(result).toContain(room); expect(result).toContain(room);
@@ -663,7 +663,7 @@ describe("RoomListStoreV3", () => {
await store.start(); await store.start();
// Should only give us rooms at index 8 and 27 // Should only give us rooms at index 8 and 27
const result = store.getSortedRoomsInActiveSpace([FilterKey.MentionsFilter]); const result = store.getSortedRoomsInActiveSpace([FilterKey.MentionsFilter]).rooms;
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
for (const i of [8, 27]) { for (const i of [8, 27]) {
expect(result).toContain(rooms[i]); expect(result).toContain(rooms[i]);
@@ -685,7 +685,7 @@ describe("RoomListStoreV3", () => {
await store.start(); await store.start();
// Sorted, filtered rooms should be 8, 27 and 75 // Sorted, filtered rooms should be 8, 27 and 75
const result = store.getSortedRoomsInActiveSpace([FilterKey.LowPriorityFilter]); const result = store.getSortedRoomsInActiveSpace([FilterKey.LowPriorityFilter]).rooms;
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
for (const i of [8, 27, 75]) { for (const i of [8, 27, 75]) {
expect(result).toContain(rooms[i]); expect(result).toContain(rooms[i]);
@@ -713,7 +713,10 @@ describe("RoomListStoreV3", () => {
await store.start(); await store.start();
// Should give us only room at 8 since that's the only room which matches both filters // Should give us only room at 8 since that's the only room which matches both filters
const result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter, FilterKey.FavouriteFilter]); const result = store.getSortedRoomsInActiveSpace([
FilterKey.UnreadFilter,
FilterKey.FavouriteFilter,
]).rooms;
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result).toContain(rooms[8]); expect(result).toContain(rooms[8]);
}); });
+5 -40
View File
@@ -1208,7 +1208,7 @@
"@babel/plugin-transform-modules-commonjs" "^7.27.1" "@babel/plugin-transform-modules-commonjs" "^7.27.1"
"@babel/plugin-transform-typescript" "^7.27.1" "@babel/plugin-transform-typescript" "^7.27.1"
"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": "@babel/runtime@^7.0.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
version "7.28.3" version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.3.tgz#75c5034b55ba868121668be5d5bb31cc64e6e61a" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.3.tgz#75c5034b55ba868121668be5d5bb31cc64e6e61a"
integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA== integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==
@@ -4261,11 +4261,6 @@
resolved "https://registry.yarnpkg.com/@types/png-chunks-extract/-/png-chunks-extract-1.0.2.tgz#31dd8d74d6ba879ace317c1e042dcdabc6300d6e" resolved "https://registry.yarnpkg.com/@types/png-chunks-extract/-/png-chunks-extract-1.0.2.tgz#31dd8d74d6ba879ace317c1e042dcdabc6300d6e"
integrity sha512-z6djfFIbrrddtunoMJBOPlyZrnmeuG1kkvHUNi2QfpOb+JMMLuLliHHTmMyRi7k7LiTAut0HbdGCF6ibDtQAHQ== integrity sha512-z6djfFIbrrddtunoMJBOPlyZrnmeuG1kkvHUNi2QfpOb+JMMLuLliHHTmMyRi7k7LiTAut0HbdGCF6ibDtQAHQ==
"@types/prop-types@*":
version "15.7.15"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7"
integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==
"@types/qrcode@^1.3.5": "@types/qrcode@^1.3.5":
version "1.5.5" version "1.5.5"
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac" resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac"
@@ -4310,14 +4305,6 @@
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044"
integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==
"@types/react-virtualized@^9.21.30":
version "9.22.2"
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.22.2.tgz#97674f050a85d0f7aab827b3d894f3f1b237922a"
integrity sha512-0Eg/ME3OHYWGxs+/n4VelfYrhXssireZaa1Uqj5SEkTpSaBu5ctFGOCVxcOqpGXRiEdrk/7uho9tlZaryCIjHA==
dependencies:
"@types/prop-types" "*"
"@types/react" "*"
"@types/react@*", "@types/react@19.1.10": "@types/react@*", "@types/react@19.1.10":
version "19.1.10" version "19.1.10"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.10.tgz#a05015952ef328e1b85579c839a71304b07d21d9" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.10.tgz#a05015952ef328e1b85579c839a71304b07d21d9"
@@ -4712,7 +4699,7 @@
classnames "^2.5.1" classnames "^2.5.1"
vaul "^1.0.0" vaul "^1.0.0"
"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm": "@vector-im/matrix-wysiwyg-wasm@link:../../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm":
version "0.0.0" version "0.0.0"
uid "" uid ""
@@ -4721,7 +4708,7 @@
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.39.0.tgz#a6238e517f23a2f3025d9c65445914771c63b163" resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.39.0.tgz#a6238e517f23a2f3025d9c65445914771c63b163"
integrity sha512-OROXnzPcQWrCMoUpIrCKEC4FYU+9SsRomUgu+VbJwWtBDkCbfvLD4z6w/mgiADw3iTUpBPgmcWJoGxesFuB20Q== integrity sha512-OROXnzPcQWrCMoUpIrCKEC4FYU+9SsRomUgu+VbJwWtBDkCbfvLD4z6w/mgiADw3iTUpBPgmcWJoGxesFuB20Q==
dependencies: dependencies:
"@vector-im/matrix-wysiwyg-wasm" "link:../../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm" "@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm"
"@vitest/expect@3.2.4": "@vitest/expect@3.2.4":
version "3.2.4" version "3.2.4"
@@ -6206,11 +6193,6 @@ clone-deep@^4.0.1:
kind-of "^6.0.2" kind-of "^6.0.2"
shallow-clone "^3.0.0" shallow-clone "^3.0.0"
clsx@^1.0.4:
version "1.2.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
co@^4.6.0: co@^4.6.0:
version "4.6.0" version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -7175,7 +7157,7 @@ dom-converter@^0.2.0:
dependencies: dependencies:
utila "~0.4" utila "~0.4"
dom-helpers@^5.0.1, dom-helpers@^5.1.3: dom-helpers@^5.0.1:
version "5.2.1" version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==
@@ -13188,11 +13170,6 @@ react-is@^17.0.2:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-lifecycles-compat@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-property@2.0.2: react-property@2.0.2:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.2.tgz#d5ac9e244cef564880a610bc8d868bd6f60fdda6" resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.2.tgz#d5ac9e244cef564880a610bc8d868bd6f60fdda6"
@@ -13253,19 +13230,7 @@ react-transition-group@^4.4.1:
loose-envify "^1.4.0" loose-envify "^1.4.0"
prop-types "^15.6.2" prop-types "^15.6.2"
react-virtualized@^9.22.5: react-virtuoso@^4.14.0:
version "9.22.6"
resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.22.6.tgz#3ae2aa69eca61cf3af332e2f9d6b4aa5638786d5"
integrity sha512-U5j7KuUQt3AaMatlMJ0UJddqSiX+Km0YJxSqbAzIiGw5EmNz0khMyqP2hzgu4+QUtm+QPIrxzUX4raJxmVJnHg==
dependencies:
"@babel/runtime" "^7.7.2"
clsx "^1.0.4"
dom-helpers "^5.1.3"
loose-envify "^1.4.0"
prop-types "^15.7.2"
react-lifecycles-compat "^3.0.4"
react-virtuoso@^4.12.6:
version "4.14.0" version "4.14.0"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.14.0.tgz#6998631cb0a86efc2b15e551f55e7199a0f25c7a" resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.14.0.tgz#6998631cb0a86efc2b15e551f55e7199a0f25c7a"
integrity sha512-fR+eiCvirSNIRvvCD7ueJPRsacGQvUbjkwgWzBZXVq+yWypoH7mRUvWJzGHIdoRaCZCT+6mMMMwIG2S1BW3uwA== integrity sha512-fR+eiCvirSNIRvvCD7ueJPRsacGQvUbjkwgWzBZXVq+yWypoH7mRUvWJzGHIdoRaCZCT+6mMMMwIG2S1BW3uwA==