Compare commits

..

5 Commits

Author SHA1 Message Date
Michael Telatynski
7e670bfe41 Type fixes
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-08-21 16:09:17 +01:00
Michael Telatynski
cb7382f235 Merge branch 'renovate/typescript' of https://github.com/element-hq/element-web into renovate/typescript 2025-08-21 16:04:16 +01:00
Michael Telatynski
f796dce34b Merge branch 'develop' into renovate/typescript 2025-08-21 15:44:28 +01:00
renovate[bot]
523783706f Update dependency typescript to v5.9.2 2025-08-19 12:57:16 +00:00
renovate[bot]
0f530f6c01 Update dependency typescript to v5.9.2 2025-08-10 12:38:05 +00:00
92 changed files with 1267 additions and 1677 deletions

View File

@@ -27,7 +27,7 @@ Element has several tiers of support for different environments:
- Best effort
- Definition:
- Issues **accepted**, regressions **do not block** the release
- The wider Element Products (including Element Call and the Enterprise Server Suite) do still not officially support these browsers.
- The wider Element Products(including Element Call and the Enterprise Server Suite) do still not officially support these browsers.
- The element web project and its contributors should keep the client functioning and gracefully degrade where other sibling features (E.g. Element Call) may not function.
- Last major release of Firefox ESR and Chrome/Edge Extended Stable
- Community Supported

View File

@@ -96,6 +96,7 @@
"@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^10.0.0",
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^6.0.0",
"@vector-im/compound-web": "^8.1.2",
"@vector-im/matrix-wysiwyg": "2.39.0",
@@ -152,7 +153,8 @@
"react-focus-lock": "^2.5.1",
"react-string-replace": "^1.1.1",
"react-transition-group": "^4.4.1",
"react-virtuoso": "^4.14.0",
"react-virtualized": "^9.22.5",
"react-virtuoso": "^4.12.6",
"rfc4648": "^1.4.0",
"sanitize-filename": "^1.6.3",
"sanitize-html": "2.17.0",
@@ -305,7 +307,7 @@
"terser-webpack-plugin": "^5.3.9",
"testcontainers": "^11.0.0",
"ts-node": "^10.9.1",
"typescript": "5.8.3",
"typescript": "5.9.2",
"util": "^0.12.5",
"vite": "^7.0.1",
"vite-plugin-node-polyfills": "^0.24.0",

View File

@@ -11,3 +11,50 @@ index 917a7fc..a2710c6 100644
didOkOrSubmit: boolean;
model: M;
}>;
diff --git a/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.d.ts b/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.d.ts
index cb5f2e5..51daa51 100644
--- a/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.d.ts
+++ b/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.d.ts
@@ -66,23 +66,23 @@ export interface SetupEncryptionStoreProjection {
export interface ProvideCryptoSetupExtensions {
examineLoginResponse(response: any, credentials: ExtendedMatrixClientCreds): void;
persistCredentials(credentials: ExtendedMatrixClientCreds): void;
- getSecretStorageKey(): Uint8Array | null;
- createSecretStorageKey(): Uint8Array | null;
+ getSecretStorageKey(): Uint8Array<ArrayBuffer> | null;
+ createSecretStorageKey(): Uint8Array<ArrayBuffer> | null;
catchAccessSecretStorageError(e: Error): void;
setupEncryptionNeeded: (args: CryptoSetupArgs) => boolean;
/** @deprecated This callback is no longer used by matrix-react-sdk */
- getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise<Uint8Array>) | null;
+ getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array<ArrayBuffer>) => void) => Promise<Uint8Array<ArrayBuffer>>) | null;
SHOW_ENCRYPTION_SETUP_UI: boolean;
}
export declare abstract class CryptoSetupExtensionsBase implements ProvideCryptoSetupExtensions {
abstract examineLoginResponse(response: any, credentials: ExtendedMatrixClientCreds): void;
abstract persistCredentials(credentials: ExtendedMatrixClientCreds): void;
- abstract getSecretStorageKey(): Uint8Array | null;
- abstract createSecretStorageKey(): Uint8Array | null;
+ abstract getSecretStorageKey(): Uint8Array<ArrayBuffer> | null;
+ abstract createSecretStorageKey(): Uint8Array<ArrayBuffer> | null;
abstract catchAccessSecretStorageError(e: Error): void;
abstract setupEncryptionNeeded(args: CryptoSetupArgs): boolean;
/** `getDehydrationKeyCallback` is no longer used; we provide an empty impl for type compatibility. */
- getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise<Uint8Array>) | null;
+ getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array<ArrayBuffer>) => void) => Promise<Uint8Array<ArrayBuffer>>) | null;
abstract SHOW_ENCRYPTION_SETUP_UI: boolean;
}
export interface CryptoSetupArgs {
@@ -98,9 +98,9 @@ export declare class DefaultCryptoSetupExtensions extends CryptoSetupExtensionsB
SHOW_ENCRYPTION_SETUP_UI: boolean;
examineLoginResponse(response: any, credentials: ExtendedMatrixClientCreds): void;
persistCredentials(credentials: ExtendedMatrixClientCreds): void;
- getSecretStorageKey(): Uint8Array | null;
- createSecretStorageKey(): Uint8Array | null;
+ getSecretStorageKey(): Uint8Array<ArrayBuffer> | null;
+ createSecretStorageKey(): Uint8Array<ArrayBuffer> | null;
catchAccessSecretStorageError(e: Error): void;
setupEncryptionNeeded(args: CryptoSetupArgs): boolean;
- getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise<Uint8Array>) | null;
+ getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array<ArrayBuffer>) => void) => Promise<Uint8Array<ArrayBuffer>>) | null;
}

View File

