From c3e5367e45c27a433c1b3aa26e6609335886ae33 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Wed, 30 Jul 2025 14:57:09 +0100 Subject: [PATCH 01/18] Fix downloaded attachments not being decrypted (#30433) (#30434) * Fix downloaded attachments not being decrypted Fixes https://github.com/element-hq/element-web/issues/30339 * Import order (cherry picked from commit 1e15a322a53b135c909b9f8e9c26b9497dc0f0a9) Co-authored-by: David Baker --- src/hooks/useDownloadMedia.ts | 18 ++++++---- .../views/elements/ImageView-test.tsx | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/hooks/useDownloadMedia.ts b/src/hooks/useDownloadMedia.ts index 74328ac7ca..eb0af954e0 100644 --- a/src/hooks/useDownloadMedia.ts +++ b/src/hooks/useDownloadMedia.ts @@ -59,15 +59,19 @@ export function useDownloadMedia(url: string, fileName?: string, mxEvent?: Matri return downloadBlob(blobRef.current); } - const res = await fetch(url); - if (!res.ok) { - throw parseErrorResponse(res, await res.text()); + // We must download via the mediaEventHelper if given as the file may need decryption. + if (mediaEventHelper) { + blobRef.current = await mediaEventHelper.sourceBlob.value; + } else { + const res = await fetch(url); + if (!res.ok) { + throw parseErrorResponse(res, await res.text()); + } + + blobRef.current = await res.blob(); } - const blob = await res.blob(); - blobRef.current = blob; - - await downloadBlob(blob); + await downloadBlob(blobRef.current); } catch (e) { showError(e); } diff --git a/test/unit-tests/components/views/elements/ImageView-test.tsx b/test/unit-tests/components/views/elements/ImageView-test.tsx index b0e9338f69..6537a3948a 100644 --- a/test/unit-tests/components/views/elements/ImageView-test.tsx +++ b/test/unit-tests/components/views/elements/ImageView-test.tsx @@ -10,11 +10,13 @@ import React from "react"; import { mocked } from "jest-mock"; import { render, fireEvent, waitFor } from "jest-matrix-react"; import fetchMock from "fetch-mock-jest"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import ImageView from "../../../../../src/components/views/elements/ImageView"; import { FileDownloader } from "../../../../../src/utils/FileDownloader"; import Modal from "../../../../../src/Modal"; import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog"; +import { stubClient } from "../../../../test-utils"; jest.mock("../../../../../src/utils/FileDownloader"); @@ -44,6 +46,39 @@ describe("", () => { expect(fetchMock).toHaveFetched("https://example.com/image.png"); }); + it("should use event as download source if given", async () => { + stubClient(); + + const event = new MatrixEvent({ + event_id: "$eventId", + type: "m.image", + content: { + body: "fromEvent.png", + url: "mxc://test.dummy/fromEvent.png", + file_name: "filename.png", + }, + origin_server_ts: new Date(2000, 0, 1, 0, 0, 0, 0).getTime(), + }); + + fetchMock.get("http://this.is.a.url/test.dummy/fromEvent.png", "TESTFILE"); + const { getByRole } = render( + , + ); + fireEvent.click(getByRole("button", { name: "Download" })); + await waitFor(() => + expect(mocked(FileDownloader).mock.instances[0].download).toHaveBeenCalledWith({ + blob: expect.anything(), + name: "fromEvent.png", + }), + ); + expect(fetchMock).toHaveFetched("http://this.is.a.url/test.dummy/fromEvent.png"); + }); + it("should start download on Ctrl+S", async () => { fetchMock.get("https://example.com/image.png", "TESTFILE"); From d98533025a6ba7f1cb1aa0d487e43c7e77f59366 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Wed, 30 Jul 2025 14:22:51 +0000 Subject: [PATCH 02/18] v1.11.108 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e578d777f9..36dea524a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +Changes in [1.11.108](https://github.com/element-hq/element-web/releases/tag/v1.11.108) (2025-07-30) +==================================================================================================== +## šŸ› Bug Fixes + +* [Backport staging] Fix downloaded attachments not being decrypted ([#30434](https://github.com/element-hq/element-web/pull/30434)). Contributed by @RiotRobot. + + Changes in [1.11.107](https://github.com/element-hq/element-web/releases/tag/v1.11.107) (2025-07-29) ==================================================================================================== ## ✨ Features diff --git a/package.json b/package.json index 2361007e77..120631e453 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.11.107", + "version": "1.11.108", "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { From 7eb5a29cf0bc070a890799ccaf34556d565cf5eb Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Wed, 30 Jul 2025 21:50:19 +0100 Subject: [PATCH 03/18] Hacky fix to the MatrixChat flakiness (#30429) Add a sleep to let these tests clean up. --- .../components/structures/MatrixChat-test.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index 6d8dcec1a7..b33e1b9d45 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -270,11 +270,17 @@ describe("", () => { // (must be sync otherwise the next test will start before it happens) act(() => defaultDispatcher.dispatch({ action: Action.OnLoggedOut }, true)); - // that will cause the Login to kick off an update in the background, which we need to allow to finish within - // an `act` to avoid warnings - await flushPromises(); - localStorage.clear(); + + // This is a massive hack, but ... + // + // A lot of these tests end up completing while the login flow is still proceeding. So then, we start the next + // test while stuff is still ongoing from the previous test, which messes up the current test (by changing + // localStorage or opening modals, or whatever). + // + // There is no obvious event we could wait for which indicates that everything has completed, since each test + // does something different. Instead... + await act(() => sleep(200)); }); resetJsDomAfterEach(); From 652e8916635b7724c44703936da6dcc418307d00 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 31 Jul 2025 00:41:30 +0200 Subject: [PATCH 04/18] Stop using deprecated Element Call URL parameters (#30422) These deprecated parameters will be removed very soon (planned for Element Call version 0.15.0) and we no longer have to care about backward compatibility with old versions of Element Call (due to the embedding/bundling work), so now is the right time to migrate. --- src/models/Call.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 70c7e4779f..2e1f0f1f10 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -669,12 +669,12 @@ export class ElementCall extends Call { // Splice together the Element Call URL for this call const params = new URLSearchParams({ - embed: "true", // We're embedding EC within another application + confineToRoom: "true", // Only show the call interface for the configured room // Template variables are used, so that this can be configured using the widget data. skipLobby: "$skipLobby", // Skip the lobby in case we show a lobby component of our own. returnToLobby: "$returnToLobby", // Returns to the lobby (instead of blank screen) when the call ends. (For video rooms) perParticipantE2EE: "$perParticipantE2EE", - hideHeader: "true", // Hide the header since our room header is enough + header: "none", // Hide the header since our room header is enough userId: client.getUserId()!, deviceId: client.getDeviceId()!, roomId: roomId, From 3f0dcaa64c9812047be1214f98e863de76ad5337 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Thu, 31 Jul 2025 11:23:44 +0100 Subject: [PATCH 05/18] Playwright Docker image updates (#30406) * [create-pull-request] automated change * [create-pull-request] automated change * Bump playwright-common Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com> Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 2 +- playwright/testcontainers/synapse.ts | 2 +- yarn.lock | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index f0d4a144ca..bbb0b176a1 100644 --- a/package.json +++ b/package.json @@ -186,7 +186,7 @@ "@babel/runtime": "^7.12.5", "@casualbot/jest-sonar-reporter": "2.2.7", "@element-hq/element-call-embedded": "0.13.1", - "@element-hq/element-web-playwright-common": "^1.4.3", + "@element-hq/element-web-playwright-common": "^1.4.4", "@peculiar/webcrypto": "^1.4.3", "@playwright/test": "^1.50.1", "@principalstudio/html-webpack-inject-preload": "^1.2.7", diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 7af8ae47f1..1ea07a13b7 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -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:8c2d9a93dd209a79d3e5e50cd18addfe52d80bea0ffe48a5d3e15836032eeb9d"; +const TAG = "develop@sha256:29b212891be5f0e2c3cdbbf9274750e842f0aa747e61c3360b68dac237428014"; /** * SynapseContainer which freezes the docker digest to stabilise tests, diff --git a/yarn.lock b/yarn.lock index 3ac17bbbe1..f59810b66f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1687,10 +1687,10 @@ resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.3.0.tgz#6067fa654174d1dd0953447bb036e38f9dfa51a5" integrity sha512-rEV0xnT/tNYPIdqHWWiz2KZo96UeZR0YChfoVLiPT46ZlEYyxqkjxT5bOm1eL2/CiYRe8t1yka3UDkIjq481/g== -"@element-hq/element-web-playwright-common@^1.4.3": - version "1.4.3" - resolved "https://registry.yarnpkg.com/@element-hq/element-web-playwright-common/-/element-web-playwright-common-1.4.3.tgz#c33217032e805a0668fbf3fa09929aac9acedb09" - integrity sha512-WrvScEsXTBreYmOMK2AiAA/ifAbgOrctolex2LRO0Z0TUkDF5Bh2sg6MBTK8i11EO+ifsy2eCLJtAQ//Yzj1GA== +"@element-hq/element-web-playwright-common@^1.4.4": + version "1.4.4" + resolved "https://registry.yarnpkg.com/@element-hq/element-web-playwright-common/-/element-web-playwright-common-1.4.4.tgz#d58dba7b5b4198f2fc137e1bdd1ad82c2cee46fb" + integrity sha512-QnWz8dlRuQHZYZT9ewrcN++l7gQ0Kf+oZwMCi0k1TBf8Za40r5ibNrgZqZYyCoItBc8LGTVL3yOrUfzN4Dm2Qw== dependencies: "@axe-core/playwright" "^4.10.1" "@testcontainers/postgresql" "^11.0.0" @@ -4543,7 +4543,7 @@ classnames "^2.5.1" vaul "^1.0.0" -"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm": +"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm": version "0.0.0" uid "" @@ -4552,7 +4552,7 @@ resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.39.0.tgz#a6238e517f23a2f3025d9c65445914771c63b163" integrity sha512-OROXnzPcQWrCMoUpIrCKEC4FYU+9SsRomUgu+VbJwWtBDkCbfvLD4z6w/mgiADw3iTUpBPgmcWJoGxesFuB20Q== dependencies: - "@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm" + "@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm" "@vitest/expect@3.2.4": version "3.2.4" From c79c8c836b1df358849fb6e4239f6c3834cd279b Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 31 Jul 2025 15:20:33 +0100 Subject: [PATCH 06/18] Put the 'decrypting' tooltip back (#30446) ...when downloading encrypted attachments (regressed by https://github.com/element-hq/element-web/pull/30330). Also adds tests for the tooltips and fix the tests so they don't pollute mocks / dialogs. --- .../views/messages/DownloadActionButton.tsx | 10 +- src/i18n/strings/en_EN.json | 1 + .../messages/DownloadActionButton-test.tsx | 124 ++++++++++++++++-- 3 files changed, 120 insertions(+), 15 deletions(-) diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx index 479e792fca..0072dd42a0 100644 --- a/src/components/views/messages/DownloadActionButton.tsx +++ b/src/components/views/messages/DownloadActionButton.tsx @@ -26,6 +26,12 @@ interface IProps { mediaEventHelperGet: () => MediaEventHelper | undefined; } +function useButtonTitle(loading: boolean, isEncrypted: boolean): string { + if (!loading) return _t("action|download"); + + return isEncrypted ? _t("timeline|download_action_decrypting") : _t("timeline|download_action_downloading"); +} + export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null { const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]); const downloadUrl = mediaEventHelper?.media.srcHttp ?? ""; @@ -33,6 +39,8 @@ export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: I const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent); + const buttonTitle = useButtonTitle(loading, mediaEventHelper?.media.isEncrypted ?? false); + if (!canDownload) return null; const spinner = loading ? : undefined; @@ -45,7 +53,7 @@ export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: I return ( ({ + decryptAttachment: jest.fn().mockResolvedValue(new Blob(["TESTFILE"], { type: "application/octet-stream" })), +})); + describe("DownloadActionButton", () => { + const plainEvent = new MatrixEvent({ + room_id: "!room:id", + sender: "@user:id", + type: "m.room.message", + content: { + body: "test", + msgtype: "m.image", + url: "mxc://matrix.org/1234", + }, + }); + + beforeEach(() => { + jest.restoreAllMocks(); + fetchMockJest.restore(); + }); + + afterEach(() => { + clearAllModals(); + }); + it("should show error if media API returns one", async () => { const cli = stubClient(); // eslint-disable-next-line no-restricted-properties @@ -26,24 +51,14 @@ describe("DownloadActionButton", () => { (mxc) => `https://matrix.org/_matrix/media/r0/download/${mxc.slice(6)}`, ); - fetchMockJest.get("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", { + fetchMockJest.getOnce("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", { status: 404, body: { errcode: "M_NOT_FOUND", error: "Not found" }, }); - const event = new MatrixEvent({ - room_id: "!room:id", - sender: "@user:id", - type: "m.room.message", - content: { - body: "test", - msgtype: "m.image", - url: "mxc://matrix.org/1234", - }, - }); - const mediaEventHelper = new MediaEventHelper(event); + const mediaEventHelper = new MediaEventHelper(plainEvent); - render( mediaEventHelper} />); + render( mediaEventHelper} />); const spy = jest.spyOn(Modal, "createDialog"); @@ -57,4 +72,85 @@ describe("DownloadActionButton", () => { ), ); }); + + it("should show download tooltip on hover", async () => { + stubClient(); + + const user = userEvent.setup(); + + fetchMockJest.getOnce("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", "TESTFILE"); + + const event = new MatrixEvent({ + room_id: "!room:id", + sender: "@user:id", + type: "m.room.message", + content: { + body: "test", + msgtype: "m.image", + url: "mxc://matrix.org/1234", + }, + }); + + render( undefined} />); + + const button = screen.getByRole("button"); + await user.hover(button); + + await waitFor(() => { + expect(screen.getByRole("tooltip")).toHaveTextContent("Download"); + }); + }); + + it("should show downloading tooltip while unencrypted files are downloading", async () => { + const user = userEvent.setup(); + + stubClient(); + + fetchMockJest.getOnce("http://this.is.a.url/matrix.org/1234", "TESTFILE"); + + const mediaEventHelper = new MediaEventHelper(plainEvent); + + render( mediaEventHelper} />); + + const button = screen.getByRole("button"); + await user.hover(button); + + await user.click(button); + + await waitFor(() => { + expect(screen.getByRole("tooltip")).toHaveTextContent("Downloading"); + }); + }); + + it("should show decrypting tooltip while encrypted files are downloading", async () => { + const user = userEvent.setup(); + + stubClient(); + + fetchMockJest.getOnce("http://this.is.a.url/matrix.org/1234", "UFTUGJMF"); + + const e2eEvent = new MatrixEvent({ + room_id: "!room:id", + sender: "@user:id", + type: "m.room.message", + content: { + body: "test", + msgtype: "m.image", + file: { url: "mxc://matrix.org/1234" }, + }, + }); + + const mediaEventHelper = new MediaEventHelper(e2eEvent); + + render( mediaEventHelper} />); + + const button = screen.getByRole("button"); + await user.hover(button); + + await user.click(button); + + await waitFor(() => { + expect(screen.getByRole("tooltip")).toHaveTextContent("Decrypting"); + }); + }); }); From ab6ef2fa858dd5c55cf46166f72c9bde26f21107 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Thu, 31 Jul 2025 15:20:37 +0100 Subject: [PATCH 07/18] Add labs option for history sharing on invite (#30313) * Add labs option for "share history on invite" * Set `acceptSharedHistory` when joining a room * set `shareEncryptedHistory` when sending an invite * Update src/i18n/strings/en_EN.json Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --------- Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/i18n/strings/en_EN.json | 3 +++ src/settings/Settings.tsx | 24 ++++++++++++++++++++ src/stores/RoomViewStore.tsx | 18 +++++++++------ src/utils/MultiInviter.ts | 8 +++++-- test/unit-tests/stores/RoomViewStore-test.ts | 11 +++++++++ test/unit-tests/utils/MultiInviter-test.ts | 24 ++++++++++++++------ 6 files changed, 72 insertions(+), 16 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 01b2d0b4b0..aa52c80fc5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1534,6 +1534,9 @@ "render_reaction_images_description": "Sometimes referred to as \"custom emojis\".", "report_to_moderators": "Report to moderators", "report_to_moderators_description": "In rooms that support moderation, the ā€œReportā€ button will let you report abuse to room moderators.", + "share_history_on_invite": "Share encrypted history with new members", + "share_history_on_invite_description": "When inviting a user to an encrypted room that has history visibility set to \"shared\", share encrypted history with that user, and accept encrypted history when you are invited to such a room.", + "share_history_on_invite_warning": "This feature is EXPERIMENTAL and not all security precautions are implemented. Do not enable on production accounts.", "sliding_sync": "Sliding Sync mode", "sliding_sync_description": "Under active development, cannot be disabled.", "sliding_sync_disabled_notice": "Log out and back in to disable", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 62a24214b9..3adb29a23e 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -205,6 +205,7 @@ export interface Settings { "feature_mjolnir": IFeature; "feature_custom_themes": IFeature; "feature_exclude_insecure_devices": IFeature; + "feature_share_history_on_invite": IFeature; "feature_html_topic": IFeature; "feature_bridge_state": IFeature; "feature_jump_to_date": IFeature; @@ -503,6 +504,29 @@ export const SETTINGS: Settings = { supportedLevelsAreOrdered: true, default: false, }, + "feature_share_history_on_invite": { + isFeature: true, + labsGroup: LabGroup.Encryption, + displayName: _td("labs|share_history_on_invite"), + description: () => ( + <> + {_t("labs|share_history_on_invite_description")} +
+ {_t( + "settings|warning", + {}, + { + w: (sub) => {sub}, + description: _t("labs|share_history_on_invite_warning"), + }, + )} +
+ + ), + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, + supportedLevelsAreOrdered: true, + default: false, + }, "useOnlyCurrentProfiles": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("settings|disable_historical_profile"), diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 633d55f3f6..4e1b89d8e8 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. import React, { type ReactNode } from "react"; import * as utils from "matrix-js-sdk/src/utils"; -import { MatrixError, JoinRule, type Room, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixError, JoinRule, type Room, type MatrixEvent, type IJoinRoomOpts } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; import { type ViewRoom as ViewRoomEvent } from "@matrix-org/analytics-events/types/typescript/ViewRoom"; @@ -512,15 +512,19 @@ export class RoomViewStore extends EventEmitter { // take a copy of roomAlias & roomId as they may change by the time the join is complete const { roomAlias, roomId = payload.roomId } = this.state; const address = roomAlias || roomId!; - const viaServers = this.state.viaServers || []; + + const joinOpts: IJoinRoomOpts = { + viaServers: this.state.viaServers || [], + ...(payload.opts ?? {}), + }; + if (SettingsStore.getValue("feature_share_history_on_invite")) { + joinOpts.acceptSharedHistory = true; + } + try { const cli = MatrixClientPeg.safeGet(); await retry( - () => - cli.joinRoom(address, { - viaServers, - ...(payload.opts || {}), - }), + () => cli.joinRoom(address, joinOpts), NUM_JOIN_RETRY, (err) => { // if we received a Gateway timeout or Cloudflare timeout then retry diff --git a/src/utils/MultiInviter.ts b/src/utils/MultiInviter.ts index f8310de8bd..9ad16c0549 100644 --- a/src/utils/MultiInviter.ts +++ b/src/utils/MultiInviter.ts @@ -6,7 +6,7 @@ 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 { MatrixError, type MatrixClient, EventType, type EmptyObject } from "matrix-js-sdk/src/matrix"; +import { MatrixError, type MatrixClient, EventType, type EmptyObject, type InviteOpts } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -183,7 +183,11 @@ export default class MultiInviter { } } - return this.matrixClient.invite(roomId, addr, this.reason); + const opts: InviteOpts = {}; + if (this.reason !== undefined) opts.reason = this.reason; + if (SettingsStore.getValue("feature_share_history_on_invite")) opts.shareEncryptedHistory = true; + + return this.matrixClient.invite(roomId, addr, opts); } else { throw new Error("Unsupported address"); } diff --git a/test/unit-tests/stores/RoomViewStore-test.ts b/test/unit-tests/stores/RoomViewStore-test.ts index 71bc57160d..53840b0c32 100644 --- a/test/unit-tests/stores/RoomViewStore-test.ts +++ b/test/unit-tests/stores/RoomViewStore-test.ts @@ -440,6 +440,17 @@ describe("RoomViewStore", function () { }); expect(mocked(dis.dispatch).mock.calls[2][0]).toEqual({ action: "prompt_ask_to_join" }); }); + + it("sets 'acceptSharedHistory' if that option is enabled", async () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName, roomId, value) => { + return settingName === "feature_share_history_on_invite"; // this is enabled, everything else is disabled. + }); + + dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); + dis.dispatch({ action: Action.JoinRoom }); + await untilDispatch(Action.JoinRoomReady, dis); + expect(mockClient.joinRoom).toHaveBeenCalledWith(roomId, { acceptSharedHistory: true, viaServers: [] }); + }); }); describe("Action.JoinRoomError", () => { diff --git a/test/unit-tests/utils/MultiInviter-test.ts b/test/unit-tests/utils/MultiInviter-test.ts index 998334c9af..8fcc673143 100644 --- a/test/unit-tests/utils/MultiInviter-test.ts +++ b/test/unit-tests/utils/MultiInviter-test.ts @@ -96,9 +96,9 @@ describe("MultiInviter", () => { const result = await inviter.invite([MXID1, MXID2, MXID3]); expect(client.invite).toHaveBeenCalledTimes(3); - expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined); - expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, undefined); - expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, undefined); + expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, {}); + expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, {}); + expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, {}); expectAllInvitedResult(result); }); @@ -114,9 +114,9 @@ describe("MultiInviter", () => { const result = await inviter.invite([MXID1, MXID2, MXID3]); expect(client.invite).toHaveBeenCalledTimes(3); - expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined); - expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, undefined); - expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, undefined); + expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, {}); + expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, {}); + expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, {}); expectAllInvitedResult(result); }); @@ -129,7 +129,7 @@ describe("MultiInviter", () => { const result = await inviter.invite([MXID1, MXID2, MXID3]); expect(client.invite).toHaveBeenCalledTimes(1); - expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined); + expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, {}); // The resolved state is 'invited' for all users. // With the above client expectations, the test ensures that only the first user is invited. @@ -231,5 +231,15 @@ describe("MultiInviter", () => { `"This space is unfederated. You cannot invite people from external servers."`, ); }); + + it("should set shareEncryptedHistory if that setting is enabled", async () => { + mocked(SettingsStore.getValue).mockImplementation((settingName, roomId, value) => { + return settingName === "feature_share_history_on_invite"; // this is enabled, everything else is disabled. + }); + await inviter.invite([MXID1]); + + expect(client.invite).toHaveBeenCalledTimes(1); + expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, { shareEncryptedHistory: true }); + }); }); }); From cc0ece9837b2d263450544340d8f35c181e82537 Mon Sep 17 00:00:00 2001 From: David Langley Date: Thu, 31 Jul 2025 16:49:53 +0100 Subject: [PATCH 08/18] Implement the member list with virtuoso (#29869) * implement basic scrolling and keyboard navigation * Update focus style and improve keyboard navigation * lint * Use avatar tootltip for the title rather than the whole button It's more performant and feels less glitchy than the button tooltip moving around when you scroll. * lint * Add tooltip for invite buttons active state As we have for other icon based buttons in the right panel/app * Fix location of scrollToIndex and add useCallback * Improve voiceover experience - As well as stylng cells, set the tabIndex(roving) - Natively focus the div with .focus() so screen reader actually moves over the cells - improve labels and roles * Fix jest tests * Add aria index/counts and remove repeating "Open" string in label * update snapshot * Add the rest of the keyboard navigation and handle the case when the list looses focus. * lint and update snapshot * lint * Only focus first/lastFocsed cell if focus.currentTarget is the overall list. So it isn't erroneously called during onClick of an item. * Put back overscan and fix formatting * Extract ListView out of MemberList * lint and fix e2e test * Update screenshot It looks like it is slightly better center aligned in the new list, as if maybe it was 1 px to high with the old one. * Fix default overscan value and add ListView tests * Just leave the avatar as it was * We removed the tooltip that showed power level. Removing string. * Use key rather than index to track focus. * Remove overscan, fix typos, fix scrollToItem logic * Use listbox role for member list and correct position/count values to account for the separator * Fix inadvertant scrolling of the timeline when using pageUp/pageDown * Always set the roving tab index regardless of whether we are actually focused. Fixes the issue of not being able to shift+t * Add aria-hidden to items within the option to avoid the SR calling it a group. Also * Make sure there is a roving tab set if the last one has been removed from the list. * Update snapshot --- package.json | 1 + .../e2e/lazy-loading/lazy-loading.spec.ts | 4 + .../e2e/share-dialog/share-dialog.spec.ts | 2 +- .../with-four-members-linux.png | Bin 19391 -> 18835 bytes src/components/utils/ListView.tsx | 272 +++++++++++++ .../memberlist/MemberListViewModel.tsx | 12 + .../memberlist/tiles/MemberTileViewModel.tsx | 14 +- .../rooms/MemberList/MemberListHeaderView.tsx | 14 +- .../views/rooms/MemberList/MemberListView.tsx | 142 ++++--- .../MemberList/tiles/RoomMemberTileView.tsx | 12 +- .../tiles/ThreePidInviteTileView.tsx | 12 +- .../tiles/common/MemberTileView.tsx | 34 +- src/i18n/strings/en_EN.json | 4 +- .../rooms/memberlist/MemberTileView-test.tsx | 16 +- .../MemberTileView-test.tsx.snap | 46 ++- .../views/rooms/memberlist/common.tsx | 8 + .../components/views/utils/ListView-test.tsx | 377 ++++++++++++++++++ yarn.lock | 5 + 18 files changed, 849 insertions(+), 126 deletions(-) create mode 100644 src/components/utils/ListView.tsx create mode 100644 test/unit-tests/components/views/utils/ListView-test.tsx diff --git a/package.json b/package.json index bbb0b176a1..363276af27 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "react-string-replace": "^1.1.1", "react-transition-group": "^4.4.1", "react-virtualized": "^9.22.5", + "react-virtuoso": "^4.12.6", "rfc4648": "^1.4.0", "sanitize-filename": "^1.6.3", "sanitize-html": "2.17.0", diff --git a/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/playwright/e2e/lazy-loading/lazy-loading.spec.ts index 7c31c288fa..f6f098a079 100644 --- a/playwright/e2e/lazy-loading/lazy-loading.spec.ts +++ b/playwright/e2e/lazy-loading/lazy-loading.spec.ts @@ -30,6 +30,10 @@ test.describe("Lazy Loading", () => { }); test.beforeEach(async ({ page, homeserver, user, bot, app }) => { + // The charlies were running off the bottom of the screen. + // We no longer overscan the member list so the result is they are not in the dom. + // Increase the viewport size to ensure they are. + await page.setViewportSize({ width: 1000, height: 1000 }); for (let i = 1; i <= 10; i++) { const displayName = `Charly #${i}`; const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false }); diff --git a/playwright/e2e/share-dialog/share-dialog.spec.ts b/playwright/e2e/share-dialog/share-dialog.spec.ts index 58574a46ff..a77e89fcdc 100644 --- a/playwright/e2e/share-dialog/share-dialog.spec.ts +++ b/playwright/e2e/share-dialog/share-dialog.spec.ts @@ -35,7 +35,7 @@ test.describe("Share dialog", () => { const rightPanel = await app.toggleRoomInfoPanel(); await rightPanel.getByRole("menuitem", { name: "People" }).click(); - await rightPanel.getByRole("button", { name: `${user.userId} (power 100)` }).click(); + await rightPanel.getByRole("option", { name: user.displayName }).click(); await rightPanel.getByRole("button", { name: "Share profile" }).click(); const dialog = page.getByRole("dialog", { name: "Share User" }); diff --git a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png index a5db88aae638325eee39eefd8b087d0b27d1e5eb..ab5b0ea76c6dd5e7fe48693310a776a0eb90f663 100644 GIT binary patch literal 18835 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 Q*FfI#boFyt=akR{0K3oHd;kCd literal 19391 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 = { + /** The key of item that should have tabIndex == 0 */ + tabIndexKey?: string; + /** Whether an item in the list is currently focused */ + focused: boolean; + /** Additional context data passed from the parent component */ + context: Context; +}; + +export interface IListViewProps + extends Omit>, "data" | "itemContent" | "context"> { + /** + * The array of items to display in the virtualized list. + * Each item will be passed to getItemComponent for rendering. + */ + items: Item[]; + + /** + * Callback function called when an item is selected (via Enter/Space key). + * @param item - The selected item from the items array + */ + onSelectItem: (item: Item) => void; + + /** + * Function that renders each list item as a JSX element. + * @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 + * @returns JSX element representing the rendered item + */ + getItemComponent: (index: number, item: Item, context: ListContext) => JSX.Element; + + /** + * Optional additional context data to pass to each rendered item. + * This will be available in the ListContext passed to getItemComponent. + */ + context?: Context; + + /** + * Function to determine if an item can receive focus during keyboard navigation. + * @param item - The item to check for focusability + * @returns true if the item can be focused, false otherwise + */ + isItemFocusable: (item: Item) => boolean; + + /** + * Function to get the key to use for focusing an item. + * @param item - The item to get the key for + * @return The key to use for focusing the item + */ + getItemKey: (item: Item) => string; +} + +/** + * A generic virtualized list component built on top of react-virtuoso. + * Provides keyboard navigation and virtualized rendering for performance with large lists. + * + * @template Item - The type of data items in the list + * @template Context - The type of additional context data passed to items + */ +export function ListView(props: IListViewProps): React.ReactElement { + // Extract our custom props to avoid conflicts with Virtuoso props + const { items, onSelectItem, getItemComponent, isItemFocusable, getItemKey, context, ...virtuosoProps } = props; + /** Reference to the Virtuoso component for programmatic scrolling */ + const virtuosoHandleRef = useRef(null); + /** Reference to the DOM element containing the virtualized list */ + const virtuosoDomRef = useRef(null); + /** Key of the item that should have tabIndex == 0 */ + const [tabIndexKey, setTabIndexKey] = useState( + props.items[0] ? getItemKey(props.items[0]) : undefined, + ); + /** Range of currently visible items in the viewport */ + const [visibleRange, setVisibleRange] = useState(undefined); + /** Map from item keys to their indices in the items array */ + const [keyToIndexMap, setKeyToIndexMap] = useState>(new Map()); + /** Whether the list is currently scrolling to an item */ + const isScrollingToItem = useRef(false); + /** Whether the list is currently focused */ + const [isFocused, setIsFocused] = useState(false); + + // Update the key-to-index mapping whenever items change + useEffect(() => { + const newKeyToIndexMap = new Map(); + items.forEach((item, index) => { + const key = getItemKey(item); + newKeyToIndexMap.set(key, index); + }); + setKeyToIndexMap(newKeyToIndexMap); + }, [items, getItemKey]); + + // Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed + useEffect(() => { + if (items.length && (!tabIndexKey || keyToIndexMap.get(tabIndexKey) === undefined)) { + setTabIndexKey(getItemKey(items[0])); + } + }, [items, getItemKey, tabIndexKey, keyToIndexMap]); + + /** + * Scrolls to a specific item index and sets it as focused. + * Uses Virtuoso's scrollIntoView method for smooth scrolling. + */ + const scrollToIndex = useCallback( + (index: number, align?: "center" | "end" | "start"): void => { + // Ensure index is within bounds + const clampedIndex = Math.max(0, Math.min(index, items.length - 1)); + if (isScrollingToItem.current) { + // If already scrolling to an item drop this request. Adding further requests + // causes the event to bubble up and be handled by other components(unintentional timeline scrolling was observed). + return; + } + if (items[clampedIndex]) { + const key = getItemKey(items[clampedIndex]); + setTabIndexKey(key); + isScrollingToItem.current = true; + virtuosoHandleRef?.current?.scrollIntoView({ + index: clampedIndex, + align: align, + behavior: "auto", + done: () => { + isScrollingToItem.current = false; + }, + }); + } + }, + [items, getItemKey], + ); + + /** + * Scrolls to an item, skipping over non-focusable items if necessary. + * This is used for keyboard navigation to ensure focus lands on valid items. + */ + const scrollToItem = useCallback( + (index: number, isDirectionDown: boolean, align?: "center" | "end" | "start"): void => { + const totalRows = items.length; + let nextIndex: number | undefined; + + for (let i = index; isDirectionDown ? i < totalRows : i >= 0; i = i + (isDirectionDown ? 1 : -1)) { + if (isItemFocusable(items[i])) { + nextIndex = i; + break; + } + } + + if (nextIndex === undefined) { + return; + } + + scrollToIndex(nextIndex, align); + }, + [scrollToIndex, items, isItemFocusable], + ); + + /** + * Handles keyboard navigation for the list. + * Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space. + */ + const keyDownCallback = useCallback( + (e: React.KeyboardEvent) => { + if (!e) return; // Guard against null/undefined events + + 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 === "Enter" || e.code === "Space") && currentIndex !== undefined) { + const item = items[currentIndex]; + onSelectItem(item); + 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(); + } + }, + [scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onSelectItem], + ); + + /** + * Callback ref for the Virtuoso scroller element. + * Stores the reference for use in focus management. + */ + const scrollerRef = useCallback((element: HTMLElement | Window | null) => { + virtuosoDomRef.current = element; + }, []); + + /** + * Handles focus events on the list. + * Sets the focused state and scrolls to the focused item if it is not currently visible. + */ + const onFocus = useCallback( + (e?: React.FocusEvent): void => { + if (e?.currentTarget !== virtuosoDomRef.current || typeof tabIndexKey !== "string") { + return; + } + + setIsFocused(true); + const index = keyToIndexMap.get(tabIndexKey); + if ( + index !== undefined && + visibleRange && + (index < visibleRange.startIndex || index > visibleRange.endIndex) + ) { + scrollToIndex(index); + } + e?.stopPropagation(); + e?.preventDefault(); + }, + [keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey], + ); + + const onBlur = useCallback((): void => { + setIsFocused(false); + }, []); + + const listContext: ListContext = { + tabIndexKey: tabIndexKey, + focused: isFocused, + context: props.context || ({} as Context), + }; + + return ( + + ); +} diff --git a/src/components/viewmodels/memberlist/MemberListViewModel.tsx b/src/components/viewmodels/memberlist/MemberListViewModel.tsx index a03f703511..55220d29a9 100644 --- a/src/components/viewmodels/memberlist/MemberListViewModel.tsx +++ b/src/components/viewmodels/memberlist/MemberListViewModel.tsx @@ -38,6 +38,8 @@ import { isValid3pidInvite } from "../../../RoomInvite"; import { type ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite"; import { type XOR } from "../../../@types/common"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; +import { Action } from "../../../dispatcher/actions"; +import dis from "../../../dispatcher/dispatcher"; type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>; @@ -111,6 +113,7 @@ export interface MemberListViewState { shouldShowSearch: boolean; isLoading: boolean; canInvite: boolean; + onClickMember: (member: RoomMember | ThreePIDInvite) => void; onInviteButtonClick: (ev: ButtonEvent) => void; } export function useMemberListViewModel(roomId: string): MemberListViewState { @@ -133,6 +136,14 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { */ const [memberCount, setMemberCount] = useState(0); + const onClickMember = (member: RoomMember | ThreePIDInvite): void => { + dis.dispatch({ + action: Action.ViewUser, + member: member, + push: true, + }); + }; + const loadMembers = useMemo( () => throttle( @@ -267,6 +278,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { isPresenceEnabled, isLoading, onInviteButtonClick, + onClickMember, shouldShowSearch: totalMemberCount >= 20, canInvite, }; diff --git a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx index 4f6814caae..0355fe47a3 100644 --- a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx +++ b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx @@ -5,7 +5,7 @@ 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 { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { RoomStateEvent, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; import { type UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; @@ -16,7 +16,6 @@ import { asyncSome } from "../../../../utils/arrays"; import { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo"; import { type RoomMember } from "../../../../models/rooms/RoomMember"; import { _t, _td, type TranslationKey } from "../../../../languageHandler"; -import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier"; import { E2EStatus } from "../../../../utils/ShieldUtils"; interface MemberTileViewModelProps { @@ -28,7 +27,6 @@ export interface MemberTileViewState extends MemberTileViewModelProps { e2eStatus?: E2EStatus; name: string; onClick: () => void; - title?: string; userLabel?: string; } @@ -130,15 +128,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT } } - const title = useMemo(() => { - return _t("member_list|power_label", { - userName: UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, { - roomId: member.roomId, - }), - powerLevelNumber: member.powerLevel, - }).trim(); - }, [member.powerLevel, member.roomId, member.userId]); - let userLabel; const powerStatus = powerStatusMap.get(powerLevel); if (powerStatus) { @@ -149,7 +138,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT } return { - title, member, name, onClick, diff --git a/src/components/views/rooms/MemberList/MemberListHeaderView.tsx b/src/components/views/rooms/MemberList/MemberListHeaderView.tsx index c1520acd44..5d096c7228 100644 --- a/src/components/views/rooms/MemberList/MemberListHeaderView.tsx +++ b/src/components/views/rooms/MemberList/MemberListHeaderView.tsx @@ -19,10 +19,10 @@ interface TooltipProps { children: React.ReactNode; } -const OptionalTooltip: React.FC = ({ canInvite, children }) => { - if (canInvite) return children; +const InviteTooltip: React.FC = ({ canInvite, children }) => { + const description: string = canInvite ? _t("action|invite") : _t("member_list|invite_button_no_perms_tooltip"); // If the user isn't allowed to invite others to this room, wrap with a relevant tooltip. - return {children}; + return {children}; }; interface Props { @@ -42,7 +42,7 @@ const InviteButton: React.FC = ({ vm }) => { if (shouldShowSearch) { /// When rendered alongside a search box, the invite button is just an icon. return ( - + - + ); }; diff --git a/src/components/views/rooms/MemberList/MemberListView.tsx b/src/components/views/rooms/MemberList/MemberListView.tsx index 1c85b3188e..8afdeaf990 100644 --- a/src/components/views/rooms/MemberList/MemberListView.tsx +++ b/src/components/views/rooms/MemberList/MemberListView.tsx @@ -6,9 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import { Form } from "@vector-im/compound-web"; -import React, { type JSX } from "react"; -import { List, type ListRowProps } from "react-virtualized/dist/commonjs/List"; -import { AutoSizer } from "react-virtualized"; +import React, { type JSX, useCallback } from "react"; import { Flex } from "../../../../shared-components/utils/Flex"; import { @@ -21,7 +19,7 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView"; import { MemberListHeaderView } from "./MemberListHeaderView"; import BaseCard from "../../right_panel/BaseCard"; import { _t } from "../../../../languageHandler"; -import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex"; +import { type ListContext, ListView } from "../../../utils/ListView"; interface IProps { roomId: string; @@ -30,53 +28,67 @@ interface IProps { const MemberListView: React.FC = (props: IProps) => { const vm = useMemberListViewModel(props.roomId); + const { isPresenceEnabled, onClickMember, memberCount } = vm; - const totalRows = vm.members.length; - - const getRowComponent = (item: MemberWithSeparator): JSX.Element => { + const getItemKey = useCallback((item: MemberWithSeparator): string => { if (item === SEPARATOR) { - return
; + return "separator"; } else if (item.member) { - return ; + return `member-${item.member.userId}`; } else { - return ; + return `threePidInvite-${item.threePidInvite.event.getContent().public_key}`; } - }; + }, []); - const getRowHeight = ({ index }: { index: number }): number => { - if (vm.members[index] === SEPARATOR) { - /** - * This is a separator of 2px height rendered between - * joined and invited members. - */ - return 2; - } else if (totalRows && index === totalRows) { - /** - * The empty spacer div rendered at the bottom should - * have a height of 32px. - */ - return 32; - } else { - /** - * The actual member tiles have a height of 56px. - */ - return 56; - } - }; + const getItemComponent = useCallback( + (index: number, item: MemberWithSeparator, context: ListContext): JSX.Element => { + const itemKey = getItemKey(item); + const isRovingItem = itemKey === context.tabIndexKey; + const focused = isRovingItem && context.focused; + if (item === SEPARATOR) { + return
; + } else if (item.member) { + return ( + + ); + } else { + return ( + + ); + } + }, + [isPresenceEnabled, getItemKey, memberCount], + ); - const rowRenderer = ({ key, index, style }: ListRowProps): JSX.Element => { - if (index === totalRows) { - // We've rendered all the members, - // now we render an empty div to add some space to the end of the list. - return
; - } - const item = vm.members[index]; - return ( -
- {getRowComponent(item)} -
- ); - }; + const handleSelectItem = useCallback( + (item: MemberWithSeparator): void => { + if (item !== SEPARATOR) { + if (item.member) { + onClickMember(item.member); + } else { + onClickMember(item.threePidInvite); + } + } + }, + [onClickMember], + ); + + const isItemFocusable = useCallback((item: MemberWithSeparator): boolean => { + return item !== SEPARATOR; + }, []); return ( = (props: IProps) => { header={_t("common|people")} onClose={props.onClose} > - - {({ onKeyDownHandler }) => ( - - e.preventDefault()}> - - - - {({ height, width }) => ( - - )} - - - )} - + + e.preventDefault()}> + + + + ); }; diff --git a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx index f5fd5203a5..4837972da3 100644 --- a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx +++ b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx @@ -19,7 +19,11 @@ import { InvitedIconView } from "./common/InvitedIconView"; interface IProps { member: RoomMember; + index: number; + memberCount: number; showPresence?: boolean; + focused?: boolean; + tabIndex?: number; } export function RoomMemberTileView(props: IProps): JSX.Element { @@ -36,7 +40,7 @@ export function RoomMemberTileView(props: IProps): JSX.Element { /> ); const name = vm.name; - const nameJSX = ; + const nameJSX = ; const presenceState = member.presenceState; let presenceJSX: JSX.Element | undefined; @@ -54,13 +58,17 @@ export function RoomMemberTileView(props: IProps): JSX.Element { return ( ); } diff --git a/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx b/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx index 4f6caf06f6..0a93727f5f 100644 --- a/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx +++ b/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx @@ -15,20 +15,30 @@ import { InvitedIconView } from "./common/InvitedIconView"; interface Props { threePidInvite: ThreePIDInvite; + memberIndex: number; + memberCount: number; + focused?: boolean; + tabIndex?: number; } export function ThreePidInviteTileView(props: Props): JSX.Element { const vm = useThreePidTileViewModel(props); const av =