@@ -68,7 +68,7 @@ test.describe("Room list filters and sort", () => {
So we expect 'Old Room' to show up in the room list.
*/
const roomListView = getRoomList(page);
const oldRoomTile = roomListView.getByRole("option", { name: "Open room Old Room" });
const oldRoomTile = roomListView.getByRole("gridcell", { name: "Open room Old Room" });
await expect(oldRoomTile).toBeVisible();
/*
@@ -139,9 +139,8 @@ test.describe("Room list filters and sort", () => {
// Open the non-favourite room
const roomListView = getRoomList(page);
const tile = roomListView.getByRole("option", { name: "Open room room-non-fav" });
// item may not be in the DOM using scrollListToBottom rather than scrollIntoViewIfNeeded
await app.scrollListToBottom(roomListView);
const tile = roomListView.getByRole("gridcell", { name: "Open room room-non-fav" });
await tile.scrollIntoViewIfNeeded();
await tile.click();
// Enable Favourite filter
@@ -152,7 +151,7 @@ test.describe("Room list filters and sort", () => {
// Ensure the room list is not scrolled
const isScrolledDown = await page
.getByRole("listbox", { name: "Room list", exact: true })
.getByRole("grid", { name: "Room list" })
.evaluate((e) => e.scrollTop !== 0);
expect(isScrolledDown).toStrictEqual(false);
});
@@ -228,37 +227,37 @@ test.describe("Room list filters and sort", () => {
await primaryFilters.getByRole("option", { name: "Unread" }).click();
// only one room should be visible
await expect(roomList.getByRole("option", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("option", { name: "unread room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=option").count()).toBe(4);
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(4);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
await primaryFilters.getByRole("option", { name: "People" }).click();
await expect(roomList.getByRole("option", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("option", { name: "invited room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=option").count()).toBe(2);
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(2);
await primaryFilters.getByRole("option", { name: "Rooms" }).click();
await expect(roomList.getByRole("option", { name: "unread room" })).toBeVisible();
await expect(roomList.getByRole("option", { name: "favourite room" })).toBeVisible();
await expect(roomList.getByRole("option", { name: "empty room" })).toBeVisible();
await expect(roomList.getByRole("option", { name: "room with mention" })).toBeVisible();
await expect(roomList.getByRole("option", { name: "Low prio room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=option").count()).toBe(5);
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(5);
await getFilterExpandButton(page).click();
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("option", { name: "favourite room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=option").count()).toBe(1);
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Mentions" }).click();
await expect(roomList.getByRole("option", { name: "room with mention" })).toBeVisible();
await expect.poll(() => roomList.locator("role=option").count()).toBe(1);
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Invites" }).click();
await expect(roomList.getByRole("option", { name: "invited room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=option").count()).toBe(1);
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
await getFilterCollapseButton(page).click();
await expect(primaryFilters.locator("role=option").first()).toHaveText("Invites");
@@ -269,7 +268,6 @@ test.describe("Room list filters and sort", () => {
{ tag: "@screenshot" },
async ({ page, app, bot }) => {
const roomListView = getRoomList(page);
const primaryFilters = getPrimaryFilters(page);
// Let's configure unread dm room so that we only get notification for mentions and keywords
await app.viewRoomById(unReadDmId);
@@ -278,20 +276,20 @@ test.describe("Room list filters and sort", () => {
await app.settings.closeDialog();
// Let's open a room other than unread room or unread dm
await roomListView.getByRole("option", { name: "Open room favourite room" }).click();
await roomListView.getByRole("gridcell", { name: "Open room favourite room" }).click();
// Let's make the bot send a new message in both rooms
await bot.sendMessage(unReadDmId, "Hello!");
await bot.sendMessage(unReadRoomId, "Hello!");
// Let's activate the unread filter now
await primaryFilters.getByRole("option", { name: "Unread" }).click();
await page.getByRole("option", { name: "Unread" }).click();
// Unread filter should only show unread room and not unread dm!
const unreadDm = roomListView.getByRole("option", { name: "Open room unread room" });
const unreadDm = roomListView.getByRole("gridcell", { name: "Open room unread room" });
await expect(unreadDm).toBeVisible();
await expect(unreadDm).toMatchScreenshot("unread-dm.png");
await expect(roomListView.getByRole("option", { name: "Open room unread dm" })).not.toBeVisible();
await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible();
},
);
@@ -301,7 +299,7 @@ test.describe("Room list filters and sort", () => {
await getRoomOptionsMenu(page).click();
await page.getByRole("menuitemradio", { name: "A-Z" }).click();
await expect(roomListView.getByRole("option").first()).toHaveText(/empty room/);
await expect(roomListView.getByRole("gridcell").first()).toHaveText(/empty room/);
});
test("should move room to the top on message when sorting by activity", async ({ page, bot }) => {
@@ -309,7 +307,7 @@ test.describe("Room list filters and sort", () => {
await bot.sendMessage(unReadDmId, "Hello!");
await expect(roomListView.getByRole("option").first()).toHaveText(/unread dm/);
await expect(roomListView.getByRole("gridcell").first()).toHaveText(/unread dm/);
});
});

View File

@@ -38,7 +38,7 @@ test.describe("Room list panel", () => {
test("should render the room list panel", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomListView(page);
// Wait for the last room to be visible
await expect(roomListView.getByRole("option", { name: "Open room room19" })).toBeVisible();
await expect(roomListView.getByRole("gridcell", { name: "Open room room19" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list-panel.png");
});

View File

@@ -43,35 +43,31 @@ test.describe("Room list", () => {
test("should render the room list", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await expect(roomListView.getByRole("option", { name: "Open room room29" })).toBeVisible();
await expect(roomListView.getByRole("gridcell", { name: "Open room room29" })).toBeVisible();
await expect(roomListView).toMatchScreenshot("room-list.png");
// Put focus on the room list
await roomListView.getByRole("option", { name: "Open room room29" }).click();
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Scroll to the end of the room 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 app.scrollListToBottom(page.locator(".mx_RoomList_List"));
await expect(roomListView).toMatchScreenshot("room-list-scrolled.png");
});
test("should open the room when it is clicked", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("option", { name: "Open room room29" }).click();
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
});
test("should open the context menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("option", { name: "Open room room29" }).click({ button: "right" });
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click({ button: "right" });
await expect(page.getByRole("menu", { name: "More Options" })).toBeVisible();
});
test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const roomItem = roomListView.getByRole("option", { name: "Open room room29" });
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
await roomItem.hover();
await expect(roomItem).toMatchScreenshot("room-list-item-hover.png");
@@ -101,7 +97,7 @@ test.describe("Room list", () => {
test("should open the notification options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const roomItem = roomListView.getByRole("option", { name: "Open room room29" });
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
await roomItem.hover();
await expect(roomItem).toMatchScreenshot("room-list-item-hover.png");
@@ -121,10 +117,10 @@ test.describe("Room list", () => {
await expect(roomItem.getByTestId("notification-decoration")).not.toBeVisible();
// Put focus on the room list
await roomListView.getByRole("option", { name: "Open room room28" }).click();
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
// Scroll to the end of the room list
await app.scrollListToBottom(roomListView);
await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
// The room decoration should have the muted icon
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
@@ -143,25 +139,25 @@ test.describe("Room list", () => {
test("should scroll to the current room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
// Put focus on the room list
await roomListView.getByRole("option", { name: "Open room room29" }).click();
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Scroll to the end of the room list
await app.scrollListToBottom(roomListView);
await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
await expect(roomListView.getByRole("option", { name: "Open room room0" })).toBeVisible();
await roomListView.getByRole("option", { name: "Open room room0" }).click();
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
await roomListView.getByRole("gridcell", { name: "Open room room0" }).click();
const filters = page.getByRole("listbox", { name: "Room list filters" });
await filters.getByRole("option", { name: "People" }).click();
await expect(roomListView.getByRole("option", { name: "Open room room0" })).not.toBeVisible();
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).not.toBeVisible();
await filters.getByRole("option", { name: "People" }).click();
await expect(roomListView.getByRole("option", { name: "Open room room0" })).toBeVisible();
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
});
test.describe("Shortcuts", () => {
test("should select the next room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("option", { name: "Open room room29" }).click();
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
await page.keyboard.press("Alt+ArrowDown");
await expect(page.getByRole("heading", { name: "room28", level: 1 })).toBeVisible();
@@ -169,7 +165,7 @@ test.describe("Room list", () => {
test("should select the previous room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("option", { name: "Open room room28" }).click();
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
await page.keyboard.press("Alt+ArrowUp");
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
@@ -177,7 +173,7 @@ test.describe("Room list", () => {
test("should select the last room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.getByRole("option", { name: "Open room room29" }).click();
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
await page.keyboard.press("Alt+ArrowUp");
await expect(page.getByRole("heading", { name: "room0", level: 1 })).toBeVisible();
@@ -191,7 +187,7 @@ test.describe("Room list", () => {
await bot.joinRoom(roomId);
await bot.sendMessage(roomId, "I am a robot. Beep.");
await roomListView.getByRole("option", { name: "Open room room20" }).click();
await roomListView.getByRole("gridcell", { name: "Open room room20" }).click();
await page.keyboard.press("Alt+Shift+ArrowDown");
@@ -203,8 +199,8 @@ test.describe("Room list", () => {
test("should navigate to the room list", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const room29 = roomListView.getByRole("option", { name: "Open room room29" });
const room28 = roomListView.getByRole("option", { name: "Open room room28" });
const room29 = roomListView.getByRole("gridcell", { name: "Open room room29" });
const room28 = roomListView.getByRole("gridcell", { name: "Open room room28" });
// open the room
await room29.click();
@@ -223,7 +219,7 @@ test.describe("Room list", () => {
test("should navigate to the notification menu", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const room29 = roomListView.getByRole("option", { name: "Open room room29" });
const room29 = roomListView.getByRole("gridcell", { name: "Open room room29" });
const moreButton = room29.getByRole("button", { name: "More options" });
const notificationButton = room29.getByRole("button", { name: "Notification options" });
@@ -262,7 +258,7 @@ test.describe("Room list", () => {
await page.getByRole("button", { name: "User menu" }).focus();
const roomListView = getRoomList(page);
const publicRoom = roomListView.getByRole("option", { name: "public room" });
const publicRoom = roomListView.getByRole("gridcell", { name: "public room" });
await expect(publicRoom).toBeVisible();
await expect(publicRoom).toMatchScreenshot("room-list-item-public.png");
@@ -272,7 +268,7 @@ test.describe("Room list", () => {
// @ts-ignore Visibility enum is not accessible
await app.client.createRoom({ name: "low priority room", visibility: "public" });
const roomListView = getRoomList(page);
const publicRoom = roomListView.getByRole("option", { name: "low priority room" });
const publicRoom = roomListView.getByRole("gridcell", { name: "low priority room" });
// Make room low priority
await publicRoom.hover();
@@ -297,7 +293,7 @@ test.describe("Room list", () => {
await page.getByRole("button", { name: "Create video room" }).click();
const roomListView = getRoomList(page);
const videoRoom = roomListView.getByRole("option", { name: "video room" });
const videoRoom = roomListView.getByRole("gridcell", { name: "video room" });
// focus the user menu to avoid to have hover decoration
await page.getByRole("button", { name: "User menu" }).focus();
@@ -316,7 +312,7 @@ test.describe("Room list", () => {
invite: [user.userId],
is_direct: true,
});
const invitedRoom = roomListView.getByRole("option", { name: "invited room" });
const invitedRoom = roomListView.getByRole("gridcell", { name: "invited room" });
await expect(invitedRoom).toBeVisible();
await expect(invitedRoom).toMatchScreenshot("room-list-item-invited.png");
});
@@ -331,7 +327,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("option", { name: "2 notifications" });
const room = roomListView.getByRole("gridcell", { name: "2 notifications" });
await expect(room).toBeVisible();
await expect(room.getByTestId("notification-decoration")).toHaveText("2");
await expect(room).toMatchScreenshot("room-list-item-notification.png");
@@ -362,7 +358,7 @@ test.describe("Room list", () => {
);
await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("option", { name: "mention" });
const room = roomListView.getByRole("gridcell", { name: "mention" });
await expect(room).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-mention.png");
});
@@ -383,7 +379,7 @@ test.describe("Room list", () => {
await bot.joinRoom(roomId);
await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("option", { name: "activity" });
const room = roomListView.getByRole("gridcell", { name: "activity" });
await expect(room.getByText("I am a robot. Beep.")).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-message-preview.png");
});
@@ -410,7 +406,7 @@ test.describe("Room list", () => {
await app.viewRoomById(otherRoomId);
await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("option", { name: "activity" });
const room = roomListView.getByRole("gridcell", { name: "activity" });
await expect(room.getByTestId("notification-decoration")).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-activity.png");
});
@@ -422,7 +418,7 @@ test.describe("Room list", () => {
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
const room = roomListView.getByRole("option", { name: "mark as unread" });
const room = roomListView.getByRole("gridcell", { name: "mark as unread" });
await room.hover();
await room.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "mark as unread" }).click();
@@ -445,7 +441,7 @@ test.describe("Room list", () => {
await page.getByText("Off").click();
await app.settings.closeDialog();
const room = roomListView.getByRole("option", { name: "silent" });
const room = roomListView.getByRole("gridcell", { name: "silent" });
await expect(room.getByTestId("notification-decoration")).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-silent.png");
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 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.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.6 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.0 KiB

After

Width:  |  Height:  |  Size: 3.3 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.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.7 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.5 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -10,7 +10,7 @@ import {
type StartedPostgreSqlContainer,
} from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "main@sha256:430b1f00e74c3f89f078670f676b4333f6bbe5a339962344b3ae84e99e9bcd7f";
const TAG = "main@sha256:e1004d0213a985064766c99df344b0ac799869aff503be5aba1a720478258873";
/**
* MatrixAuthenticationServiceContainer which freezes the docker digest to

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "develop@sha256:18e9e77eac01709e9ab4d26cf20c36bf5a1567756bb5a78c00cabf366d65a950";
const TAG = "develop@sha256:78b32934c4a7a616ada0a0af6e6ba3a102f97ff747fc0d2d1e3bd930e16e529e";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,

View File

@@ -142,7 +142,6 @@
@import "./views/dialogs/_GenericFeatureFeedbackDialog.pcss";
@import "./views/dialogs/_IncomingSasDialog.pcss";
@import "./views/dialogs/_InviteDialog.pcss";
@import "./views/dialogs/_InviteProgressBody.pcss";
@import "./views/dialogs/_JoinRuleDropdown.pcss";
@import "./views/dialogs/_LeaveSpaceDialog.pcss";
@import "./views/dialogs/_LocationViewDialog.pcss";

View File

@@ -63,6 +63,17 @@ Please see LICENSE files in the repository root for full details.
height: 25px;
line-height: $font-25px;
}
.mx_InviteDialog_buttonAndSpinner {
.mx_Spinner {
/* Width and height are required to trick the layout engine. */
width: 20px;
height: 20px;
margin-inline-start: 5px;
display: inline-block;
vertical-align: middle;
}
}
}
.mx_InviteDialog_section {
@@ -207,10 +218,6 @@ Please see LICENSE files in the repository root for full details.
flex-direction: column;
flex-grow: 1;
overflow: hidden;
.mx_InviteProgressBody {
margin-top: var(--cpd-space-12x);
}
}
.mx_InviteDialog_transfer {

View File

@@ -1,16 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_InviteProgressBody {
text-align: center;
font: var(--cpd-font-body-lg-regular);
h1 {
color: var(--cpd-color-text-primary);
font: var(--cpd-font-heading-sm-semibold);
}
}

View File

@@ -15,44 +15,40 @@
* |-------------------------------------------------------|
*/
.mx_RoomListItemView {
/* Remove button default style */
background: unset;
border: none;
padding: 0;
text-align: unset;
all: unset;
cursor: pointer;
height: 48px;
width: 100%;
padding-left: var(--cpd-space-3x);
font: var(--cpd-font-body-md-regular);
.mx_RoomListItemView_content {
.mx_RoomListItemView_container {
padding-left: var(--cpd-space-3x);
font: var(--cpd-font-body-md-regular);
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_text {
.mx_RoomListItemView_content {
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_roomName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mx_RoomListItemView_text {
min-width: 0;
}
.mx_RoomListItemView_messagePreview {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.mx_RoomListItemView_roomName {
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;
}
}
}
}
@@ -61,7 +57,7 @@
background-color: var(--cpd-color-bg-action-secondary-hovered);
}
.mx_RoomListItemView_menu_open .mx_RoomListItemView_content {
.mx_RoomListItemView_menu_open .mx_RoomListItemView_container .mx_RoomListItemView_content {
/**
* 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

View File

@@ -79,12 +79,3 @@ export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent)
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;
}

View File

@@ -26,7 +26,7 @@ import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDia
// during the same single operation. Use `accessSecretStorage` below to scope a
// single secret storage operation, as it will clear the cached keys once the
// operation ends.
let secretStorageKeys: Record<string, Uint8Array> = {};
let secretStorageKeys: Record<string, Uint8Array<ArrayBuffer>> = {};
let secretStorageKeyInfo: Record<string, SecretStorage.SecretStorageKeyDescription> = {};
let secretStorageBeingAccessed = false;
@@ -51,8 +51,8 @@ export class AccessCancelledError extends Error {
function makeInputToKey(
keyInfo: SecretStorage.SecretStorageKeyDescription,
): (keyParams: KeyParams) => Promise<Uint8Array> {
return async ({ passphrase, recoveryKey }): Promise<Uint8Array> => {
): (keyParams: KeyParams) => Promise<Uint8Array<ArrayBuffer>> {
return async ({ passphrase, recoveryKey }): Promise<Uint8Array<ArrayBuffer>> => {
if (passphrase) {
return deriveRecoveryKeyFromPassphrase(passphrase, keyInfo.passphrase.salt, keyInfo.passphrase.iterations);
} else if (recoveryKey) {
@@ -69,7 +69,7 @@ async function getSecretStorageKey(
keys: Record<string, SecretStorage.SecretStorageKeyDescription>;
},
secretName: string,
): Promise<[string, Uint8Array]> {
): Promise<[string, Uint8Array<ArrayBuffer>]> {
const cli = MatrixClientPeg.safeGet();
const defaultKeyId = await cli.secretStorage.getDefaultKeyId();
@@ -139,7 +139,7 @@ async function getSecretStorageKey(
function cacheSecretStorageKey(
keyId: string,
keyInfo: SecretStorage.SecretStorageKeyDescription,
key: Uint8Array,
key: Uint8Array<ArrayBuffer>,
): void {
if (secretStorageBeingAccessed) {
logger.debug(`Caching 4S key ${keyId}`);

View File

@@ -124,7 +124,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
if (keyFromCustomisations) this.initExtension(keyFromCustomisations);
}
private initExtension(keyFromCustomisations: Uint8Array): void {
private initExtension(keyFromCustomisations: Uint8Array<ArrayBuffer>): void {
logger.log("CryptoSetupExtension: Created key via extension, jumping to bootstrap step");
this.recoveryKey = {
privateKey: keyFromCustomisations,

View File

@@ -142,7 +142,7 @@ export class VoiceMessageRecording implements IDestroyable {
this.buffer = concat(this.buffer, buf);
};
private get audioBuffer(): Uint8Array {
private get audioBuffer(): Uint8Array<ArrayBuffer> {
// We need a clone of the buffer to avoid accidentally changing the position
// on the real thing.
return this.buffer.slice(0);

View File

@@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
import React, { useRef, type JSX, useCallback, useEffect, useState } from "react";
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
* and any additional context data from the parent component.
@@ -35,7 +34,6 @@ export interface IListViewProps<Item, Context>
* @param index - The index of the item in the list
* @param item - The data item to render
* @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
*/
getItemComponent: (
@@ -64,14 +62,6 @@ export interface IListViewProps<Item, Context>
* @return The key to use for focusing the item
*/
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;
}
/**
@@ -83,7 +73,7 @@ export interface IListViewProps<Item, Context>
*/
export function ListView<Item, Context = any>(props: IListViewProps<Item, Context>): React.ReactElement {
// Extract our custom props to avoid conflicts with Virtuoso props
const { items, getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, ...virtuosoProps } = props;
const { items, getItemComponent, isItemFocusable, getItemKey, context, ...virtuosoProps } = props;
/** Reference to the Virtuoso component for programmatic scrolling */
const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
/** Reference to the DOM element containing the virtualized list */
@@ -135,7 +125,7 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
const key = getItemKey(items[clampedIndex]);
setTabIndexKey(key);
isScrollingToItem.current = true;
virtuosoHandleRef.current?.scrollIntoView({
virtuosoHandleRef?.current?.scrollIntoView({
index: clampedIndex,
align: align,
behavior: "auto",
@@ -178,44 +168,40 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
* Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space.
*/
const keyDownCallback = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined;
let handled = false;
(e: React.KeyboardEvent) => {
if (!e) return; // Guard against null/undefined events
// Guard against null/undefined events and modified keys which we don't want to handle here but do
// at the settings level shortcuts(E.g. Select next room, etc )
if (e || !isModifiedKeyEvent(e)) {
if (e.code === Key.ARROW_UP && currentIndex !== undefined) {
scrollToItem(currentIndex - 1, false);
handled = true;
} else if (e.code === Key.ARROW_DOWN && currentIndex !== undefined) {
scrollToItem(currentIndex + 1, true);
handled = true;
} else if (e.code === Key.HOME) {
scrollToIndex(0);
handled = true;
} else if (e.code === Key.END) {
scrollToIndex(items.length - 1);
handled = true;
} else if (e.code === Key.PAGE_DOWN && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`);
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;
}
const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined;
let handled = false;
if (e.code === "ArrowUp" && currentIndex !== undefined) {
scrollToItem(currentIndex - 1, false);
handled = true;
} else if (e.code === "ArrowDown" && currentIndex !== undefined) {
scrollToItem(currentIndex + 1, true);
handled = true;
} else if (e.code === "Home") {
scrollToIndex(0);
handled = true;
} else if (e.code === "End") {
scrollToIndex(items.length - 1);
handled = true;
} else if (e.code === "PageDown" && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`);
handled = true;
} else if (e.code === "PageUp" && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, `start`);
handled = true;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
} else {
onKeyDown?.(e);
}
},
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown],
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items],
);
/**
@@ -265,12 +251,8 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
[keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey],
);
const onBlur = useCallback((event: React.FocusEvent<HTMLDivElement>): void => {
// 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 onBlur = useCallback((): void => {
setIsFocused(false);
}, []);
const listContext: ListContext<Context> = {
@@ -282,8 +264,8 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
return (
<Virtuoso
tabIndex={props.tabIndex || undefined} // We don't need to focus the container, so leave it undefined by default
ref={virtuosoHandleRef}
scrollerRef={scrollerRef}
ref={virtuosoHandleRef}
onKeyDown={keyDownCallback}
context={listContext}
rangeChanged={setVisibleRange}

View File

@@ -18,7 +18,6 @@ import { Action } from "../../../dispatcher/actions";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useStickyRoomList } from "./useStickyRoomList";
import { useRoomListNavigation } from "./useRoomListNavigation";
import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
export interface RoomListViewState {
/**
@@ -27,9 +26,9 @@ export interface RoomListViewState {
isLoadingRooms: boolean;
/**
* The room results to be displayed (along with the spaceId and filter keys at the time of query)
* A list of rooms to be displayed in the left panel.
*/
roomsResult: RoomsResult;
rooms: Room[];
/**
* Create a chat room
@@ -72,10 +71,10 @@ export interface RoomListViewState {
*/
export function useRoomListViewModel(): RoomListViewState {
const matrixClient = useMatrixClientContext();
const { isLoadingRooms, primaryFilters, activePrimaryFilter, roomsResult: filteredRooms } = useFilteredRooms();
const { activeIndex, roomsResult } = useStickyRoomList(filteredRooms);
const { isLoadingRooms, primaryFilters, activePrimaryFilter, rooms: filteredRooms } = useFilteredRooms();
const { activeIndex, rooms } = useStickyRoomList(filteredRooms);
useRoomListNavigation(roomsResult.rooms);
useRoomListNavigation(rooms);
const currentSpace = useEventEmitterState<Room | null>(
SpaceStore.instance,
@@ -89,7 +88,7 @@ export function useRoomListViewModel(): RoomListViewState {
return {
isLoadingRooms,
roomsResult,
rooms,
canCreateRoom,
createRoom,
createChatRoom,

View File

@@ -5,15 +5,12 @@ 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.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters";
import { _t, _td, type TranslationKey } from "../../../languageHandler";
import RoomListStoreV3, {
LISTS_LOADED_EVENT,
LISTS_UPDATE_EVENT,
type RoomsResult,
} from "../../../stores/room-list-v3/RoomListStoreV3";
import RoomListStoreV3, { LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT } from "../../../stores/room-list-v3/RoomListStoreV3";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
@@ -38,7 +35,7 @@ export interface PrimaryFilter {
interface FilteredRooms {
primaryFilters: PrimaryFilter[];
isLoadingRooms: boolean;
roomsResult: RoomsResult;
rooms: Room[];
/**
* The currently active primary filter.
* If no primary filter is active, this will be undefined.
@@ -66,12 +63,12 @@ export function useFilteredRooms(): FilteredRooms {
*/
const [primaryFilter, setPrimaryFilter] = useState<FilterKey | undefined>();
const [roomsResult, setRoomsResult] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
const [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms);
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
setRoomsResult(newRooms);
setRooms(newRooms);
}, []);
// Reset filters when active space changes
@@ -80,15 +77,9 @@ export function useFilteredRooms(): FilteredRooms {
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
array.filter((f) => f !== undefined) as FilterKey[];
const getAppliedFilters = useCallback((): FilterKey[] => {
const getAppliedFilters = (): FilterKey[] => {
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, () => {
const filters = getAppliedFilters();
@@ -131,6 +122,6 @@ export function useFilteredRooms(): FilteredRooms {
isLoadingRooms,
primaryFilters,
activePrimaryFilter,
roomsResult,
rooms,
};
}

View File

@@ -14,7 +14,6 @@ import { Action } from "../../../dispatcher/actions";
import type { Room } from "matrix-js-sdk/src/matrix";
import type { Optional } from "matrix-events-sdk";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { type RoomsResult } from "../../../stores/room-list-v3/RoomListStoreV3";
function getIndexByRoomId(rooms: Room[], roomId: Optional<string>): number | undefined {
const index = rooms.findIndex((room) => room.roomId === roomId);
@@ -68,11 +67,11 @@ function getRoomsWithStickyRoom(
return { newIndex: oldIndex, newRooms };
}
export interface StickyRoomListResult {
interface StickyRoomListResult {
/**
* The rooms result with the active sticky room applied
* List of rooms with sticky active room.
*/
roomsResult: RoomsResult;
rooms: Room[];
/**
* Index of the active room in the room list.
*/
@@ -86,10 +85,10 @@ export interface StickyRoomListResult {
* @param rooms list of rooms
* @see {@link StickyRoomListResult} details what this hook returns..
*/
export function useStickyRoomList(roomsResult: RoomsResult): StickyRoomListResult {
const [listState, setListState] = useState<StickyRoomListResult>({
activeIndex: getIndexByRoomId(roomsResult.rooms, SdkContextClass.instance.roomViewStore.getRoomId()),
roomsResult: roomsResult,
export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
const [listState, setListState] = useState<{ index: number | undefined; roomsWithStickyRoom: Room[] }>({
index: undefined,
roomsWithStickyRoom: rooms,
});
const currentSpaceRef = useRef(SpaceStore.instance.activeSpace);
@@ -98,18 +97,13 @@ export function useStickyRoomList(roomsResult: RoomsResult): StickyRoomListResul
(newRoomId: string | null, isRoomChange: boolean = false) => {
setListState((current) => {
const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId();
const newActiveIndex = getIndexByRoomId(roomsResult.rooms, activeRoomId);
const oldIndex = current.activeIndex;
const { newIndex, newRooms } = getRoomsWithStickyRoom(
roomsResult.rooms,
oldIndex,
newActiveIndex,
isRoomChange,
);
return { activeIndex: newIndex, roomsResult: { ...roomsResult, rooms: newRooms } };
const newActiveIndex = getIndexByRoomId(rooms, activeRoomId);
const oldIndex = current.index;
const { newIndex, newRooms } = getRoomsWithStickyRoom(rooms, oldIndex, newActiveIndex, isRoomChange);
return { index: newIndex, roomsWithStickyRoom: newRooms };
});
},
[roomsResult],
[rooms],
);
// Re-calculate the index when the active room has changed.
@@ -121,19 +115,20 @@ export function useStickyRoomList(roomsResult: RoomsResult): StickyRoomListResul
useEffect(() => {
let newRoomId: string | null = null;
let isRoomChange = false;
if (currentSpaceRef.current !== roomsResult.spaceId) {
const newSpace = SpaceStore.instance.activeSpace;
if (currentSpaceRef.current !== newSpace) {
/*
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
flicker because of the delay between the space change event and
active room change dispatch.
*/
newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(roomsResult.spaceId);
newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpace);
isRoomChange = true;
currentSpaceRef.current = roomsResult.spaceId;
currentSpaceRef.current = newSpace;
}
updateRoomsAndIndex(newRoomId, isRoomChange);
}, [roomsResult, updateRoomsAndIndex]);
}, [rooms, updateRoomsAndIndex]);
return listState;
return { activeIndex: listState.index, rooms: listState.roomsWithStickyRoom };
}

View File

@@ -40,6 +40,7 @@ import Field from "../elements/Field";
import TabbedView, { Tab, TabLocation } from "../../structures/TabbedView";
import Dialpad from "../voip/DialPad";
import QuestionDialog from "./QuestionDialog";
import Spinner from "../elements/Spinner";
import BaseDialog from "./BaseDialog";
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
import LegacyCallHandler from "../../../LegacyCallHandler";
@@ -64,7 +65,6 @@ import { UNKNOWN_PROFILE_ERRORS } from "../../../utils/MultiInviter";
import AskInviteAnywayDialog, { type UnknownProfiles } from "./AskInviteAnywayDialog";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { type UserProfilesStore } from "../../../stores/UserProfilesStore";
import InviteProgressBody from "./InviteProgressBody.tsx";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
@@ -329,14 +329,8 @@ interface IInviteDialogState {
dialPadValue: string;
currentTabId: TabId;
/**
* True if we are sending the invites.
*
* We will grey out the action button, hide the suggestions, and display a spinner.
*/
// These two flags are used for the 'Go' button to communicate what is going on.
busy: boolean;
/** Error from the last attempt to send invites. */
errorText?: string;
}
@@ -623,10 +617,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}
try {
const result = await inviteMultipleToRoom(cli, this.props.roomId, targetIds, {
// We show our own progress body, so don't pop up a separate dialog.
inhibitProgressDialog: true,
});
const result = await inviteMultipleToRoom(cli, this.props.roomId, targetIds);
if (!this.shouldAbortAfterInviteError(result, room)) {
// handles setting error message too
this.props.onFinished(true);
@@ -1337,6 +1328,11 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
* "CallTransfer" one.
*/
private renderMainTab(): JSX.Element {
let spinner: JSX.Element | undefined;
if (this.state.busy) {
spinner = <Spinner w={20} h={20} />;
}
let helpText;
let buttonText;
let goButtonFn: (() => Promise<void>) | null = null;
@@ -1441,9 +1437,12 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
<p className="mx_InviteDialog_helpText">{helpText}</p>
<div className="mx_InviteDialog_addressBar">
{this.renderEditor()}
{goButton}
<div className="mx_InviteDialog_buttonAndSpinner">
{goButton}
{spinner}
</div>
</div>
{this.state.busy ? <InviteProgressBody /> : this.renderSuggestions()}
{this.renderSuggestions()}
</React.Fragment>
);
}

View File

@@ -1,24 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import InlineSpinner from "../elements/InlineSpinner";
import { _t } from "../../../languageHandler";
/** The common body of components that show the progress of sending room invites. */
const InviteProgressBody: React.FC = () => {
return (
<div className="mx_InviteProgressBody">
<InlineSpinner w={32} h={32} />
<h1>{_t("invite|progress|preparing")}</h1>
{_t("invite|progress|dont_close")}
</div>
);
};
export default InviteProgressBody;

View File

@@ -1,38 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import Modal from "../../../Modal.tsx";
import InviteProgressBody from "./InviteProgressBody.tsx";
/** A Modal dialog that pops up while room invites are being sent. */
const InviteProgressDialog: React.FC = (_) => {
return <InviteProgressBody />;
};
/**
* Open the invite progress dialog.
*
* Returns a callback which will close the dialog again.
*/
export function openInviteProgressDialog(): () => void {
const onBeforeClose = async (reason?: string): Promise<boolean> => {
// Inhibit closing via background click
return reason != "backgroundClick";
};
const { close } = Modal.createDialog(
InviteProgressDialog,
/* props */ {},
/* className */ undefined,
/* isPriorityModal */ false,
/* isStaticModal */ false,
{ onBeforeClose },
);
return close;
}

View File

@@ -74,7 +74,6 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
public componentWillUnmount(): void {
this.state.playback?.destroy();
this.state.audioPlayerVm?.dispose();
}
protected get showFileBody(): boolean {

View File

@@ -12,9 +12,7 @@ import { type MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
import DisambiguatedProfile from "./DisambiguatedProfile";
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
import ModuleApi from "../../../modules/Api";
import { CustomComponentsApi } from "../../../modules/customComponentApi";
import { MessageProfileComponentProps } from "@element-hq/element-web-module-api";
interface IProps {
mxEvent: MatrixEvent;
onClick?(): void;
@@ -26,25 +24,17 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
userId: mxEvent.getSender(),
member: mxEvent.sender,
});
if (mxEvent.getContent().msgtype === MsgType.Emote) {
return <></>;
}
const moduleRenderer = ModuleApi.customComponents.messageProfileRenderer;
const renderFn = (moduleProps: MessageProfileComponentProps) => <DisambiguatedProfile
fallbackName={moduleProps.mxEvent.sender ?? ""}
onClick={moduleProps.onClick}
member={moduleProps.member}
colored={true}
emphasizeDisplayName={true}
withTooltip={withTooltip}
/>;
const modProps = {
onClick,
mxEvent: CustomComponentsApi.getModuleMatrixEvent(mxEvent)!,
member: member || undefined,
};
return moduleRenderer ? moduleRenderer(modProps, renderFn) : renderFn(modProps);
return mxEvent.getContent().msgtype !== MsgType.Emote ? (
<DisambiguatedProfile
fallbackName={mxEvent.getSender() ?? ""}
onClick={onClick}
member={member}
colored={true}
emphasizeDisplayName={true}
withTooltip={withTooltip}
/>
) : (
<></>
);
}

View File

@@ -16,7 +16,6 @@ import { Flex } from "../../../../shared-components/utils/Flex";
import PresenceLabel from "../../rooms/PresenceLabel";
import CopyableText from "../../elements/CopyableText";
import { UserInfoHeaderVerificationView } from "./UserInfoHeaderVerificationView";
import ModuleApi from "../../../../modules/Api";
export interface UserInfoHeaderViewProps {
member: Member;
@@ -49,27 +48,6 @@ export const UserInfoHeaderView: React.FC<UserInfoHeaderViewProps> = ({
);
}
const moduleRenderer = ModuleApi.customComponents.userInfoRenderer;
let usernameSection;
if (moduleRenderer && vm.userIdentifier) {
usernameSection = moduleRenderer(
{
userId: vm.userIdentifier,
},
(props) => <Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
<CopyableText getTextToCopy={() => props.userId} border={false}>
{props.userId}
</CopyableText>
</Text>,
);
} else {
usernameSection = <Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
<CopyableText getTextToCopy={() => vm.userIdentifier} border={false}>
{vm.userIdentifier}
</CopyableText>
</Text>;
}
return (
<React.Fragment>
<div className="mx_UserInfo_avatar">
@@ -105,7 +83,11 @@ export const UserInfoHeaderView: React.FC<UserInfoHeaderViewProps> = ({
</Flex>
</Tooltip>
)}
{usernameSection}
<Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
<CopyableText getTextToCopy={() => vm.userIdentifier} border={false}>
{vm.userIdentifier}
</CopyableText>
</Text>
</Flex>
{!hideVerificationSection && <UserInfoHeaderVerificationView member={member} devices={devices} />}
</Container>

View File

@@ -5,16 +5,13 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, useRef, type JSX } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { type ScrollIntoViewLocation } from "react-virtuoso";
import { isEqual } from "lodash";
import React, { useCallback, type JSX } from "react";
import { AutoSizer, List, type ListRowProps } from "react-virtualized";
import { type RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { _t } from "../../../../languageHandler";
import { RoomListItemView } from "./RoomListItemView";
import { type ListContext, ListView } from "../../../utils/ListView";
import { type FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation";
@@ -29,93 +26,55 @@ interface RoomListProps {
/**
* A virtualized list of rooms.
*/
export function RoomList({ vm: { roomsResult, activeIndex } }: RoomListProps): JSX.Element {
const lastSpaceId = useRef<string | undefined>(undefined);
const lastFilterKeys = useRef<FilterKey[] | undefined>(undefined);
const roomCount = roomsResult.rooms.length;
const getItemComponent = useCallback(
(
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],
export function RoomList({ vm: { rooms, activeIndex } }: RoomListProps): JSX.Element {
const roomRendererMemoized = useCallback(
({ key, index, style }: ListRowProps) => (
<RoomListItemView room={rooms[index]} key={key} style={style} isSelected={activeIndex === index} />
),
[rooms, activeIndex],
);
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;
}
}, []);
// The first div is needed to make the virtualized list take all the remaining space and scroll correctly
return (
<ListView
context={{ spaceId: roomsResult.spaceId, filterKeys: roomsResult.filterKeys }}
scrollIntoViewOnChange={scrollIntoViewOnChange}
initialTopMostItemIndex={activeIndex}
data-testid="room-list"
role="listbox"
aria-label={_t("room_list|list_title")}
fixedItemHeight={48}
items={roomsResult.rooms}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
isItemFocusable={() => true}
onKeyDown={keyDownCallback}
/>
<RovingTabIndexProvider handleHomeEnd={true} handleUpDown={true}>
{({ onKeyDownHandler }) => (
<div
className="mx_RoomList"
data-testid="room-list"
onKeyDown={(ev) => {
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;
}
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>
);
}

View File

@@ -97,10 +97,7 @@ interface MoreOptionContentProps {
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
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 && (
<MenuItem
Icon={MarkAsReadIcon}
@@ -160,7 +157,7 @@ export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
onClick={(evt) => evt.stopPropagation()}
hideChevron={true}
/>
</div>
</>
);
}
@@ -199,59 +196,54 @@ function NotificationMenu({ vm, setMenuOpen }: NotificationMenuProps): JSX.Eleme
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
return (
<div
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
<Menu
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
setMenuOpen(isOpen);
}}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={<NotificationButton isRoomMuted={vm.isNotificationMute} size="24px" />}
>
<Menu
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
setMenuOpen(isOpen);
}}
title={_t("room_list|notification_options")}
showTitle={false}
align="start"
trigger={<NotificationButton isRoomMuted={vm.isNotificationMute} size="24px" />}
<MenuItem
aria-selected={vm.isNotificationAllMessage}
hideChevron={true}
label={_t("notifications|default_settings")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessages)}
onClick={(evt) => evt.stopPropagation()}
>
<MenuItem
aria-selected={vm.isNotificationAllMessage}
hideChevron={true}
label={_t("notifications|default_settings")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessages)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationAllMessage && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationAllMessageLoud}
hideChevron={true}
label={_t("notifications|all_messages")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationAllMessageLoud && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationMentionOnly}
hideChevron={true}
label={_t("notifications|mentions_keywords")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.MentionsOnly)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMentionOnly && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationMute}
hideChevron={true}
label={_t("notifications|mute_room")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.Mute)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMute && checkComponent}
</MenuItem>
</Menu>
</div>
{vm.isNotificationAllMessage && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationAllMessageLoud}
hideChevron={true}
label={_t("notifications|all_messages")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationAllMessageLoud && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationMentionOnly}
hideChevron={true}
label={_t("notifications|mentions_keywords")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.MentionsOnly)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMentionOnly && checkComponent}
</MenuItem>
<MenuItem
aria-selected={vm.isNotificationMute}
hideChevron={true}
label={_t("notifications|mute_room")}
onSelect={() => vm.setRoomNotifState(RoomNotifState.Mute)}
onClick={(evt) => evt.stopPropagation()}
>
{vm.isNotificationMute && checkComponent}
</MenuItem>
</Menu>
);
}

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, memo, useCallback, useEffect, useRef, useState } from "react";
import React, { type JSX, memo, useCallback, useRef, useState } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
@@ -14,6 +14,7 @@ import { Flex } from "../../../../shared-components/utils/Flex";
import { RoomListItemMenuView } from "./RoomListItemMenuView";
import { NotificationDecoration } from "../NotificationDecoration";
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
import { RoomListItemContextMenuView } from "./RoomListItemContextMenuView";
interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement> {
@@ -25,22 +26,6 @@ interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement>
* Whether the room is selected
*/
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;
}
/**
@@ -49,19 +34,18 @@ interface RoomListItemViewProps extends React.HTMLAttributes<HTMLButtonElement>
export const RoomListItemView = memo(function RoomListItemView({
room,
isSelected,
isFocused,
onFocus,
roomIndex: index,
roomCount: count,
...props
}: RoomListItemViewProps): JSX.Element {
const ref = useRef<HTMLButtonElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [onFocus, isActive, ref] = useRovingTabIndex(buttonRef);
const vm = useRoomListItemViewModel(room);
const [isHover, setHover] = useState(false);
const [isHover, setIsHoverWithDelay] = useIsHover();
const [isMenuOpen, setIsMenuOpen] = useState(false);
// 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
const showHoverDecoration = isMenuOpen || isFocused || isHover;
const showHoverDecoration = isMenuOpen || isHover;
const showHoverMenu = showHoverDecoration && vm.showHoverMenu;
const closeMenu = useCallback(() => {
@@ -70,15 +54,8 @@ export const RoomListItemView = memo(function RoomListItemView({
setTimeout(() => setIsMenuOpen(false), 10);
}, []);
useEffect(() => {
if (isFocused) {
ref.current?.focus({ preventScroll: true, focusVisible: true });
}
}, [isFocused]);
const content = (
<Flex
as="button"
<button
ref={ref}
className={classNames("mx_RoomListItemView", {
mx_RoomListItemView_hover: showHoverDecoration,
@@ -86,59 +63,63 @@ export const RoomListItemView = memo(function RoomListItemView({
mx_RoomListItemView_selected: isSelected,
mx_RoomListItemView_bold: vm.isBold,
})}
gap="var(--cpd-space-3x)"
align="center"
type="button"
role="option"
aria-posinset={index + 1}
aria-setsize={count}
aria-selected={isSelected}
aria-label={vm.a11yLabel}
onClick={() => vm.openRoom()}
onFocus={onFocus}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onBlur={() => setHover(false)}
tabIndex={isFocused ? 0 : -1}
onMouseOver={() => setIsHoverWithDelay(true)}
onMouseOut={() => setIsHoverWithDelay(false)}
onFocus={() => {
setIsHoverWithDelay(true);
onFocus();
}}
// 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}
>
<RoomAvatarView room={room} />
<Flex
className="mx_RoomListItemView_content"
gap="var(--cpd-space-2x)"
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">
<div className="mx_RoomListItemView_roomName" title={vm.name}>
{vm.name}
</div>
{vm.messagePreview && (
<div className="mx_RoomListItemView_messagePreview" title={vm.messagePreview}>
{vm.messagePreview}
{/* 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 */}
<Flex className="mx_RoomListItemView_container" gap="var(--cpd-space-3x)" align="center">
<RoomAvatarView room={room} />
<Flex
className="mx_RoomListItemView_content"
gap="var(--cpd-space-2x)"
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">
<div className="mx_RoomListItemView_roomName" title={vm.name}>
{vm.name}
</div>
)}
</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}
/>
{vm.messagePreview && (
<div className="mx_RoomListItemView_messagePreview" title={vm.messagePreview}>
{vm.messagePreview}
</div>
)}
</>
)}
</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>
</Flex>
</button>
);
if (!vm.showContextMenu) return content;
@@ -159,3 +140,33 @@ export const RoomListItemView = memo(function RoomListItemView({
</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];
}

View File

@@ -17,7 +17,7 @@ import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
*/
export function RoomListView(): JSX.Element {
const vm = useRoomListViewModel();
const isRoomListEmpty = vm.roomsResult.rooms.length === 0;
const isRoomListEmpty = vm.rooms.length === 0;
let listBody;
if (vm.isLoadingRooms) {
listBody = <div className="mx_RoomListSkeleton" />;

View File

@@ -18,7 +18,7 @@ import SpaceStore from "../../../stores/spaces/SpaceStore";
import Modal from "../../../Modal";
import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog";
import RoomUpgradeWarningDialog, { type IFinishedOpts } from "../dialogs/RoomUpgradeWarningDialog";
import { type RoomUpgradeProgress, upgradeRoom } from "../../../utils/RoomUpgrade";
import { upgradeRoom } from "../../../utils/RoomUpgrade";
import { arrayHasDiff } from "../../../utils/arrays";
import { useLocalEcho } from "../../../hooks/useLocalEcho";
import dis from "../../../dispatcher/dispatcher";
@@ -120,7 +120,7 @@ const JoinRuleSettings: React.FC<JoinRuleSettingsProps> = ({
opts: IFinishedOpts,
fn: (progressText: string, progress: number, total: number) => void,
): Promise<void> => {
const progressCallback = (progress: RoomUpgradeProgress): void => {
const roomId = await upgradeRoom(room, targetVersion, opts.invite, true, true, true, (progress) => {
const total = 2 + progress.updateSpacesTotal + progress.inviteUsersTotal;
if (!progress.roomUpgraded) {
fn(_t("room_settings|security|join_rule_upgrade_upgrading_room"), 0, total);
@@ -151,20 +151,7 @@ const JoinRuleSettings: React.FC<JoinRuleSettingsProps> = ({
total,
);
}
};
const roomId = await upgradeRoom(
room,
targetVersion,
opts.invite,
true,
true,
true,
progressCallback,
// We want to keep the RoomUpgradeDialog open during the upgrade, so don't replace it with the
// invite progress dialog.
/* inhibitInviteProgressDialog: */ true,
);
});
closeSettingsFn?.();

View File

@@ -1366,10 +1366,6 @@
"name_email_mxid_share_space": "Invite someone using their name, email address, username (like <userId/>) or <a>share this space</a>.",
"name_mxid_share_room": "Invite someone using their name, username (like <userId/>) or <a>share this room</a>.",
"name_mxid_share_space": "Invite someone using their name, username (like <userId/>) or <a>share this space</a>.",
"progress": {
"dont_close": "Do not close the app until finished.",
"preparing": "Preparing invitations..."
},
"recents_section": "Recent Conversations",
"room_failed_partial": "We sent the others, but the below people couldn't be invited to <RoomName/>",
"room_failed_partial_title": "Some invites couldn't be sent",

View File

@@ -1366,10 +1366,6 @@
"name_email_mxid_share_space": "Inviter noen ved å bruke navn, e-postadresse, brukernavn (lik <userId/>) eller <a> del dette området</a>.",
"name_mxid_share_room": "Inviter noen ved å bruke navnet, brukernavnet (som <userId/>) eller <a>del dette rommet</a>.",
"name_mxid_share_space": "Inviter noen ved å bruke navnet sitt, brukernavnet (lik <userId/>) eller <a> dele dette området</a>.",
"progress": {
"dont_close": "Ikke lukk appen før den er ferdig.",
"preparing": "Forbereder invitasjoner..."
},
"recents_section": "Nylige samtaler",
"room_failed_partial": "Vi sendte de andre, men folkene nedenfor kunne ikke inviteres til <RoomName/>",
"room_failed_partial_title": "Noen invitasjoner kunne ikke sendes",

View File

@@ -1380,10 +1380,6 @@
"name_email_mxid_share_space": "Pozvite niekoho pomocou jeho mena, e-mailovej adresy, používateľského mena (napríklad <userId/>) alebo <a>zdieľajte tento priestor</a>.",
"name_mxid_share_room": "Pozvite niekoho pomocou jeho mena, používateľského mena (napríklad <userId/>) alebo <a>zdieľate túto miestnosť</a>.",
"name_mxid_share_space": "Pozvite niekoho pomocou jeho mena, používateľského mena (napríklad <userId/>) alebo <a>zdieľajte tento priestor</a>.",
"progress": {
"dont_close": "Nezatvárajte aplikáciu, kým nie je proces dokončený.",
"preparing": "Príprava pozvánok..."
},
"recents_section": "Nedávne konverzácie",
"room_failed_partial": "Ostatným sme pozvánky poslali, ale nižšie uvedené osoby nemohli byť pozvané do <RoomName/>",
"room_failed_partial_title": "Niektoré pozvánky nebolo možné odoslať",
@@ -1786,7 +1782,6 @@
},
"power_level": {
"admin": "Správca",
"creator": "Vlastník",
"custom": "Vlastný (%(level)s)",
"custom_level": "Vlastná úroveň",
"default": "Predvolené",
@@ -1945,7 +1940,6 @@
"thread_list": {
"context_menu_label": "Možnosti vlákna"
},
"title": "Pravý panel",
"video_room_chat": {
"title": "Konverzácia"
}

View File

@@ -1366,10 +1366,6 @@
"name_email_mxid_share_space": "Bjud in någon med deras namn, e-postadress eller användarnamn (som <userId/>), eller <a>dela det här rummet</a>.",
"name_mxid_share_room": "Bjud in någon med deras namn eller användarnamn (som <userId/>) eller <a>dela det här rummet</a>.",
"name_mxid_share_space": "Bjud in någon med deras namn eller användarnamn (som <userId/>), eller <a>dela det här utrymmet</a>.",
"progress": {
"dont_close": "Stäng inte appen förrän det är klart.",
"preparing": "Förbereder inbjudningar …"
},
"recents_section": "Senaste konversationerna",
"room_failed_partial": "Vi skickade de andra, men personerna nedan kunde inte bjudas in till <RoomName/>",
"room_failed_partial_title": "Vissa inbjudningar kunde inte skickas",
@@ -1767,7 +1763,6 @@
},
"power_level": {
"admin": "Administratör",
"creator": "Ägare",
"custom": "Anpassad (%(level)s)",
"custom_level": "Anpassad nivå",
"default": "Standard",
@@ -1920,7 +1915,6 @@
"thread_list": {
"context_menu_label": "Trådalternativ"
},
"title": "Högerpanel",
"video_room_chat": {
"title": "Chatt"
}

View File

@@ -16,8 +16,6 @@ import type {
CustomMessageRenderHints as ModuleCustomCustomMessageRenderHints,
MatrixEvent as ModuleMatrixEvent,
CustomRoomPreviewBarRenderFunction,
MessageProfileRenderFunction,
UserInfoRenderFunction,
} from "@element-hq/element-web-module-api";
import type React from "react";
@@ -44,7 +42,7 @@ export class CustomComponentsApi implements ICustomComponentsApi {
* @param mxEvent
* @returns An event object, or `null` if the event was not a message event.
*/
public static getModuleMatrixEvent(mxEvent: MatrixEvent): ModuleMatrixEvent | null {
private static getModuleMatrixEvent(mxEvent: MatrixEvent): ModuleMatrixEvent | null {
const eventId = mxEvent.getId();
const roomId = mxEvent.getRoomId();
const sender = mxEvent.sender;
@@ -140,8 +138,6 @@ export class CustomComponentsApi implements ICustomComponentsApi {
}
private _roomPreviewBarRenderer?: CustomRoomPreviewBarRenderFunction;
private _userInfoRenderer?: UserInfoRenderFunction;
private _messageProfileRenderer?: MessageProfileRenderFunction;
/**
* Get the custom room preview bar renderer, if any has been registered.
@@ -157,25 +153,4 @@ export class CustomComponentsApi implements ICustomComponentsApi {
public registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void {
this._roomPreviewBarRenderer = renderer;
}
/**
* Get the custom user info renderer, if any has been registered.
*/
public get messageProfileRenderer(): MessageProfileRenderFunction | undefined {
return this._messageProfileRenderer;
}
public registerMessageProfile(renderer: MessageProfileRenderFunction): void {
this._messageProfileRenderer = renderer;
}
/**
* Get the custom user info renderer, if any has been registered.
*/
public get userInfoRenderer(): UserInfoRenderFunction | undefined {
return this._userInfoRenderer;
}
public registerUserInfo(renderer: UserInfoRenderFunction): void {
this._userInfoRenderer = renderer;
}
}

View File

@@ -22,7 +22,7 @@ import { AlphabeticSorter } from "./skip-list/sorters/AlphabeticSorter";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { EffectiveMembership, getEffectiveMembership, getEffectiveMembershipTag } from "../../utils/membership";
import SpaceStore from "../spaces/SpaceStore";
import { type SpaceKey, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces";
import { UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../spaces";
import { FavouriteFilter } from "./skip-list/filters/FavouriteFilter";
import { UnreadFilter } from "./skip-list/filters/UnreadFilter";
import { PeopleFilter } from "./skip-list/filters/PeopleFilter";
@@ -56,16 +56,6 @@ export enum RoomListStoreV3Event {
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_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
/**
@@ -117,15 +107,9 @@ export class RoomListStoreV3Class extends AsyncStoreWithClient<EmptyObject> {
* @param filterKeys Optional array of filters that the rooms must match against.
*/
public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): RoomsResult {
const spaceId = SpaceStore.instance.activeSpace;
if (this.roomSkipList?.initialized)
return {
spaceId: spaceId,
filterKeys,
rooms: Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys)),
};
else return { spaceId: spaceId, filterKeys, rooms: [] };
public getSortedRoomsInActiveSpace(filterKeys?: FilterKey[]): Room[] {
if (this.roomSkipList?.initialized) return Array.from(this.roomSkipList.getRoomsInActiveSpace(filterKeys));
else return [];
}
/**

View File

@@ -58,14 +58,14 @@ export async function decryptMegolmKeyFile(data: ArrayBuffer, password: string):
throw friendlyError("Invalid file: too short", _t("encryption|import_invalid_keyfile", { brand }));
}
const salt = body.subarray(1, 1 + 16);
const iv = body.subarray(17, 17 + 16);
const salt = body.subarray(1, 1 + 16).slice();
const iv = body.subarray(17, 17 + 16).slice();
const iterations = (body[33] << 24) | (body[34] << 16) | (body[35] << 8) | body[36];
const ciphertext = body.subarray(37, 37 + ciphertextLength);
const hmac = body.subarray(-32);
const ciphertext = body.subarray(37, 37 + ciphertextLength).slice();
const hmac = body.subarray(-32).slice();
const [aesKey, hmacKey] = await deriveKeys(salt, iterations, password);
const toVerify = body.subarray(0, -32);
const toVerify = body.subarray(0, -32).slice();
let isValid;
try {
@@ -180,7 +180,11 @@ export async function encryptMegolmKeyFile(
* @param {String} password password
* @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, hmac key]
*/
async function deriveKeys(salt: Uint8Array, iterations: number, password: string): Promise<[CryptoKey, CryptoKey]> {
async function deriveKeys(
salt: Uint8Array<ArrayBuffer>,
iterations: number,
password: string,
): Promise<[CryptoKey, CryptoKey]> {
const start = new Date();
let key;

View File

@@ -16,7 +16,6 @@ import Modal from "../Modal";
import SettingsStore from "../settings/SettingsStore";
import AskInviteAnywayDialog from "../components/views/dialogs/AskInviteAnywayDialog";
import ConfirmUserActionDialog from "../components/views/dialogs/ConfirmUserActionDialog";
import { openInviteProgressDialog } from "../components/views/dialogs/InviteProgressDialog.tsx";
export enum InviteState {
Invited = "invited",
@@ -45,12 +44,6 @@ const USER_BANNED = "IO.ELEMENT.BANNED";
export interface MultiInviterOptions {
/** Optional callback, fired after each invite */
progressCallback?: () => void;
/**
* By default, we will pop up a "Preparing invitations..." dialog while the invites are being sent. Set this to
* `true` to inhibit it (in which case, you probably want to implement another bit of feedback UI).
*/
inhibitProgressDialog?: boolean;
}
/**
@@ -95,59 +88,49 @@ export default class MultiInviter {
this.addresses.push(...addresses);
this.reason = reason;
let closeDialog: (() => void) | undefined;
if (!this.options.inhibitProgressDialog) {
closeDialog = openInviteProgressDialog();
for (const addr of this.addresses) {
if (getAddressType(addr) === null) {
this.completionStates[addr] = InviteState.Error;
this.errors[addr] = {
errcode: "M_INVALID",
errorText: _t("invite|invalid_address"),
};
}
}
try {
for (const addr of this.addresses) {
if (getAddressType(addr) === null) {
this.completionStates[addr] = InviteState.Error;
this.errors[addr] = {
errcode: "M_INVALID",
errorText: _t("invite|invalid_address"),
};
}
for (const addr of this.addresses) {
// don't try to invite it if it's an invalid address
// (it will already be marked as an error though,
// so no need to do so again)
if (getAddressType(addr) === null) {
continue;
}
for (const addr of this.addresses) {
// don't try to invite it if it's an invalid address
// (it will already be marked as an error though,
// so no need to do so again)
if (getAddressType(addr) === null) {
continue;
}
// don't re-invite (there's no way in the UI to do this, but
// for sanity's sake)
if (this.completionStates[addr] === InviteState.Invited) {
continue;
}
await this.doInvite(addr, false);
if (this._fatal) {
// `doInvite` suffered a fatal error. The error should have been recorded in `errors`; it's up
// to the caller to report back to the user.
return this.completionStates;
}
// don't re-invite (there's no way in the UI to do this, but
// for sanity's sake)
if (this.completionStates[addr] === InviteState.Invited) {
continue;
}
if (Object.keys(this.errors).length > 0) {
// There were problems inviting some people - see if we can invite them
// without caring if they exist or not.
const unknownProfileUsers = Object.keys(this.errors).filter((a) =>
UNKNOWN_PROFILE_ERRORS.includes(this.errors[a].errcode),
);
await this.doInvite(addr, false);
if (unknownProfileUsers.length > 0) {
await this.handleUnknownProfileUsers(unknownProfileUsers);
}
if (this._fatal) {
// `doInvite` suffered a fatal error. The error should have been recorded in `errors`; it's up
// to the caller to report back to the user.
return this.completionStates;
}
}
if (Object.keys(this.errors).length > 0) {
// There were problems inviting some people - see if we can invite them
// without caring if they exist or not.
const unknownProfileUsers = Object.keys(this.errors).filter((a) =>
UNKNOWN_PROFILE_ERRORS.includes(this.errors[a].errcode),
);
if (unknownProfileUsers.length > 0) {
await this.handleUnknownProfileUsers(unknownProfileUsers);
}
} finally {
// Remember to close the progress dialog, if we opened one.
closeDialog?.();
}
return this.completionStates;

View File

@@ -16,9 +16,8 @@ import { _t } from "../languageHandler";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
import SpaceStore from "../stores/spaces/SpaceStore";
import Spinner from "../components/views/elements/Spinner";
import type { MultiInviterOptions } from "./MultiInviter";
export interface RoomUpgradeProgress {
interface IProgress {
roomUpgraded: boolean;
roomSynced?: boolean;
inviteUsersProgress?: number;
@@ -51,8 +50,7 @@ export async function upgradeRoom(
handleError = true,
updateSpaces = true,
awaitRoom = false,
progressCallback?: (progress: RoomUpgradeProgress) => void,
inhibitInviteProgressDialog = false,
progressCallback?: (progress: IProgress) => void,
): Promise<string> {
const cli = room.client;
let spinnerModal: IHandle<any> | undefined;
@@ -79,7 +77,7 @@ export async function upgradeRoom(
) as Room[];
}
const progress: RoomUpgradeProgress = {
const progress: IProgress = {
roomUpgraded: false,
roomSynced: awaitRoom || inviteUsers ? false : undefined,
inviteUsersProgress: inviteUsers ? 0 : undefined,
@@ -114,12 +112,9 @@ export async function upgradeRoom(
if (toInvite.length > 0) {
// Errors are handled internally to this function
await inviteUsersToRoom(cli, newRoomId, toInvite, {
progressCallback: () => {
progress.inviteUsersProgress!++;
progressCallback?.(progress);
},
inhibitProgressDialog: inhibitInviteProgressDialog,
await inviteUsersToRoom(cli, newRoomId, toInvite, () => {
progress.inviteUsersProgress!++;
progressCallback?.(progress);
});
}
@@ -155,9 +150,9 @@ async function inviteUsersToRoom(
client: MatrixClient,
roomId: string,
userIds: string[],
inviteOptions: MultiInviterOptions,
progressCallback?: () => void,
): Promise<void> {
const result = await inviteMultipleToRoom(client, roomId, userIds, inviteOptions);
const result = await inviteMultipleToRoom(client, roomId, userIds, { progressCallback });
const room = client.getRoom(roomId)!;
showAnyInviteErrors(result.states, room, result.inviter);
}

View File

@@ -297,8 +297,8 @@ export class GroupedArray<K, T> {
}
}
export const concat = (...arrays: Uint8Array[]): Uint8Array => {
return arrays.reduce((concatenatedSoFar: Uint8Array, toBeConcatenated: Uint8Array) => {
export const concat = (...arrays: Uint8Array<ArrayBuffer>[]): Uint8Array<ArrayBuffer> => {
return arrays.reduce((concatenatedSoFar: Uint8Array<ArrayBuffer>, toBeConcatenated: Uint8Array<ArrayBuffer>) => {
const concatenated = new Uint8Array(concatenatedSoFar.length + toBeConcatenated.length);
concatenated.set(concatenatedSoFar, 0);
concatenated.set(toBeConcatenated, concatenatedSoFar.length);

View File

@@ -48,7 +48,7 @@ export interface EncryptedPickleKey {
* @param {string} deviceId The device ID which owns the pickle key.
* @return {Uint8Array} The additional data as a Uint8Array.
*/
export function getPickleAdditionalData(userId: string, deviceId: string): Uint8Array {
export function getPickleAdditionalData(userId: string, deviceId: string): Uint8Array<ArrayBuffer> {
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
for (let i = 0; i < userId.length; i++) {
additionalData[i] = userId.charCodeAt(i);
@@ -70,7 +70,7 @@ export function getPickleAdditionalData(userId: string, deviceId: string): Uint8
* @returns Data object ready for storing in indexeddb.
*/
export async function encryptPickleKey(
pickleKey: Uint8Array,
pickleKey: Uint8Array<ArrayBuffer>,
userId: string,
deviceId: string,
): Promise<EncryptedPickleKey | undefined> {

View File

@@ -39,7 +39,7 @@ export const HAS_REFRESH_TOKEN_STORAGE_KEY = "mx_has_refresh_token";
* @param pickleKey
* @returns AES key
*/
async function pickleKeyToAesKey(pickleKey: string): Promise<Uint8Array> {
async function pickleKeyToAesKey(pickleKey: string): Promise<Uint8Array<ArrayBuffer>> {
const pickleKeyBuffer = new Uint8Array(pickleKey.length);
for (let i = 0; i < pickleKey.length; i++) {
pickleKeyBuffer[i] = pickleKey.charCodeAt(i);

View File

@@ -77,9 +77,6 @@ export class AudioPlayerViewModel
public constructor(props: Props) {
super(props, AudioPlayerViewModel.computeSnapshot(props.playback, props.mediaName));
this.disposables.trackListener(props.playback, UPDATE_EVENT, this.setSnapshot);
// There is no unsubscribe method in SimpleObservable
this.props.playback.clockInfo.liveData.onUpdate(this.setSnapshot);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
@@ -100,6 +97,15 @@ export class AudioPlayerViewModel
}
}
protected addDownstreamSubscription(): void {
this.props.playback.on(UPDATE_EVENT, this.setSnapshot);
// There is no unsubscribe method in SimpleObservable
this.props.playback.clockInfo.liveData.onUpdate(this.setSnapshot);
}
protected removeDownstreamSubscription(): void {
this.props.playback.off(UPDATE_EVENT, this.setSnapshot);
}
/**
* Sets the snapshot and emits an update to subscribers.
*/

View File

@@ -6,7 +6,6 @@ Please see LICENSE files in the repository root for full details.
*/
import { type ViewModel } from "../../shared-components/ViewModel";
import { Disposables } from "./Disposables";
import { Snapshot } from "./Snapshot";
import { ViewModelSubscriptions } from "./ViewModelSubscriptions";
@@ -14,11 +13,13 @@ export abstract class BaseViewModel<T, P> implements ViewModel<T> {
protected subs: ViewModelSubscriptions;
protected snapshot: Snapshot<T>;
protected props: P;
protected disposables = new Disposables();
protected constructor(props: P, initialSnapshot: T) {
this.props = props;
this.subs = new ViewModelSubscriptions();
this.subs = new ViewModelSubscriptions(
this.addDownstreamSubscriptionWrapper,
this.removeDownstreamSubscriptionWrapper,
);
this.snapshot = new Snapshot(initialSnapshot, () => {
this.subs.emit();
});
@@ -28,24 +29,37 @@ export abstract class BaseViewModel<T, P> implements ViewModel<T> {
return this.subs.add(listener);
};
/**
* Wrapper around the abstract subscribe callback as we can't assume that the subclassed method
* has a bound `this` context.
*/
private addDownstreamSubscriptionWrapper = (): void => {
this.addDownstreamSubscription();
};
/**
* Wrapper around the abstract unsubscribe callback as we can't call pass an abstract method directly
* in the constructor.
*/
private removeDownstreamSubscriptionWrapper = (): void => {
this.removeDownstreamSubscription();
};
/**
* Called when the first listener subscribes: the subclass should set up any necessary subscriptions
* to call this.subs.emit() when the snapshot changes.
*/
protected abstract addDownstreamSubscription(): void;
/**
* Called when the last listener unsubscribes: the subclass should clean up any subscriptions.
*/
protected abstract removeDownstreamSubscription(): void;
/**
* Returns the current snapshot of the view model.
*/
public getSnapshot = (): T => {
return this.snapshot.current;
};
/**
* Relinquish any resources held by this view-model.
*/
public dispose(): void {
this.disposables.dispose();
}
/**
* Whether this view-model has been disposed.
*/
public get isDisposed(): boolean {
return this.disposables.isDisposed;
}
}

View File

@@ -1,70 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { EventEmitter } from "events";
/**
* Something that needs to be eventually disposed. This can be:
* - A function that does the disposing
* - An object containing a dispose method which does the disposing
*/
export type DisposableItem = { dispose: () => void } | (() => void);
/**
* This class provides a way for the view-model to track any resource
* that it needs to eventually relinquish.
*/
export class Disposables {
private readonly disposables: DisposableItem[] = [];
private _isDisposed: boolean = false;
/**
* Relinquish all tracked disposable values
*/
public dispose(): void {
if (this.isDisposed) return;
this._isDisposed = true;
for (const disposable of this.disposables) {
if (typeof disposable === "function") {
disposable();
} else {
disposable.dispose();
}
}
}
/**
* Track a value that needs to be eventually relinquished
*/
public track<T extends DisposableItem>(disposable: T): T {
this.throwIfDisposed();
this.disposables.push(disposable);
return disposable;
}
/**
* Add an event listener that will be removed on dispose
*/
public trackListener(emitter: EventEmitter, event: string, callback: (...args: unknown[]) => void): void {
this.throwIfDisposed();
emitter.on(event, callback);
this.track(() => {
emitter.off(event, callback);
});
}
private throwIfDisposed(): void {
if (this.isDisposed) throw new Error("Disposable is already disposed");
}
/**
* Whether this disposable has been disposed
*/
public get isDisposed(): boolean {
return this._isDisposed;
}
}

View File

@@ -6,11 +6,20 @@ Please see LICENSE files in the repository root for full details.
*/
/**
* Utility class for view models to manage subscriptions to their updates
* Utility class for view models to manage suscriptions to their updates
*/
export class ViewModelSubscriptions {
private listeners = new Set<() => void>();
/**
* @param subscribeCallback Called when the first listener subscribes.
* @param unsubscribeCallback Called when the last listener unsubscribes.
*/
public constructor(
private subscribeCallback: () => void,
private unsubscribeCallback: () => void,
) {}
/**
* Subscribe to changes in the view model.
* @param listener Will be called whenever the snapshot changes.
@@ -18,8 +27,15 @@ export class ViewModelSubscriptions {
*/
public add = (listener: () => void): (() => void) => {
this.listeners.add(listener);
if (this.listeners.size === 1) {
this.subscribeCallback();
}
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0) {
this.unsubscribeCallback();
}
};
};

View File

@@ -17,11 +17,18 @@ export class TextualEventViewModel extends BaseViewModel<TextualEventViewSnapsho
public constructor(props: EventTileTypeProps) {
super(props, { content: "" });
this.setTextFromEvent();
this.disposables.trackListener(this.props.mxEvent, MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
}
private setTextFromEvent = (): void => {
const content = textForEvent(this.props.mxEvent, MatrixClientPeg.safeGet(), true, this.props.showHiddenEvents);
this.snapshot.set({ content });
};
protected addDownstreamSubscription = (): void => {
this.props.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
};
protected removeDownstreamSubscription = (): void => {
this.props.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent);
};
}

View File

@@ -64,7 +64,8 @@ global.URL.revokeObjectURL = jest.fn();
// polyfilling TextEncoder as it is not available on JSDOM
// view https://github.com/facebook/jest/issues/9983
global.TextEncoder = TextEncoder;
// XXX: Node's implementation has marginally different types, so we fudge it
(globalThis as any).TextEncoder = TextEncoder;
// @ts-ignore
global.TextDecoder = TextDecoder;

View File

@@ -118,7 +118,7 @@ describe("VoiceMessageRecording", () => {
const encryptedFile = {} as unknown as EncryptedFile;
beforeEach(() => {
voiceRecording.onDataAvailable!(testBuf);
voiceRecording.onDataAvailable!(testBuf.buffer);
});
it("contentLength should return the buffer length", () => {

View File

@@ -209,7 +209,7 @@ describe("useRoomListHeaderViewModel", () => {
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
const fn = jest
.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace")
.mockImplementation(() => ({ spaceId: "home", rooms: [...rooms] }));
.mockImplementation(() => [...rooms]);
return { rooms, fn };
}

View File

@@ -30,7 +30,7 @@ describe("RoomListViewModel", () => {
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
const fn = jest
.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace")
.mockImplementation(() => ({ spaceId: "home", rooms: [...rooms] }));
.mockImplementation(() => [...rooms]);
return { rooms, fn };
}
@@ -42,9 +42,9 @@ describe("RoomListViewModel", () => {
const { rooms } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.roomsResult.rooms).toHaveLength(10);
expect(vm.current.rooms).toHaveLength(10);
for (const room of rooms) {
expect(vm.current.roomsResult.rooms).toContain(room);
expect(vm.current.rooms).toContain(room);
}
});
@@ -57,7 +57,7 @@ describe("RoomListViewModel", () => {
await act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
await waitFor(() => {
expect(vm.current.roomsResult.rooms).toContain(newRoom);
expect(vm.current.rooms).toContain(newRoom);
});
});
@@ -176,7 +176,7 @@ describe("RoomListViewModel", () => {
describe("Sticky room and active index", () => {
function expectActiveRoom(vm: ReturnType<typeof useRoomListViewModel>, i: number, roomId: string) {
expect(vm.activeIndex).toEqual(i);
expect(vm.roomsResult.rooms[i].roomId).toEqual(roomId);
expect(vm.rooms[i].roomId).toEqual(roomId);
}
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));
// Let's say all the rooms are in space1
const roomsInSpace1 = { spaceId: currentSpace, rooms: [...rooms] };
const roomsInSpace1 = [...rooms];
// Let's say all rooms with even index are in space 2
const roomsInSpace2 = { spaceId: "!space2:matrix.org", rooms: [...rooms].filter((_, i) => i % 2 === 0) };
const roomsInSpace2 = [...rooms].filter((_, i) => i % 2 === 0);
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockImplementation(() =>
currentSpace === "!space1:matrix.org" ? roomsInSpace1 : roomsInSpace2,
);

View File

@@ -137,7 +137,6 @@ describe("InviteDialog", () => {
supportsThreads: jest.fn().mockReturnValue(false),
isInitialSyncComplete: jest.fn().mockReturnValue(true),
getClientWellKnown: jest.fn().mockResolvedValue({}),
invite: jest.fn(),
});
SdkConfig.put({ validated_server_config: {} as ValidatedServerConfig } as IConfigOptions);
DMRoomMap.makeShared(mockClient);
@@ -407,18 +406,6 @@ describe("InviteDialog", () => {
expect(tile).toBeInTheDocument();
});
describe("while the invite is in progress", () => {
it("should show a spinner", async () => {
mockClient.invite.mockReturnValue(new Promise(() => {}));
render(<InviteDialog kind={InviteKind.Invite} roomId={roomId} onFinished={jest.fn()} />);
await enterIntoSearchField(bobId);
await userEvent.click(screen.getByRole("button", { name: "Invite" }));
await screen.findByText("Preparing invitations...");
});
});
describe("when inviting a user with an unknown profile", () => {
beforeEach(async () => {
render(<InviteDialog kind={InviteKind.Dm} onFinished={jest.fn()} />);

View File

@@ -1,18 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render } from "jest-matrix-react";
import InviteProgressBody from "../../../../../src/components/views/dialogs/InviteProgressBody.tsx";
describe("InviteProgressBody", () => {
it("should match snapshot", () => {
const { asFragment } = render(<InviteProgressBody />);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -1,23 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InviteProgressBody should match snapshot 1`] = `
<DocumentFragment>
<div
class="mx_InviteProgressBody"
>
<div
class="mx_InlineSpinner"
>
<div
aria-label="Loading…"
class="mx_InlineSpinner_icon mx_Spinner_icon"
style="width: 32px; height: 32px;"
/>
</div>
<h1>
Preparing invitations...
</h1>
Do not close the app until finished.
</div>
</DocumentFragment>
`;

View File

@@ -19,7 +19,7 @@ describe("<EmptyRoomList />", () => {
beforeEach(() => {
vm = {
isLoadingRooms: false,
roomsResult: { spaceId: "home", rooms: [] },
rooms: [],
primaryFilters: [],
createRoom: jest.fn(),
createChatRoom: jest.fn(),

View File

@@ -9,25 +9,28 @@ import React from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { render } from "jest-matrix-react";
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 { RoomList } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomList";
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { Landmark, LandmarkNavigation } from "../../../../../../src/accessibility/LandmarkNavigation";
import { mkRoom, stubClient } from "../../../../../test-utils";
describe("<RoomList />", () => {
let matrixClient: MatrixClient;
let vm: RoomListViewState;
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();
const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`));
vm = {
isLoadingRooms: false,
roomsResult: { spaceId: "home", rooms },
rooms,
primaryFilters: [],
createRoom: jest.fn(),
createChatRoom: jest.fn(),
@@ -41,18 +44,7 @@ describe("<RoomList />", () => {
});
it("should render a room list", () => {
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
const { asFragment } = render(<RoomList vm={vm} />, withClientContextRenderOptions(matrixClient));
expect(asFragment()).toMatchSnapshot();
});
@@ -61,15 +53,7 @@ describe("<RoomList />", () => {
{ shortcut: { key: "F6", ctrlKey: true }, isPreviousLandmark: false, label: "NextLandmark" },
])("should navigate to the landmark on NextLandmark.$label action", ({ shortcut, isPreviousLandmark }) => {
const spyFindLandmark = jest.spyOn(LandmarkNavigation, "findAndFocusNextLandmark").mockReturnValue();
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 { getByTestId } = render(<RoomList vm={vm} />, withClientContextRenderOptions(matrixClient));
const roomList = getByTestId("room-list");
fireEvent.keyDown(roomList, shortcut);

View File

@@ -28,20 +28,6 @@ describe("<RoomListItemView />", () => {
let defaultValue: RoomListItemViewState;
let matrixClient: MatrixClient;
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(() => {
matrixClient = stubClient();
room = mkRoom(matrixClient, "room1");
@@ -74,10 +60,7 @@ describe("<RoomListItemView />", () => {
test("should render a room item", () => {
const onClick = jest.fn();
const { asFragment } = renderRoomListItem({
onClick,
roomCount: 0,
});
const { asFragment } = render(<RoomListItemView room={room} onClick={onClick} isSelected={false} />);
expect(asFragment()).toMatchSnapshot();
});
@@ -85,17 +68,15 @@ describe("<RoomListItemView />", () => {
defaultValue.messagePreview = "The message looks like this";
const onClick = jest.fn();
const { asFragment } = renderRoomListItem({
onClick,
});
const { asFragment } = render(<RoomListItemView room={room} onClick={onClick} isSelected={false} />);
expect(asFragment()).toMatchSnapshot();
});
test("should call openRoom when clicked", async () => {
const user = userEvent.setup();
renderRoomListItem();
render(<RoomListItemView room={room} isSelected={false} />);
await user.click(screen.getByRole("option", { name: `Open room ${room.name}` }));
await user.click(screen.getByRole("button", { name: `Open room ${room.name}` }));
expect(defaultValue.openRoom).toHaveBeenCalled();
});
@@ -103,9 +84,8 @@ describe("<RoomListItemView />", () => {
mocked(useRoomListItemViewModel).mockReturnValue({ ...defaultValue, showHoverMenu: true });
const user = userEvent.setup();
renderRoomListItem();
const listItem = screen.getByRole("option", { name: `Open room ${room.name}` });
render(<RoomListItemView room={room} isSelected={false} />, withClientContextRenderOptions(matrixClient));
const listItem = screen.getByRole("button", { name: `Open room ${room.name}` });
expect(screen.queryByRole("button", { name: "More Options" })).toBeNull();
await user.hover(listItem);
@@ -113,33 +93,19 @@ describe("<RoomListItemView />", () => {
});
test("should hover decoration if focused", async () => {
const { rerender } = renderRoomListItem({
isFocused: true,
});
const user = userEvent.setup();
render(<RoomListItemView room={room} isSelected={false} />, withClientContextRenderOptions(matrixClient));
const listItem = screen.getByRole("button", { name: `Open room ${room.name}` });
await user.click(listItem);
expect(listItem).toHaveClass("mx_RoomListItemView_hover");
const listItem = screen.getByRole("option", { name: `Open room ${room.name}` });
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"));
await user.tab();
await waitFor(() => expect(listItem).not.toHaveClass("mx_RoomListItemView_hover"));
});
test("should be selected if isSelected=true", async () => {
const { asFragment } = renderRoomListItem({
isSelected: true,
});
expect(screen.queryByRole("option", { name: `Open room ${room.name}` })).toHaveAttribute(
const { asFragment } = render(<RoomListItemView room={room} isSelected={true} />);
expect(screen.queryByRole("button", { name: `Open room ${room.name}` })).toHaveAttribute(
"aria-selected",
"true",
);
@@ -152,8 +118,7 @@ describe("<RoomListItemView />", () => {
showNotificationDecoration: true,
});
const { asFragment } = renderRoomListItem();
const { asFragment } = render(<RoomListItemView room={room} isSelected={false} />);
expect(screen.getByTestId("notification-decoration")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
@@ -166,9 +131,8 @@ describe("<RoomListItemView />", () => {
showNotificationDecoration: true,
});
renderRoomListItem();
const listItem = screen.getByRole("option", { name: `Open room ${room.name}` });
render(<RoomListItemView room={room} isSelected={false} />);
const listItem = screen.getByRole("button", { name: `Open room ${room.name}` });
await user.hover(listItem);
expect(screen.queryByRole("notification-decoration")).toBeNull();
@@ -182,9 +146,8 @@ describe("<RoomListItemView />", () => {
showContextMenu: true,
});
renderRoomListItem();
const button = screen.getByRole("option", { name: `Open room ${room.name}` });
render(<RoomListItemView room={room} isSelected={false} />, withClientContextRenderOptions(matrixClient));
const button = screen.getByRole("button", { name: `Open room ${room.name}` });
await user.pointer([{ target: button }, { keys: "[MouseRight]", target: button }]);
await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument());
// Menu should close

View File

@@ -23,7 +23,7 @@ jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListViewMode
describe("<RoomListView />", () => {
const defaultValue: RoomListViewState = {
isLoadingRooms: false,
roomsResult: { spaceId: "home", rooms: [] },
rooms: [],
primaryFilters: [],
createRoom: jest.fn(),
createChatRoom: jest.fn(),
@@ -56,10 +56,10 @@ describe("<RoomListView />", () => {
it("should render a room list", () => {
mocked(useRoomListViewModel).mockReturnValue({
...defaultValue,
roomsResult: { spaceId: "home", rooms: [mkRoom(matrixClient, "testing room")] },
rooms: [mkRoom(matrixClient, "testing room")],
});
render(<RoomListView />);
expect(screen.getByRole("listbox", { name: "Room list" })).toBeInTheDocument();
expect(screen.getByRole("grid", { name: "Room list" })).toBeInTheDocument();
});
});

View File

@@ -3,556 +3,531 @@
exports[`<RoomList /> should render a room list 1`] = `
<DocumentFragment>
<div
aria-label="Room list"
context="[object Object]"
class="mx_RoomList"
data-testid="room-list"
data-virtuoso-scroller="true"
role="listbox"
style="height: 100%; outline: none; overflow-y: auto; position: relative;"
>
<div
data-viewport-type="element"
style="height: 100%; position: absolute; top: 0px; width: 100%;"
style="overflow: visible; height: 0px; width: 0px;"
>
<div
data-testid="virtuoso-item-list"
style="box-sizing: border-box; margin-top: 0px; padding-bottom: 0px; padding-top: 0px;"
aria-label="Room list"
aria-readonly="true"
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
data-index="0"
data-item-index="0"
data-known-size="48"
class="ReactVirtualized__Grid__innerScrollContainer"
role="row"
style="width: auto; height: 480px; max-width: 1500px; max-height: 480px; overflow: hidden; position: relative;"
>
<button
aria-haspopup="menu"
aria-label="Open room room0"
aria-posinset="1"
aria-selected="false"
aria-setsize="10"
class="flex mx_RoomListItemView"
class="mx_RoomListItemView"
data-state="closed"
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;"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 0px; width: 100%;"
tabindex="0"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="2"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="2"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room0"
class="mx_RoomListItemView_text"
>
room0
<div
class="mx_RoomListItemView_roomName"
title="room0"
>
room0
</div>
</div>
</div>
</div>
</button>
</div>
<div
data-index="1"
data-item-index="1"
data-known-size="48"
>
<button
aria-haspopup="menu"
aria-label="Open room room1"
aria-posinset="2"
aria-selected="false"
aria-setsize="10"
class="flex mx_RoomListItemView"
class="mx_RoomListItemView"
data-state="closed"
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;"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 48px; width: 100%;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room1"
class="mx_RoomListItemView_text"
>
room1
<div
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
</div>
</div>
</div>
</button>
</div>
<div
data-index="2"
data-item-index="2"
data-known-size="48"
>
<button
aria-haspopup="menu"
aria-label="Open room room2"
aria-posinset="3"
aria-selected="false"
aria-setsize="10"
class="flex mx_RoomListItemView"
class="mx_RoomListItemView"
data-state="closed"
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;"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 96px; width: 100%;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="4"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="4"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room2"
class="mx_RoomListItemView_text"
>
room2
<div
class="mx_RoomListItemView_roomName"
title="room2"
>
room2
</div>
</div>
</div>
</div>
</button>
</div>
<div
data-index="3"
data-item-index="3"
data-known-size="48"
>
<button
aria-haspopup="menu"
aria-label="Open room room3"
aria-posinset="4"
aria-selected="false"
aria-setsize="10"
class="flex mx_RoomListItemView"
class="mx_RoomListItemView"
data-state="closed"
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;"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 144px; width: 100%;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="5"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="5"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room3"
class="mx_RoomListItemView_text"
>
room3
<div
class="mx_RoomListItemView_roomName"
title="room3"
>
room3
</div>
</div>
</div>
</div>
</button>
</div>
<div
data-index="4"
data-item-index="4"
data-known-size="48"
>
<button
aria-haspopup="menu"
aria-label="Open room room4"
aria-posinset="5"
aria-selected="false"
aria-setsize="10"
class="flex mx_RoomListItemView"
class="mx_RoomListItemView"
data-state="closed"
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;"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 192px; width: 100%;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="6"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="6"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room4"
class="mx_RoomListItemView_text"
>
room4
<div
class="mx_RoomListItemView_roomName"
title="room4"
>
room4
</div>
</div>
</div>
</div>
</button>
</div>
<div
data-index="5"
data-item-index="5"
data-known-size="48"
>
<button
aria-haspopup="menu"
aria-label="Open room room5"
aria-posinset="6"
aria-selected="false"
aria-setsize="10"
class="flex mx_RoomListItemView"
class="mx_RoomListItemView"
data-state="closed"
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;"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 240px; width: 100%;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="1"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="1"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room5"
class="mx_RoomListItemView_text"
>
room5
<div
class="mx_RoomListItemView_roomName"
title="room5"
>
room5
</div>
</div>
</div>
</div>
</button>
</div>
<div
data-index="6"
data-item-index="6"
data-known-size="48"
>
<button
aria-haspopup="menu"
aria-label="Open room room6"
aria-posinset="7"
aria-selected="false"
aria-setsize="10"
class="flex mx_RoomListItemView"
class="mx_RoomListItemView"
data-state="closed"
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;"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 288px; width: 100%;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="2"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="2"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room6"
class="mx_RoomListItemView_text"
>
room6
<div
class="mx_RoomListItemView_roomName"
title="room6"
>
room6
</div>
</div>
</div>
</div>
</button>
</div>
<div
data-index="7"
data-item-index="7"
data-known-size="48"
>
<button
aria-haspopup="menu"
aria-label="Open room room7"
aria-posinset="8"
aria-selected="false"
aria-setsize="10"
class="flex mx_RoomListItemView"
class="mx_RoomListItemView"
data-state="closed"
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;"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 336px; width: 100%;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room7"
class="mx_RoomListItemView_text"
>
room7
<div
class="mx_RoomListItemView_roomName"
title="room7"
>
room7
</div>
</div>
</div>
</div>
</button>
</div>
<div
data-index="8"
data-item-index="8"
data-known-size="48"
>
<button
aria-haspopup="menu"
aria-label="Open room room8"
aria-posinset="9"
aria-selected="false"
aria-setsize="10"
class="flex mx_RoomListItemView"
class="mx_RoomListItemView"
data-state="closed"
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;"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 384px; width: 100%;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="4"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="4"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room8"
class="mx_RoomListItemView_text"
>
room8
<div
class="mx_RoomListItemView_roomName"
title="room8"
>
room8
</div>
</div>
</div>
</div>
</button>
</div>
<div
data-index="9"
data-item-index="9"
data-known-size="48"
>
<button
aria-haspopup="menu"
aria-label="Open room room9"
aria-posinset="10"
aria-selected="false"
aria-setsize="10"
class="flex mx_RoomListItemView"
class="mx_RoomListItemView"
data-state="closed"
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;"
role="gridcell"
style="height: 48px; left: 0px; position: absolute; top: 432px; width: 100%;"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="5"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="5"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room9"
class="mx_RoomListItemView_text"
>
room9
<div
class="mx_RoomListItemView_roomName"
title="room9"
>
room9
</div>
</div>
</div>
</div>
@@ -560,6 +535,20 @@ exports[`<RoomList /> should render a room list 1`] = `
</div>
</div>
</div>
<div
class="resize-triggers"
>
<div
class="expand-trigger"
>
<div
style="width: 1501px; height: 1501px;"
/>
</div>
<div
class="contract-trigger"
/>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -38,43 +38,41 @@ exports[`<RoomListItemMenuView /> should render the more options menu 1`] = `
</svg>
</div>
</button>
<div>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Notification options"
aria-labelledby="«r9»"
class="_icon-button_1pz9o_8"
data-kind="primary"
data-state="closed"
id="radix-«r7»"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Notification options"
aria-labelledby="«r9»"
class="_icon-button_1pz9o_8"
data-kind="primary"
data-state="closed"
id="radix-«r7»"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
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>
<path
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>
</DocumentFragment>
`;
@@ -117,43 +115,41 @@ exports[`<RoomListItemMenuView /> should render the notification options menu 1`
</svg>
</div>
</button>
<div>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Notification options"
aria-labelledby="«rp»"
class="_icon-button_1pz9o_8"
data-kind="primary"
data-state="closed"
id="radix-«rn»"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Notification options"
aria-labelledby="«rp»"
class="_icon-button_1pz9o_8"
data-kind="primary"
data-state="closed"
id="radix-«rn»"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
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>
<path
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>
</DocumentFragment>
`;

View File

@@ -4,46 +4,47 @@ exports[`<RoomListItemView /> should be selected if isSelected=true 1`] = `
<DocumentFragment>
<button
aria-label="Open room room1"
aria-posinset="1"
aria-selected="true"
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;"
class="mx_RoomListItemView mx_RoomListItemView_selected"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room1"
class="mx_RoomListItemView_text"
>
room1
<div
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
</div>
</div>
</div>
@@ -55,59 +56,60 @@ exports[`<RoomListItemView /> should display notification decoration 1`] = `
<DocumentFragment>
<button
aria-label="Open room room1"
aria-posinset="1"
aria-selected="false"
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;"
class="mx_RoomListItemView"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room1"
class="mx_RoomListItemView_text"
>
room1
<div
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
</div>
</div>
<div
aria-hidden="true"
class="flex"
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"
<div
aria-hidden="true"
class="flex"
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;"
>
1
</span>
<span
class="_unread-counter_9mg0k_8"
>
1
</span>
</div>
</div>
</div>
</button>
@@ -118,46 +120,47 @@ exports[`<RoomListItemView /> should render a room item 1`] = `
<DocumentFragment>
<button
aria-label="Open room room1"
aria-posinset="1"
aria-selected="false"
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;"
class="mx_RoomListItemView"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room1"
class="mx_RoomListItemView_text"
>
room1
<div
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
</div>
</div>
</div>
@@ -169,52 +172,53 @@ exports[`<RoomListItemView /> should render a room item with a message preview 1
<DocumentFragment>
<button
aria-label="Open room room1"
aria-posinset="1"
aria-selected="false"
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;"
class="mx_RoomListItemView"
tabindex="-1"
type="button"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="flex mx_RoomListItemView_content"
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;"
class="flex mx_RoomListItemView_container"
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;"
>
<span
aria-label="Avatar"
class="_avatar_1qbcf_8 mx_BaseAvatar"
data-color="3"
data-testid="avatar-img"
data-type="round"
style="--cpd-avatar-size: 32px;"
>
<img
alt=""
class="_image_1qbcf_41"
data-type="round"
height="32px"
loading="lazy"
referrerpolicy="no-referrer"
src="http://this.is.a.url/avatar.url/room.png"
width="32px"
/>
</span>
<div
class="mx_RoomListItemView_text"
class="flex mx_RoomListItemView_content"
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
class="mx_RoomListItemView_roomName"
title="room1"
class="mx_RoomListItemView_text"
>
room1
</div>
<div
class="mx_RoomListItemView_messagePreview"
title="The message looks like this"
>
The message looks like this
<div
class="mx_RoomListItemView_roomName"
title="room1"
>
room1
</div>
<div
class="mx_RoomListItemView_messagePreview"
title="The message looks like this"
>
The message looks like this
</div>
</div>
</div>
</div>

View File

@@ -11,6 +11,7 @@ import { act, fireEvent, screen, waitFor } from "jest-matrix-react";
import { RoomMember, User, RoomEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked } from "jest-mock";
import { type JSX } from "react";
import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents";
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
@@ -20,6 +21,14 @@ jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({
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, "offsetWidth", "get").mockReturnValue(1500);

View File

@@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details.
import { act } from "react";
import { waitFor, fireEvent } from "jest-matrix-react";
import { type Room, type RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { type JSX } from "react";
import { filterConsole } from "../../../../../test-utils";
import { type Rendered, renderMemberList } from "./common";
@@ -18,6 +19,14 @@ jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({
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, "offsetWidth", "get").mockReturnValue(1500);

View File

@@ -125,7 +125,7 @@ export async function renderMemberList(
{
wrapper: ({ children }) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 56 }}>
<>{children}</>
{children}
</VirtuosoMockContext.Provider>
),
},

View File

@@ -48,7 +48,7 @@ describe("ListView", () => {
return render(getListViewComponent(mergedProps), {
wrapper: ({ children }) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 400, itemHeight: 56 }}>
<>{children}</>
{children}
</VirtuosoMockContext.Provider>
),
});

View File

@@ -460,7 +460,7 @@ describe("RoomListStoreV3", () => {
store.on(LISTS_UPDATE_EVENT, fn);
// The rooms which belong to the space should not be shown
const result = store.getSortedRoomsInActiveSpace().rooms.map((r) => r.roomId);
const result = store.getSortedRoomsInActiveSpace().map((r) => r.roomId);
for (const id of roomIds) {
expect(result).not.toContain(id);
}
@@ -469,7 +469,7 @@ describe("RoomListStoreV3", () => {
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => spaceRoom.roomId);
SpaceStore.instance.emit(UPDATE_SELECTED_SPACE);
expect(fn).toHaveBeenCalled();
const result2 = store.getSortedRoomsInActiveSpace().rooms.map((r) => r.roomId);
const result2 = store.getSortedRoomsInActiveSpace().map((r) => r.roomId);
for (const id of roomIds) {
expect(result2).toContain(id);
}
@@ -492,7 +492,7 @@ describe("RoomListStoreV3", () => {
await store.start();
// Sorted, filtered rooms should be 8, 27 and 75
const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]).rooms;
const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]);
expect(result).toHaveLength(3);
for (const i of [8, 27, 75]) {
expect(result).toContain(rooms[i]);
@@ -527,7 +527,7 @@ describe("RoomListStoreV3", () => {
expect(fn).toHaveBeenCalled();
// Sorted, filtered rooms should be 27 and 75
const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]).rooms;
const result = store.getSortedRoomsInActiveSpace([FilterKey.FavouriteFilter]);
expect(result).toHaveLength(2);
for (const i of [8, 75]) {
expect(result).toContain(rooms[i]);
@@ -552,7 +552,7 @@ describe("RoomListStoreV3", () => {
await store.start();
// Should only give us rooms at index 8 and 27
const result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms;
const result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]);
expect(result).toHaveLength(2);
for (const i of [8, 27]) {
expect(result).toContain(rooms[i]);
@@ -569,7 +569,7 @@ describe("RoomListStoreV3", () => {
await store.start();
// Since there's no unread yet, we expect zero results
let result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms;
let result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]);
expect(result).toHaveLength(0);
// 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
result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]).rooms;
result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter]);
expect(result).toHaveLength(1);
expect(result).toContain(rooms[8]);
});
@@ -607,14 +607,14 @@ describe("RoomListStoreV3", () => {
await store.start();
// Should only give us rooms at index 8 and 27
const peopleRooms = store.getSortedRoomsInActiveSpace([FilterKey.PeopleFilter]).rooms;
const peopleRooms = store.getSortedRoomsInActiveSpace([FilterKey.PeopleFilter]);
expect(peopleRooms).toHaveLength(2);
for (const i of [8, 27]) {
expect(peopleRooms).toContain(rooms[i]);
}
// Rest are normal rooms
const nonDms = store.getSortedRoomsInActiveSpace([FilterKey.RoomsFilter]).rooms;
const nonDms = store.getSortedRoomsInActiveSpace([FilterKey.RoomsFilter]);
expect(nonDms).toHaveLength(3);
for (const i of [6, 13, 75]) {
expect(nonDms).toContain(rooms[i]);
@@ -638,7 +638,7 @@ describe("RoomListStoreV3", () => {
const store = new RoomListStoreV3Class(dispatcher);
await store.start();
const result = store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]).rooms;
const result = store.getSortedRoomsInActiveSpace([FilterKey.InvitesFilter]);
expect(result).toHaveLength(5);
for (const room of invitedRooms) {
expect(result).toContain(room);
@@ -663,7 +663,7 @@ describe("RoomListStoreV3", () => {
await store.start();
// Should only give us rooms at index 8 and 27
const result = store.getSortedRoomsInActiveSpace([FilterKey.MentionsFilter]).rooms;
const result = store.getSortedRoomsInActiveSpace([FilterKey.MentionsFilter]);
expect(result).toHaveLength(2);
for (const i of [8, 27]) {
expect(result).toContain(rooms[i]);
@@ -685,7 +685,7 @@ describe("RoomListStoreV3", () => {
await store.start();
// Sorted, filtered rooms should be 8, 27 and 75
const result = store.getSortedRoomsInActiveSpace([FilterKey.LowPriorityFilter]).rooms;
const result = store.getSortedRoomsInActiveSpace([FilterKey.LowPriorityFilter]);
expect(result).toHaveLength(3);
for (const i of [8, 27, 75]) {
expect(result).toContain(rooms[i]);
@@ -713,10 +713,7 @@ describe("RoomListStoreV3", () => {
await store.start();
// 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,
]).rooms;
const result = store.getSortedRoomsInActiveSpace([FilterKey.UnreadFilter, FilterKey.FavouriteFilter]);
expect(result).toHaveLength(1);
expect(result).toContain(rooms[8]);
});

View File

@@ -59,8 +59,8 @@ const TEST_VECTORS = [
],
];
function stringToArray(s: string): ArrayBufferLike {
return new TextEncoder().encode(s).buffer;
function stringToArray(s: string): ArrayBuffer {
return new TextEncoder().encode(s).buffer as ArrayBuffer;
}
describe("MegolmExportEncryption", function () {

View File

@@ -15,7 +15,7 @@ import Modal, { type ComponentType, type ComponentProps } from "../../../src/Mod
import SettingsStore from "../../../src/settings/SettingsStore";
import MultiInviter, { type CompletionStates } from "../../../src/utils/MultiInviter";
import * as TestUtilsMatrix from "../../test-utils";
import AskInviteAnywayDialog from "../../../src/components/views/dialogs/AskInviteAnywayDialog";
import type AskInviteAnywayDialog from "../../../src/components/views/dialogs/AskInviteAnywayDialog";
import ConfirmUserActionDialog from "../../../src/components/views/dialogs/ConfirmUserActionDialog";
const ROOMID = "!room:server";
@@ -24,14 +24,10 @@ const MXID1 = "@user1:server";
const MXID2 = "@user2:server";
const MXID3 = "@user3:server";
const MXID_PROFILE_STATES: Record<string, () => {}> = {
[MXID1]: () => ({}),
[MXID2]: () => {
throw new MatrixError({ errcode: "M_FORBIDDEN" });
},
[MXID3]: () => {
throw new MatrixError({ errcode: "M_NOT_FOUND" });
},
const MXID_PROFILE_STATES: Record<string, Promise<any>> = {
[MXID1]: Promise.resolve({}),
[MXID2]: Promise.reject(new MatrixError({ errcode: "M_FORBIDDEN" })),
[MXID3]: Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })),
};
jest.mock("../../../src/Modal", () => ({
@@ -55,12 +51,11 @@ const mockPromptBeforeInviteUnknownUsers = (value: boolean) => {
};
const mockCreateTrackedDialog = (callbackName: "onInviteAnyways" | "onGiveUp") => {
mocked(Modal.createDialog).mockImplementation((Element: ComponentType, props?: ComponentProps<ComponentType>) => {
if (Element === AskInviteAnywayDialog) {
mocked(Modal.createDialog).mockImplementation(
(Element: ComponentType, props?: ComponentProps<ComponentType>): any => {
(props as ComponentProps<typeof AskInviteAnywayDialog>)[callbackName]();
}
return { close: jest.fn(), finished: new Promise(() => {}) };
});
},
);
};
const expectAllInvitedResult = (result: CompletionStates) => {
@@ -77,7 +72,6 @@ describe("MultiInviter", () => {
beforeEach(() => {
jest.resetAllMocks();
mocked(Modal.createDialog).mockReturnValue({ close: jest.fn(), finished: new Promise(() => {}) });
TestUtilsMatrix.stubClient();
client = MatrixClientPeg.safeGet() as jest.Mocked<MatrixClient>;
@@ -86,10 +80,8 @@ describe("MultiInviter", () => {
client.invite.mockResolvedValue({});
client.getProfileInfo = jest.fn();
client.getProfileInfo.mockImplementation(async (userId: string) => {
const m = MXID_PROFILE_STATES[userId];
if (m) return m();
throw new Error();
client.getProfileInfo.mockImplementation((userId: string) => {
return MXID_PROFILE_STATES[userId] || Promise.reject();
});
client.unban = jest.fn();
@@ -97,22 +89,6 @@ describe("MultiInviter", () => {
});
describe("invite", () => {
it("should show a progress dialog while the invite happens", async () => {
const mockModalHandle = { close: jest.fn(), finished: new Promise<[]>(() => {}) };
mocked(Modal.createDialog).mockReturnValue(mockModalHandle);
const invitePromise = Promise.withResolvers<{}>();
client.invite.mockReturnValue(invitePromise.promise);
const resultPromise = inviter.invite([MXID1]);
expect(Modal.createDialog).toHaveBeenCalledTimes(1);
expect(mockModalHandle.close).not.toHaveBeenCalled();
invitePromise.resolve({});
await resultPromise;
expect(mockModalHandle.close).toHaveBeenCalled();
});
describe("with promptBeforeInviteUnknownUsers = false", () => {
beforeEach(() => mockPromptBeforeInviteUnknownUsers(false));

View File

@@ -1,57 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { EventEmitter } from "events";
import { Disposables } from "../../../src/viewmodels/base/Disposables";
describe("Disposable", () => {
it("isDisposed is true after dispose() is called", () => {
const disposables = new Disposables();
expect(disposables.isDisposed).toEqual(false);
disposables.dispose();
expect(disposables.isDisposed).toEqual(true);
});
it("dispose() calls the correct disposing function", () => {
const disposables = new Disposables();
const item1 = {
foo: 5,
dispose: jest.fn(),
};
disposables.track(item1);
const item2 = jest.fn();
disposables.track(item2);
disposables.dispose();
expect(item1.dispose).toHaveBeenCalledTimes(1);
expect(item2).toHaveBeenCalledTimes(1);
});
it("Throws error if acting on already disposed disposables", () => {
const disposables = new Disposables();
disposables.dispose();
expect(() => {
disposables.track(jest.fn);
}).toThrow();
});
it("Removes tracked event listeners on dispose", () => {
const disposables = new Disposables();
const emitter = new EventEmitter();
const fn = jest.fn();
disposables.trackListener(emitter, "FooEvent", fn);
emitter.emit("FooEvent");
expect(fn).toHaveBeenCalled();
disposables.dispose();
expect(emitter.listenerCount("FooEvent", fn)).toEqual(0);
});
});

View File

@@ -1208,7 +1208,7 @@
"@babel/plugin-transform-modules-commonjs" "^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.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.7.2", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.3.tgz#75c5034b55ba868121668be5d5bb31cc64e6e61a"
integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==
@@ -4261,6 +4261,11 @@
resolved "https://registry.yarnpkg.com/@types/png-chunks-extract/-/png-chunks-extract-1.0.2.tgz#31dd8d74d6ba879ace317c1e042dcdabc6300d6e"
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":
version "1.5.5"
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac"
@@ -4305,6 +4310,14 @@
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044"
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":
version "19.1.10"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.10.tgz#a05015952ef328e1b85579c839a71304b07d21d9"
@@ -6193,6 +6206,11 @@ clone-deep@^4.0.1:
kind-of "^6.0.2"
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:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -7157,7 +7175,7 @@ dom-converter@^0.2.0:
dependencies:
utila "~0.4"
dom-helpers@^5.0.1:
dom-helpers@^5.0.1, dom-helpers@^5.1.3:
version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==
@@ -13170,6 +13188,11 @@ react-is@^17.0.2:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
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:
version "2.0.2"
resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.2.tgz#d5ac9e244cef564880a610bc8d868bd6f60fdda6"
@@ -13230,7 +13253,19 @@ react-transition-group@^4.4.1:
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-virtuoso@^4.14.0:
react-virtualized@^9.22.5:
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"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.14.0.tgz#6998631cb0a86efc2b15e551f55e7199a0f25c7a"
integrity sha512-fR+eiCvirSNIRvvCD7ueJPRsacGQvUbjkwgWzBZXVq+yWypoH7mRUvWJzGHIdoRaCZCT+6mMMMwIG2S1BW3uwA==
@@ -15128,10 +15163,10 @@ typedarray-to-buffer@^3.1.5:
dependencies:
is-typedarray "^1.0.0"
typescript@5.8.3:
version "5.8.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e"
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
typescript@5.9.2:
version "5.9.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6"
integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==
ua-parser-js@^1.0.2:
version "1.0.40"