From 4bdd4f4f492059e92a2abcc4e35a051b590d4bad Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 12 Nov 2024 14:01:37 +0000 Subject: [PATCH 01/37] Upgrade dependency to matrix-js-sdk@34.12.0-rc.0 --- package.json | 2 +- yarn.lock | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 2c7e134405..6e6972208c 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "maplibre-gl": "^2.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-js-sdk": "34.12.0-rc.0", "matrix-widget-api": "^1.10.0", "memoize-one": "^6.0.0", "mime": "^4.0.4", diff --git a/yarn.lock b/yarn.lock index 63b60de8e0..7cc8109395 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8179,9 +8179,10 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "34.10.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/6855ace6422082d173438cb23368d2fabc6a1086" +matrix-js-sdk@34.12.0-rc.0: + version "34.12.0-rc.0" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-34.12.0-rc.0.tgz#d7ff6e5a5daa82a5c8465016cd3cb168d709576a" + integrity sha512-hT7tzLYI9Jy3d+8bpzv5p+5MV1R4YxJ8IgMZQ8cy+65/bzkPbSi/XphCfAXcG1KDdFW28l0GYvAk4K7WTOQA8Q== dependencies: "@babel/runtime" "^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm" "^9.0.0" From 08cb450d258f43a75e7f1a8fbe0ae85bde53a11a Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 12 Nov 2024 14:05:01 +0000 Subject: [PATCH 02/37] v1.11.86-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6e6972208c..ec7dc63e89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.11.85", + "version": "1.11.86-rc.0", "description": "A feature-rich client for Matrix.org", "author": "New Vector Ltd.", "repository": { From cafa02ccc2dce60ef9f120bca39fac80b2e0aa3d Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 18 Nov 2024 10:22:42 +0100 Subject: [PATCH 03/37] Remove crypto eslint exception (#28228) --- .eslintrc.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index e95f4834e9..f168a87a06 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -117,10 +117,6 @@ module.exports = { "!matrix-js-sdk/src/extensible_events_v1/PollResponseEvent", "!matrix-js-sdk/src/extensible_events_v1/PollEndEvent", "!matrix-js-sdk/src/extensible_events_v1/InvalidEventError", - "!matrix-js-sdk/src/crypto", - "!matrix-js-sdk/src/crypto/keybackup", - "!matrix-js-sdk/src/crypto/deviceinfo", - "!matrix-js-sdk/src/crypto/dehydration", "!matrix-js-sdk/src/oidc", "!matrix-js-sdk/src/oidc/discovery", "!matrix-js-sdk/src/oidc/authorize", From abf6d58b7bd9ccb7008f4451d1a5a5dd9e2f6ad6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 18 Nov 2024 09:56:22 +0000 Subject: [PATCH 04/37] Enable stylelint rule no-unknown-custom-properties (#28473) * Enable stylelint rule no-unknown-custom-properties Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix cpd css vars Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Remove dead styling Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Remove invalid css Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix comments Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .stylelintrc.js | 30 +++++++++- package.json | 1 + .../views/elements/_AppPermission.pcss | 7 +-- res/css/structures/_RoomView.pcss | 59 ------------------- res/css/structures/_SpaceRoomView.pcss | 2 +- .../audio_messages/_PlaybackContainer.pcss | 3 +- res/css/views/audio_messages/_SeekBar.pcss | 3 + res/css/views/elements/_ProgressBar.pcss | 11 +--- .../views/rooms/_BasicMessageComposer.pcss | 5 ++ res/css/views/rooms/_EventTile.pcss | 10 ---- .../wysiwyg_composer/components/_Editor.pcss | 5 ++ yarn.lock | 10 +++- 12 files changed, 58 insertions(+), 88 deletions(-) diff --git a/.stylelintrc.js b/.stylelintrc.js index dc8ae6376b..fa36402ff1 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -1,7 +1,7 @@ module.exports = { extends: ["stylelint-config-standard"], customSyntax: "postcss-scss", - plugins: ["stylelint-scss"], + plugins: ["stylelint-scss", "stylelint-value-no-unknown-custom-properties"], rules: { "comment-empty-line-before": null, "declaration-empty-line-before": null, @@ -46,5 +46,33 @@ module.exports = { "number-max-precision": null, "no-invalid-double-slash-comments": true, "media-feature-range-notation": null, + "csstools/value-no-unknown-custom-properties": [ + true, + { + importFrom: [ + { from: "res/css/_common.pcss", type: "css" }, + { from: "res/themes/light/css/_light.pcss", type: "css" }, + // Right now our styles share vars all over the place, this is not ideal but acceptable for now + { from: "res/css/views/rooms/_EventTile.pcss", type: "css" }, + { from: "res/css/views/rooms/_IRCLayout.pcss", type: "css" }, + { from: "res/css/views/rooms/_EventBubbleTile.pcss", type: "css" }, + { from: "res/css/views/rooms/_ReadReceiptGroup.pcss", type: "css" }, + { from: "res/css/views/rooms/_EditMessageComposer.pcss", type: "css" }, + { from: "res/css/views/right_panel/_BaseCard.pcss", type: "css" }, + { from: "res/css/views/messages/_MessageTimestamp.pcss", type: "css" }, + { from: "res/css/views/messages/_EventTileBubble.pcss", type: "css" }, + { from: "res/css/views/messages/_MessageActionBar.pcss", type: "css" }, + { from: "res/css/views/voip/LegacyCallView/_LegacyCallViewButtons.pcss", type: "css" }, + { from: "res/css/views/elements/_ToggleSwitch.pcss", type: "css" }, + { from: "res/css/views/settings/tabs/_SettingsTab.pcss", type: "css" }, + { from: "res/css/structures/_RoomView.pcss", type: "css" }, + // Compound vars + "node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-common-base.css", + "node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-common-semantic.css", + "node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-theme-light-base-mq.css", + "node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-theme-light-semantic-mq.css", + ], + }, + ], }, }; diff --git a/package.json b/package.json index 0a0d0a477b..e01c670965 100644 --- a/package.json +++ b/package.json @@ -276,6 +276,7 @@ "stylelint": "^16.1.0", "stylelint-config-standard": "^36.0.0", "stylelint-scss": "^6.0.0", + "stylelint-value-no-unknown-custom-properties": "^6.0.1", "terser-webpack-plugin": "^5.3.9", "ts-node": "^10.9.1", "ts-prune": "^0.10.3", diff --git a/res/css/components/views/elements/_AppPermission.pcss b/res/css/components/views/elements/_AppPermission.pcss index 25db241f73..0891d25221 100644 --- a/res/css/components/views/elements/_AppPermission.pcss +++ b/res/css/components/views/elements/_AppPermission.pcss @@ -11,7 +11,8 @@ Please see LICENSE files in the repository root for full details. font-size: $font-12px; width: 100%; /* make mx_AppPermission fill width of mx_AppTileBody so that scroll bar appears on the edge */ overflow-y: scroll; - .mx_AppPermission_bolder { + .mx_AppPermission_bolder, + .mx_AppPermission_content_bolder { font-weight: var(--cpd-font-weight-semibold); } .mx_AppPermission_content { @@ -21,10 +22,6 @@ Please see LICENSE files in the repository root for full details. margin-block: 12px; } - .mx_AppPermission_content_bolder { - font-weight: var(--font-semi-bold); - } - .mx_TextWithTooltip_target--helpIcon { display: inline-block; height: $font-14px; /* align with characters on the same line */ diff --git a/res/css/structures/_RoomView.pcss b/res/css/structures/_RoomView.pcss index eaa02cd2d2..65ea555ce1 100644 --- a/res/css/structures/_RoomView.pcss +++ b/res/css/structures/_RoomView.pcss @@ -207,62 +207,3 @@ Please see LICENSE files in the repository root for full details. min-height: 42px; } } - -@keyframes mx_Indicator_pulse { - 0% { - transform: scale(0.95); - } - - 70% { - transform: scale(1); - } - - 100% { - transform: scale(0.95); - } -} - -@keyframes mx_Indicator_pulse_shadow { - 0% { - opacity: 0.7; - } - - 70% { - transform: scale(2.2); - opacity: 0; - } - - 100% { - opacity: 0; - } -} - -.mx_Indicator { - position: absolute; - right: -3px; - top: -3px; - width: var(--RoomHeader-indicator-dot-size); - height: var(--RoomHeader-indicator-dot-size); - border-radius: 50%; - transform: scale(1); - background: var(--RoomHeader-indicator-pulseColor); - box-shadow: 0 0 0 0 var(--RoomHeader-indicator-pulseColor); - animation: mx_Indicator_pulse 2s infinite; - animation-iteration-count: 1; - - &::after { - content: ""; - position: absolute; - width: inherit; - height: inherit; - top: 0; - left: 0; - transform: scale(1); - transform-origin: center center; - animation-name: mx_Indicator_pulse_shadow; - animation-duration: inherit; - animation-iteration-count: inherit; - border-radius: 50%; - background: inherit; - } -} diff --git a/res/css/structures/_SpaceRoomView.pcss b/res/css/structures/_SpaceRoomView.pcss index 7e55743200..c54bc53dc2 100644 --- a/res/css/structures/_SpaceRoomView.pcss +++ b/res/css/structures/_SpaceRoomView.pcss @@ -39,7 +39,7 @@ Please see LICENSE files in the repository root for full details. } &:hover { - border-color: var(--cpd-color-bg-interactive-primary-rest); + border-color: var(--cpd-color-bg-action-primary-rest); &::before { background-color: var(--cpd-color-icon-primary); diff --git a/res/css/views/audio_messages/_PlaybackContainer.pcss b/res/css/views/audio_messages/_PlaybackContainer.pcss index e02533037b..f1dc1d1ec8 100644 --- a/res/css/views/audio_messages/_PlaybackContainer.pcss +++ b/res/css/views/audio_messages/_PlaybackContainer.pcss @@ -28,10 +28,11 @@ Please see LICENSE files in the repository root for full details. /* Waveforms are present in live recording only */ .mx_Waveform { + /* default, overridden in JS */ + --barHeight: 1; .mx_Waveform_bar { background-color: $quaternary-content; height: 100%; - /* Variable set by a JS component */ transform: scaleY(max(0.05, var(--barHeight))); &.mx_Waveform_bar_100pct { diff --git a/res/css/views/audio_messages/_SeekBar.pcss b/res/css/views/audio_messages/_SeekBar.pcss index 47cce4b47a..fb781811f1 100644 --- a/res/css/views/audio_messages/_SeekBar.pcss +++ b/res/css/views/audio_messages/_SeekBar.pcss @@ -12,6 +12,9 @@ Please see LICENSE files in the repository root for full details. /* * https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ */ .mx_SeekBar { + /* default, overridden in JS */ + --fillTo: 1; + /* Dev note: we deliberately do not have the -ms-track (and friends) selectors because we don't */ /* need to support IE. */ diff --git a/res/css/views/elements/_ProgressBar.pcss b/res/css/views/elements/_ProgressBar.pcss index 8900b7d985..062770f77f 100644 --- a/res/css/views/elements/_ProgressBar.pcss +++ b/res/css/views/elements/_ProgressBar.pcss @@ -16,16 +16,7 @@ progress.mx_ProgressBar { @mixin ProgressBarBorderRadius 6px; @mixin ProgressBarColour var(--cpd-color-icon-accent-tertiary); @mixin ProgressBarBgColour $progressbar-bg-color; - ::-webkit-progress-value { + &::-webkit-progress-value { transition: width 1s; } - ::-moz-progress-bar { - transition: padding-bottom 1s; - padding-bottom: var(--value); - transform-origin: 0 0; - transform: rotate(-90deg) translateX(-15px); - padding-left: 15px; - - height: 0; - } } diff --git a/res/css/views/rooms/_BasicMessageComposer.pcss b/res/css/views/rooms/_BasicMessageComposer.pcss index e34c991d89..499ce870ec 100644 --- a/res/css/views/rooms/_BasicMessageComposer.pcss +++ b/res/css/views/rooms/_BasicMessageComposer.pcss @@ -7,6 +7,11 @@ Please see LICENSE files in the repository root for full details. */ .mx_BasicMessageComposer { + /* These are set in Javascript */ + --avatar-letter: ""; + --avatar-background: unset; + --placeholder: ""; + position: relative; .mx_BasicMessageComposer_inputEmpty > :first-child::before { diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 311e059166..d405381db1 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -1017,16 +1017,6 @@ $left-gutter: 64px; visibility: visible; } -/* Inverse of the above to *disable* the animation on any indicators. This approach */ -/* is less pretty, but is easier to target because otherwise we need to define the */ -/* animation for when it's shown which means duplicating the style definition in */ -/* multiple places. */ -.mx_EventTile:not(:hover):not(.mx_EventTile_actionBarFocused):not([data-whatinput="keyboard"] :focus-within) { - &:not(:focus-visible:focus-within) .mx_MessageActionBar .mx_Indicator { - animation: none; - } -} - .mx_EventTile[data-shape="ThreadsList"], .mx_EventTile[data-shape="Notification"] { --topOffset: $spacing-12; diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss index 5c0d5da9fc..34c2a4d626 100644 --- a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss @@ -7,6 +7,11 @@ Please see LICENSE files in the repository root for full details. */ .mx_WysiwygComposer_Editor_container { + /* These are set in Javascript */ + --avatar-letter: ""; + --avatar-background: unset; + --placeholder: ""; + @keyframes visualbell { from { background-color: $visual-bell-bg-color; diff --git a/yarn.lock b/yarn.lock index f0e1621d08..c5e5ecce0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10385,7 +10385,7 @@ resolve.exports@^2.0.0: resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.2.tgz#f8c934b8e6a13f539e38b7098e2e36134f01e800" integrity sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg== -resolve@^1.1.7, resolve@^1.10.0, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.4: +resolve@^1.1.7, resolve@^1.10.0, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.4, resolve@^1.22.8: version "1.22.8" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== @@ -11127,6 +11127,14 @@ stylelint-scss@^6.0.0: postcss-selector-parser "^6.1.2" postcss-value-parser "^4.2.0" +stylelint-value-no-unknown-custom-properties@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/stylelint-value-no-unknown-custom-properties/-/stylelint-value-no-unknown-custom-properties-6.0.1.tgz#526cc20344f4fc5e33231152767a432b6ed8f957" + integrity sha512-N60PTdaTknB35j6D4FhW0GL2LlBRV++bRpXMMldWMQZ240yFQaoltzlLY4lXXs7Z0J5mNUYZQ/gjyVtU2DhCMA== + dependencies: + postcss-value-parser "^4.2.0" + resolve "^1.22.8" + stylelint@^16.1.0: version "16.10.0" resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-16.10.0.tgz#452b42a5d82f2ad910954eb2ba2b3a2ec583cd75" From 72a2773629d8105dc67fea13e73495204d549bec Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 18 Nov 2024 11:25:36 +0100 Subject: [PATCH 05/37] Start sending stable `m.marked_unread` events (#28478) * Start sending stable `m.marked_unread` events * Update tests --- src/utils/notifications.ts | 5 +---- .../views/context_menus/RoomGeneralContextMenu-test.tsx | 2 +- test/unit-tests/stores/RoomViewStore-test.ts | 2 +- .../stores/notifications/RoomNotificationState-test.ts | 2 +- test/unit-tests/utils/notifications-test.ts | 6 +++--- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index a131c3e55b..30d2948380 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -151,10 +151,7 @@ export async function setMarkedUnreadState(room: Room, client: MatrixClient, unr const currentState = getMarkedUnreadState(room); if (Boolean(currentState) !== unread) { - // Assuming MSC2867 passes FCP with no changes, we should update to start writing - // the flag to the stable prefix (or both) and then ultimately use only the - // stable prefix. - await client.setRoomAccountData(room.roomId, MARKED_UNREAD_TYPE_UNSTABLE, { unread }); + await client.setRoomAccountData(room.roomId, MARKED_UNREAD_TYPE_STABLE, { unread }); } } diff --git a/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx b/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx index 10de3996e6..9fc32dda29 100644 --- a/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx +++ b/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx @@ -150,7 +150,7 @@ describe("RoomGeneralContextMenu", () => { await sleep(0); - expect(mockClient.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", { + expect(mockClient.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "m.marked_unread", { unread: true, }); expect(onFinished).toHaveBeenCalled(); diff --git a/test/unit-tests/stores/RoomViewStore-test.ts b/test/unit-tests/stores/RoomViewStore-test.ts index 7d397397dc..c9b80553e5 100644 --- a/test/unit-tests/stores/RoomViewStore-test.ts +++ b/test/unit-tests/stores/RoomViewStore-test.ts @@ -338,7 +338,7 @@ describe("RoomViewStore", function () { }); dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); await untilDispatch(Action.ActiveRoomChanged, dis); - expect(mockClient.setRoomAccountData).toHaveBeenCalledWith(roomId, "com.famedly.marked_unread", { + expect(mockClient.setRoomAccountData).toHaveBeenCalledWith(roomId, "m.marked_unread", { unread: false, }); }); diff --git a/test/unit-tests/stores/notifications/RoomNotificationState-test.ts b/test/unit-tests/stores/notifications/RoomNotificationState-test.ts index 5ebbe3f1ad..396bb06ec6 100644 --- a/test/unit-tests/stores/notifications/RoomNotificationState-test.ts +++ b/test/unit-tests/stores/notifications/RoomNotificationState-test.ts @@ -91,7 +91,7 @@ describe("RoomNotificationState", () => { const listener = jest.fn(); roomNotifState.addListener(NotificationStateEvents.Update, listener); const accountDataEvent = { - getType: () => "com.famedly.marked_unread", + getType: () => "m.marked_unread", getContent: () => { return { unread: true }; }, diff --git a/test/unit-tests/utils/notifications-test.ts b/test/unit-tests/utils/notifications-test.ts index 67948ed217..8e33575fec 100644 --- a/test/unit-tests/utils/notifications-test.ts +++ b/test/unit-tests/utils/notifications-test.ts @@ -270,7 +270,7 @@ describe("notifications", () => { // set true, no existing event it("sets unread flag if event doesn't exist", async () => { await setMarkedUnreadState(room, client, true); - expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", { + expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "m.marked_unread", { unread: true, }); }); @@ -287,7 +287,7 @@ describe("notifications", () => { .fn() .mockReturnValue({ getContent: jest.fn().mockReturnValue({ unread: false }) }); await setMarkedUnreadState(room, client, true); - expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", { + expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "m.marked_unread", { unread: true, }); }); @@ -316,7 +316,7 @@ describe("notifications", () => { .fn() .mockReturnValue({ getContent: jest.fn().mockReturnValue({ unread: true }) }); await setMarkedUnreadState(room, client, false); - expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", { + expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "m.marked_unread", { unread: false, }); }); From 9b316e8e7fc49470237bc0adb9535226d6c33f8f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 18 Nov 2024 10:30:31 +0000 Subject: [PATCH 06/37] Check that the file the user chose has a MIME type of `image/*` (#28467) * Check that the file the user chose has a MIME type of `image/*` Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * i18n Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Optional Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * DRY Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update src/components/views/settings/AvatarSetting.tsx Co-authored-by: Florian Duros * prettier Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> Co-authored-by: Florian Duros --- .../views/elements/MiniAvatarUploader.tsx | 10 ++-- .../views/settings/AvatarSetting.tsx | 19 +++++++- src/i18n/strings/en_EN.json | 1 + .../elements/MiniAvatarUploader-test.tsx | 40 ++++++++++++++++ .../views/settings/AvatarSetting-test.tsx | 46 ++++++++++++++++++- 5 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 test/unit-tests/components/views/elements/MiniAvatarUploader-test.tsx diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index 8bbca5b309..cf5a239814 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -17,6 +17,7 @@ import { useTimeout } from "../../../hooks/useTimeout"; import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import AccessibleButton from "./AccessibleButton"; import Spinner from "./Spinner"; +import { getFileChanged } from "../settings/AvatarSetting.tsx"; export const AVATAR_SIZE = "52px"; @@ -72,11 +73,12 @@ const MiniAvatarUploader: React.FC = ({ onClick?.(ev); }} onChange={async (ev): Promise => { - if (!ev.target.files?.length) return; setBusy(true); - const file = ev.target.files[0]; - const { content_uri: uri } = await cli.uploadContent(file); - await setAvatarUrl(uri); + const file = getFileChanged(ev); + if (file) { + const { content_uri: uri } = await cli.uploadContent(file); + await setAvatarUrl(uri); + } setBusy(false); }} accept="image/*" diff --git a/src/components/views/settings/AvatarSetting.tsx b/src/components/views/settings/AvatarSetting.tsx index eaeabc641b..b6ce541590 100644 --- a/src/components/views/settings/AvatarSetting.tsx +++ b/src/components/views/settings/AvatarSetting.tsx @@ -19,6 +19,8 @@ import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import { useId } from "../../../utils/useId"; import AccessibleButton from "../elements/AccessibleButton"; import BaseAvatar from "../avatars/BaseAvatar"; +import Modal from "../../../Modal.tsx"; +import ErrorDialog from "../dialogs/ErrorDialog.tsx"; interface MenuProps { trigger: ReactNode; @@ -103,6 +105,18 @@ interface IProps { placeholderName: string; } +export function getFileChanged(e: React.ChangeEvent): File | null { + if (!e.target.files?.length) return null; + const file = e.target.files[0]; + if (file.type.startsWith("image/")) return file; + + Modal.createDialog(ErrorDialog, { + title: _t("upload_failed_title"), + description: _t("upload_file|not_image"), + }); + return null; +} + /** * Component for setting or removing an avatar on something (eg. a user or a room) */ @@ -139,7 +153,10 @@ const AvatarSetting: React.FC = ({ const onFileChanged = useCallback( (e: React.ChangeEvent) => { - if (e.target.files) onChange?.(e.target.files[0]); + const file = getFileChanged(e); + if (file) { + onChange?.(file); + } }, [onChange], ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4a524db97c..3b4765b0ad 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3742,6 +3742,7 @@ "error_files_too_large": "These files are too large to upload. The file size limit is %(limit)s.", "error_some_files_too_large": "Some files are too large to be uploaded. The file size limit is %(limit)s.", "error_title": "Upload Error", + "not_image": "The file you have chosen is not a valid image file.", "title": "Upload files", "title_progress": "Upload files (%(current)s of %(total)s)", "upload_all_button": "Upload all", diff --git a/test/unit-tests/components/views/elements/MiniAvatarUploader-test.tsx b/test/unit-tests/components/views/elements/MiniAvatarUploader-test.tsx new file mode 100644 index 0000000000..cf6ed6ae62 --- /dev/null +++ b/test/unit-tests/components/views/elements/MiniAvatarUploader-test.tsx @@ -0,0 +1,40 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { render } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; +import { mocked } from "jest-mock"; + +import MiniAvatarUploader from "../../../../../src/components/views/elements/MiniAvatarUploader.tsx"; +import { stubClient, withClientContextRenderOptions } from "../../../../test-utils"; + +const BASE64_GIF = "R0lGODlhAQABAAAAACw="; +const AVATAR_FILE = new File([Uint8Array.from(atob(BASE64_GIF), (c) => c.charCodeAt(0))], "avatar.gif", { + type: "image/gif", +}); + +describe("", () => { + it("calls setAvatarUrl when a file is uploaded", async () => { + const cli = stubClient(); + mocked(cli.uploadContent).mockResolvedValue({ content_uri: "mxc://example.com/1234" }); + + const setAvatarUrl = jest.fn(); + const user = userEvent.setup(); + + const { container, findByText } = render( + , + withClientContextRenderOptions(cli), + ); + + await findByText("Upload"); + await user.upload(container.querySelector("input")!, AVATAR_FILE); + + expect(cli.uploadContent).toHaveBeenCalledWith(AVATAR_FILE); + expect(setAvatarUrl).toHaveBeenCalledWith("mxc://example.com/1234"); + }); +}); diff --git a/test/unit-tests/components/views/settings/AvatarSetting-test.tsx b/test/unit-tests/components/views/settings/AvatarSetting-test.tsx index e3e2b1cf96..1b88c416bc 100644 --- a/test/unit-tests/components/views/settings/AvatarSetting-test.tsx +++ b/test/unit-tests/components/views/settings/AvatarSetting-test.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { render, screen } from "jest-matrix-react"; +import { render, screen, fireEvent } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import AvatarSetting from "../../../../../src/components/views/settings/AvatarSetting"; @@ -16,6 +16,9 @@ const BASE64_GIF = "R0lGODlhAQABAAAAACw="; const AVATAR_FILE = new File([Uint8Array.from(atob(BASE64_GIF), (c) => c.charCodeAt(0))], "avatar.gif", { type: "image/gif", }); +const GENERIC_FILE = new File([Uint8Array.from(atob(BASE64_GIF), (c) => c.charCodeAt(0))], "not-avatar.doc", { + type: "application/msword", +}); describe("", () => { beforeEach(() => { @@ -70,4 +73,45 @@ describe("", () => { expect(onChange).toHaveBeenCalledWith(AVATAR_FILE); }); + + it("should noop when selecting no file", async () => { + const onChange = jest.fn(); + + render( + , + ); + + const fileInput = screen.getByAltText("Upload"); + // Can't use userEvent.upload here as it doesn't support uploading invalid files + fireEvent.change(fileInput, { target: { files: [] } }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("should show error if user tries to use non-image file", async () => { + const onChange = jest.fn(); + + render( + , + ); + + const fileInput = screen.getByAltText("Upload"); + // Can't use userEvent.upload here as it doesn't support uploading invalid files + fireEvent.change(fileInput, { target: { files: [GENERIC_FILE] } }); + + expect(onChange).not.toHaveBeenCalled(); + await expect(screen.findByRole("heading", { name: "Upload Failed" })).resolves.toBeInTheDocument(); + }); }); From 08f41a48a8841b4523a4e9a3ba9a88942ab26ab5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:33:32 +0000 Subject: [PATCH 07/37] Bump cross-spawn from 7.0.3 to 7.0.5 (#28482) Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.5. - [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md) - [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.5) --- updated-dependencies: - dependency-name: cross-spawn dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index c5e5ecce0b..c47cf82029 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4750,9 +4750,9 @@ cronstrue@^2.41.0: integrity sha512-ULYhWIonJzlScCCQrPUG5uMXzXxSixty4djud9SS37DoNxDdkeRocxzHuAo4ImRBUK+mAuU5X9TSwEDccnnuPg== cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.5.tgz#910aac880ff5243da96b728bc6521a5f6c2f2f82" + integrity sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" @@ -8383,7 +8383,7 @@ matrix-web-i18n@^3.2.1: minimist "^1.2.8" walk "^2.3.15" -matrix-widget-api@^1.10.0, matrix-widget-api@^1.8.2: +matrix-widget-api@^1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.10.0.tgz#d31ea073a5871a1fb1a511ef900b0c125a37bf55" integrity sha512-rkAJ29briYV7TJnfBVLVSKtpeBrBju15JZFSDP6wj8YdbCu1bdmlplJayQ+vYaw1x4fzI49Q+Nz3E85s46sRDw== From 4f8e9eb9ac3a68efa791be4090dd4787e6a548ae Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 18 Nov 2024 15:47:15 +0000 Subject: [PATCH 08/37] Standardise icons using Compound Design Tokens (#28217) * De-duplicate icons using Compound Design Tokens Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Deduplicate more icons using Compound Design Tokens Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update icon Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Discard changes to res/css/structures/_RoomSearch.pcss * Update snapshots Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Discard changes to res/fonts/Open_Sans/LICENSE.txt * Discard changes to res/css/views/elements/_CopyableText.pcss * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../Polls-Timeline-tile-no-votes-linux.png | Bin 15395 -> 15412 bytes .../filtered-no-results-linux.png | Bin 23511 -> 23684 bytes .../filtered-one-result-linux.png | Bin 28698 -> 28861 bytes .../user-menu.spec.ts/user-menu-linux.png | Bin 13311 -> 13375 bytes res/css/structures/_RightPanel.pcss | 4 +- res/css/structures/_SpacePanel.pcss | 8 +-- res/css/structures/_SpaceRoomView.pcss | 2 +- res/css/structures/_UserMenu.pcss | 2 +- .../context_menus/_MessageContextMenu.pcss | 6 +- .../_RoomGeneralContextMenu.pcss | 12 ++-- .../_ConfirmSpaceUserActionDialog.pcss | 2 +- res/css/views/dialogs/_LeaveSpaceDialog.pcss | 2 +- .../_ManageRestrictedJoinRuleDialog.pcss | 2 +- res/css/views/dialogs/_SpotlightDialog.pcss | 10 ++-- res/css/views/elements/_InfoTooltip.pcss | 2 +- res/css/views/messages/_MessageActionBar.pcss | 4 ++ res/css/views/right_panel/_ThreadPanel.pcss | 2 +- res/css/views/rooms/_RoomList.pcss | 2 +- res/css/views/rooms/_RoomListHeader.pcss | 2 +- res/css/views/rooms/_RoomPreviewCard.pcss | 2 +- res/css/views/rooms/_RoomTile.pcss | 12 ++-- res/css/views/spaces/_SpacePublicShare.pcss | 2 +- res/img/element-icons/export.svg | 8 --- res/img/element-icons/info.svg | 4 -- res/img/element-icons/leave.svg | 7 --- res/img/element-icons/link.svg | 3 - res/img/element-icons/location.svg | 3 - res/img/element-icons/message/fwd.svg | 3 - res/img/element-icons/message/thread.svg | 1 - res/img/element-icons/room/apps.svg | 6 -- res/img/element-icons/room/members.svg | 7 --- .../element-icons/room/message-bar/reply.svg | 4 -- res/img/element-icons/room/room-summary.svg | 3 - res/img/element-icons/room/thread.svg | 1 - res/img/element-icons/roomlist/favorite.svg | 3 - .../element-icons/roomlist/member-plus.svg | 3 - src/components/views/location/MapFallback.tsx | 2 +- src/components/views/location/Marker.tsx | 2 +- src/components/views/location/ShareType.tsx | 2 +- .../views/messages/MessageActionBar.tsx | 4 +- .../EventTile/EventTileThreadToolbar.tsx | 2 +- .../tabs/user/SidebarUserSettingsTab.tsx | 13 +++-- .../views/spaces/QuickSettingsButton.tsx | 14 +++-- .../BeaconViewDialog-test.tsx.snap | 13 ++++- .../LocationViewDialog-test.tsx.snap | 13 ++++- .../__snapshots__/Marker-test.tsx.snap | 13 ++++- .../__snapshots__/SmartMarker-test.tsx.snap | 26 +++++++-- .../__snapshots__/MLocationBody-test.tsx.snap | 13 ++++- .../EventTileThreadToolbar-test.tsx.snap | 12 +++- .../SidebarUserSettingsTab-test.tsx.snap | 54 ++++++++++++++++-- 50 files changed, 190 insertions(+), 127 deletions(-) delete mode 100644 res/img/element-icons/export.svg delete mode 100644 res/img/element-icons/info.svg delete mode 100644 res/img/element-icons/leave.svg delete mode 100644 res/img/element-icons/link.svg delete mode 100644 res/img/element-icons/location.svg delete mode 100644 res/img/element-icons/message/fwd.svg delete mode 100644 res/img/element-icons/message/thread.svg delete mode 100644 res/img/element-icons/room/apps.svg delete mode 100644 res/img/element-icons/room/members.svg delete mode 100644 res/img/element-icons/room/message-bar/reply.svg delete mode 100644 res/img/element-icons/room/room-summary.svg delete mode 100644 res/img/element-icons/room/thread.svg delete mode 100644 res/img/element-icons/roomlist/favorite.svg delete mode 100644 res/img/element-icons/roomlist/member-plus.svg diff --git a/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png b/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png index 7e1195c82d6c1991b474091ea7f2cadadc382753..1ade373ba8f37f9c751c3d97b0fd6a31e65513ab 100644 GIT binary patch literal 15412 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 KPN|B`(|-Y%e>&Fy literal 23511 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 AfB*mh diff --git a/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-one-result-linux.png b/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-one-result-linux.png index 7bfa3229ed67f5b64462a5770b399834e71c6128..666dbe689ad4388a2b853f84bb116ccecc5b9b64 100644 GIT binary patch literal 28861 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 ZZgXAv2*IN(a77S|w77y;xyV=l{{@T0#W(-} literal 13311 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 diff --git a/res/css/structures/_RightPanel.pcss b/res/css/structures/_RightPanel.pcss index 57196985a9..0f796dbc96 100644 --- a/res/css/structures/_RightPanel.pcss +++ b/res/css/structures/_RightPanel.pcss @@ -27,7 +27,7 @@ Please see LICENSE files in the repository root for full details. /** Fixme - factor this out with the main header **/ .mx_RightPanel_threadsButton::before { - mask-image: url("$(res)/img/element-icons/room/thread.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/threads-solid.svg"); } .mx_RightPanel_notifsButton::before { @@ -36,7 +36,7 @@ Please see LICENSE files in the repository root for full details. } .mx_RightPanel_roomSummaryButton::before { - mask-image: url("$(res)/img/element-icons/room/room-summary.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); mask-position: center; } diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index fdaa930686..7ea717554b 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -211,11 +211,11 @@ Please see LICENSE files in the repository root for full details. } &.mx_SpaceButton_favourites .mx_SpaceButton_icon::before { - mask-image: url("$(res)/img/element-icons/roomlist/favorite.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/favourite-solid.svg"); } &.mx_SpaceButton_people .mx_SpaceButton_icon::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } &.mx_SpaceButton_orphans .mx_SpaceButton_icon::before { @@ -426,11 +426,11 @@ Please see LICENSE files in the repository root for full details. } .mx_SpacePanel_iconLeave::before { - mask-image: url("$(res)/img/element-icons/leave.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/leave.svg"); } .mx_SpacePanel_iconMembers::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } .mx_SpacePanel_iconPlus::before { diff --git a/res/css/structures/_SpaceRoomView.pcss b/res/css/structures/_SpaceRoomView.pcss index c54bc53dc2..d756d51d65 100644 --- a/res/css/structures/_SpaceRoomView.pcss +++ b/res/css/structures/_SpaceRoomView.pcss @@ -248,7 +248,7 @@ Please see LICENSE files in the repository root for full details. } .mx_SpaceRoomView_privateScope_justMeButton::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } .mx_SpaceRoomView_privateScope_meAndMyTeammatesButton::before { diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index 7cf3845027..741a4e90dc 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -197,7 +197,7 @@ Please see LICENSE files in the repository root for full details. } .mx_UserMenu_iconSignOut::before { - mask-image: url("$(res)/img/element-icons/leave.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/leave.svg"); } .mx_UserMenu_iconQr::before { diff --git a/res/css/views/context_menus/_MessageContextMenu.pcss b/res/css/views/context_menus/_MessageContextMenu.pcss index e06782ebe9..a73dab9982 100644 --- a/res/css/views/context_menus/_MessageContextMenu.pcss +++ b/res/css/views/context_menus/_MessageContextMenu.pcss @@ -33,7 +33,7 @@ Please see LICENSE files in the repository root for full details. } .mx_MessageContextMenu_iconLink::before { - mask-image: url("$(res)/img/element-icons/link.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); } .mx_MessageContextMenu_iconPermalink::before { @@ -53,7 +53,7 @@ Please see LICENSE files in the repository root for full details. } .mx_MessageContextMenu_iconForward::before { - mask-image: url("$(res)/img/element-icons/message/fwd.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/forward.svg"); } .mx_MessageContextMenu_iconRedact::before { @@ -96,7 +96,7 @@ Please see LICENSE files in the repository root for full details. } .mx_MessageContextMenu_iconReplyInThread::before { - mask-image: url("$(res)/img/element-icons/message/thread.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/threads.svg"); } .mx_MessageContextMenu_iconReact::before { diff --git a/res/css/views/context_menus/_RoomGeneralContextMenu.pcss b/res/css/views/context_menus/_RoomGeneralContextMenu.pcss index 90602538f0..0eb51420bb 100644 --- a/res/css/views/context_menus/_RoomGeneralContextMenu.pcss +++ b/res/css/views/context_menus/_RoomGeneralContextMenu.pcss @@ -1,5 +1,5 @@ .mx_RoomGeneralContextMenu_iconStar::before { - mask-image: url("$(res)/img/element-icons/roomlist/favorite.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/favourite-solid.svg"); } .mx_RoomGeneralContextMenu_iconArrowDown::before { @@ -31,7 +31,7 @@ } .mx_RoomGeneralContextMenu_iconPeople::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } .mx_RoomGeneralContextMenu_iconFiles::before { @@ -43,7 +43,7 @@ } .mx_RoomGeneralContextMenu_iconWidgets::before { - mask-image: url("$(res)/img/element-icons/room/apps.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/extensions-solid.svg"); } .mx_RoomGeneralContextMenu_iconSettings::before { @@ -51,7 +51,7 @@ } .mx_RoomGeneralContextMenu_iconExport::before { - mask-image: url("$(res)/img/element-icons/export.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/export-archive.svg"); } .mx_RoomGeneralContextMenu_iconDeveloperTools::before { @@ -59,7 +59,7 @@ } .mx_RoomGeneralContextMenu_iconCopyLink::before { - mask-image: url("$(res)/img/element-icons/link.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); } .mx_RoomGeneralContextMenu_iconInvite::before { @@ -67,5 +67,5 @@ } .mx_RoomGeneralContextMenu_iconSignOut::before { - mask-image: url("$(res)/img/element-icons/leave.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/leave.svg"); } diff --git a/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.pcss b/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.pcss index 7c1828183a..3b91eddc8b 100644 --- a/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.pcss +++ b/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.pcss @@ -51,7 +51,7 @@ Please see LICENSE files in the repository root for full details. background-color: $secondary-content; mask-repeat: no-repeat; mask-size: contain; - mask-image: url("$(res)/img/element-icons/room/room-summary.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); mask-position: center; } } diff --git a/res/css/views/dialogs/_LeaveSpaceDialog.pcss b/res/css/views/dialogs/_LeaveSpaceDialog.pcss index b332942f75..b3e3878276 100644 --- a/res/css/views/dialogs/_LeaveSpaceDialog.pcss +++ b/res/css/views/dialogs/_LeaveSpaceDialog.pcss @@ -45,7 +45,7 @@ Please see LICENSE files in the repository root for full details. background-color: $secondary-content; mask-repeat: no-repeat; mask-size: contain; - mask-image: url("$(res)/img/element-icons/room/room-summary.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); mask-position: center; } } diff --git a/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss index f6635b9791..a6b9fe0304 100644 --- a/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss +++ b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss @@ -108,7 +108,7 @@ Please see LICENSE files in the repository root for full details. background-color: $secondary-content; mask-repeat: no-repeat; mask-size: contain; - mask-image: url("$(res)/img/element-icons/room/room-summary.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); mask-position: center; } } diff --git a/res/css/views/dialogs/_SpotlightDialog.pcss b/res/css/views/dialogs/_SpotlightDialog.pcss index eff7bd0e12..c4f94bfde9 100644 --- a/res/css/views/dialogs/_SpotlightDialog.pcss +++ b/res/css/views/dialogs/_SpotlightDialog.pcss @@ -93,7 +93,7 @@ Please see LICENSE files in the repository root for full details. } &.mx_SpotlightDialog_filterPeople::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } &.mx_SpotlightDialog_filterPublicRooms::before { @@ -400,7 +400,7 @@ Please see LICENSE files in the repository root for full details. } .mx_SpotlightDialog_inviteLink .mx_AccessibleButton::before { - mask-image: url("$(res)/img/element-icons/link.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); } .mx_SpotlightDialog_createRoom .mx_AccessibleButton::before { @@ -432,7 +432,7 @@ Please see LICENSE files in the repository root for full details. } .mx_SpotlightDialog_startChat::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } .mx_SpotlightDialog_joinRoomAlias::before { @@ -512,11 +512,11 @@ Please see LICENSE files in the repository root for full details. } &.mx_SpotlightDialog_metaspaceResult_favourites-space { - mask-image: url("$(res)/img/element-icons/roomlist/favorite.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/favourite-solid.svg"); } &.mx_SpotlightDialog_metaspaceResult_people-space { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } &.mx_SpotlightDialog_metaspaceResult_orphans-space { diff --git a/res/css/views/elements/_InfoTooltip.pcss b/res/css/views/elements/_InfoTooltip.pcss index 0329f6a63b..dcec1410f1 100644 --- a/res/css/views/elements/_InfoTooltip.pcss +++ b/res/css/views/elements/_InfoTooltip.pcss @@ -25,7 +25,7 @@ Please see LICENSE files in the repository root for full details. } .mx_InfoTooltip_icon_info::before { - mask-image: url("$(res)/img/element-icons/info.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/info.svg"); } .mx_InfoTooltip_icon_warning::before { diff --git a/res/css/views/messages/_MessageActionBar.pcss b/res/css/views/messages/_MessageActionBar.pcss index fd9012ed28..cdfc3693d5 100644 --- a/res/css/views/messages/_MessageActionBar.pcss +++ b/res/css/views/messages/_MessageActionBar.pcss @@ -108,6 +108,10 @@ Please see LICENSE files in the repository root for full details. color: var(--cpd-color-icon-primary); } + &.mx_MessageActionBar_threadButton { + --MessageActionBar-icon-size: 20px; + } + &.mx_MessageActionBar_retryButton { --MessageActionBar-icon-size: 16px; } diff --git a/res/css/views/right_panel/_ThreadPanel.pcss b/res/css/views/right_panel/_ThreadPanel.pcss index 93efded304..1f9d1e0562 100644 --- a/res/css/views/right_panel/_ThreadPanel.pcss +++ b/res/css/views/right_panel/_ThreadPanel.pcss @@ -165,7 +165,7 @@ Please see LICENSE files in the repository root for full details. } .mx_ThreadPanel_copyLinkToThread::before { - mask-image: url("$(res)/img/element-icons/link.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); } .mx_ContextualMenu_wrapper { diff --git a/res/css/views/rooms/_RoomList.pcss b/res/css/views/rooms/_RoomList.pcss index 4ceba9a20a..97b1e76cef 100644 --- a/res/css/views/rooms/_RoomList.pcss +++ b/res/css/views/rooms/_RoomList.pcss @@ -29,7 +29,7 @@ Please see LICENSE files in the repository root for full details. mask-image: url("$(res)/img/element-icons/roomlist/dialpad.svg"); } .mx_RoomList_iconStartChat::before { - mask-image: url("$(res)/img/element-icons/roomlist/member-plus.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-add-solid.svg"); } .mx_RoomList_iconInvite::before { mask-image: url("$(res)/img/element-icons/room/share.svg"); diff --git a/res/css/views/rooms/_RoomListHeader.pcss b/res/css/views/rooms/_RoomListHeader.pcss index 6fbd2a38db..fa0e0b24eb 100644 --- a/res/css/views/rooms/_RoomListHeader.pcss +++ b/res/css/views/rooms/_RoomListHeader.pcss @@ -92,7 +92,7 @@ Please see LICENSE files in the repository root for full details. mask-image: url("$(res)/img/element-icons/room/invite.svg"); } .mx_RoomListHeader_iconStartChat::before { - mask-image: url("$(res)/img/element-icons/roomlist/member-plus.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-add-solid.svg"); } .mx_RoomListHeader_iconNewRoom::before { mask-image: url("$(res)/img/element-icons/roomlist/hash-plus.svg"); diff --git a/res/css/views/rooms/_RoomPreviewCard.pcss b/res/css/views/rooms/_RoomPreviewCard.pcss index 6b070de27f..f96b705cc2 100644 --- a/res/css/views/rooms/_RoomPreviewCard.pcss +++ b/res/css/views/rooms/_RoomPreviewCard.pcss @@ -34,7 +34,7 @@ Please see LICENSE files in the repository root for full details. mask-repeat: no-repeat; mask-position: center; mask-size: contain; - mask-image: url("$(res)/img/element-icons/room/room-summary.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); background-color: $secondary-content; } } diff --git a/res/css/views/rooms/_RoomTile.pcss b/res/css/views/rooms/_RoomTile.pcss index 1550fc84fa..53f9c10f1b 100644 --- a/res/css/views/rooms/_RoomTile.pcss +++ b/res/css/views/rooms/_RoomTile.pcss @@ -182,7 +182,7 @@ Please see LICENSE files in the repository root for full details. .mx_RoomTile_contextMenu { .mx_RoomTile_iconStar::before { - mask-image: url("$(res)/img/element-icons/roomlist/favorite.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/favourite-solid.svg"); } .mx_RoomTile_iconArrowDown::before { @@ -206,7 +206,7 @@ Please see LICENSE files in the repository root for full details. } .mx_RoomTile_iconPeople::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } .mx_RoomTile_iconFiles::before { @@ -218,7 +218,7 @@ Please see LICENSE files in the repository root for full details. } .mx_RoomTile_iconWidgets::before { - mask-image: url("$(res)/img/element-icons/room/apps.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/extensions-solid.svg"); } .mx_RoomTile_iconSettings::before { @@ -226,11 +226,11 @@ Please see LICENSE files in the repository root for full details. } .mx_RoomTile_iconExport::before { - mask-image: url("$(res)/img/element-icons/export.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/export-archive.svg"); } .mx_RoomTile_iconCopyLink::before { - mask-image: url("$(res)/img/element-icons/link.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); } .mx_RoomTile_iconInvite::before { @@ -238,6 +238,6 @@ Please see LICENSE files in the repository root for full details. } .mx_RoomTile_iconSignOut::before { - mask-image: url("$(res)/img/element-icons/leave.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/leave.svg"); } } diff --git a/res/css/views/spaces/_SpacePublicShare.pcss b/res/css/views/spaces/_SpacePublicShare.pcss index 58cf3659ae..ddda97b493 100644 --- a/res/css/views/spaces/_SpacePublicShare.pcss +++ b/res/css/views/spaces/_SpacePublicShare.pcss @@ -11,7 +11,7 @@ Please see LICENSE files in the repository root for full details. @mixin SpacePillButton; &.mx_SpacePublicShare_shareButton::before { - mask-image: url("$(res)/img/element-icons/link.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); } &.mx_SpacePublicShare_inviteButton::before { diff --git a/res/img/element-icons/export.svg b/res/img/element-icons/export.svg deleted file mode 100644 index 038866c294..0000000000 --- a/res/img/element-icons/export.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/res/img/element-icons/info.svg b/res/img/element-icons/info.svg deleted file mode 100644 index b5769074ab..0000000000 --- a/res/img/element-icons/info.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/res/img/element-icons/leave.svg b/res/img/element-icons/leave.svg deleted file mode 100644 index 773e27d4ce..0000000000 --- a/res/img/element-icons/leave.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/res/img/element-icons/link.svg b/res/img/element-icons/link.svg deleted file mode 100644 index 07dbdc0ccc..0000000000 --- a/res/img/element-icons/link.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/location.svg b/res/img/element-icons/location.svg deleted file mode 100644 index fc8337a43b..0000000000 --- a/res/img/element-icons/location.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/message/fwd.svg b/res/img/element-icons/message/fwd.svg deleted file mode 100644 index 8bcc70d092..0000000000 --- a/res/img/element-icons/message/fwd.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/message/thread.svg b/res/img/element-icons/message/thread.svg deleted file mode 100644 index dc23d8c14a..0000000000 --- a/res/img/element-icons/message/thread.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/res/img/element-icons/room/apps.svg b/res/img/element-icons/room/apps.svg deleted file mode 100644 index c90704752c..0000000000 --- a/res/img/element-icons/room/apps.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/img/element-icons/room/members.svg b/res/img/element-icons/room/members.svg deleted file mode 100644 index 50aa0aa466..0000000000 --- a/res/img/element-icons/room/members.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/res/img/element-icons/room/message-bar/reply.svg b/res/img/element-icons/room/message-bar/reply.svg deleted file mode 100644 index c32848a0b0..0000000000 --- a/res/img/element-icons/room/message-bar/reply.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/res/img/element-icons/room/room-summary.svg b/res/img/element-icons/room/room-summary.svg deleted file mode 100644 index b6ac258b18..0000000000 --- a/res/img/element-icons/room/room-summary.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/room/thread.svg b/res/img/element-icons/room/thread.svg deleted file mode 100644 index d1b8b35c91..0000000000 --- a/res/img/element-icons/room/thread.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/res/img/element-icons/roomlist/favorite.svg b/res/img/element-icons/roomlist/favorite.svg deleted file mode 100644 index c601b69808..0000000000 --- a/res/img/element-icons/roomlist/favorite.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/roomlist/member-plus.svg b/res/img/element-icons/roomlist/member-plus.svg deleted file mode 100644 index 71269b54ca..0000000000 --- a/res/img/element-icons/roomlist/member-plus.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/components/views/location/MapFallback.tsx b/src/components/views/location/MapFallback.tsx index cb1a579764..101a5d8066 100644 --- a/src/components/views/location/MapFallback.tsx +++ b/src/components/views/location/MapFallback.tsx @@ -8,8 +8,8 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import classNames from "classnames"; +import LocationMarkerIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid"; -import { Icon as LocationMarkerIcon } from "../../../../res/img/element-icons/location.svg"; import { Icon as MapFallbackImage } from "../../../../res/img/location/map.svg"; import Spinner from "../elements/Spinner"; diff --git a/src/components/views/location/Marker.tsx b/src/components/views/location/Marker.tsx index 93a5c28831..58e1ce30fb 100644 --- a/src/components/views/location/Marker.tsx +++ b/src/components/views/location/Marker.tsx @@ -9,8 +9,8 @@ Please see LICENSE files in the repository root for full details. import React, { ReactNode, useState } from "react"; import classNames from "classnames"; import { RoomMember } from "matrix-js-sdk/src/matrix"; +import LocationIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid"; -import { Icon as LocationIcon } from "../../../../res/img/element-icons/location.svg"; import { getUserNameColorClass } from "../../../utils/FormattingUtils"; import MemberAvatar from "../avatars/MemberAvatar"; diff --git a/src/components/views/location/ShareType.tsx b/src/components/views/location/ShareType.tsx index f580d4638d..0aa31b7bd4 100644 --- a/src/components/views/location/ShareType.tsx +++ b/src/components/views/location/ShareType.tsx @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { HTMLAttributes, useContext } from "react"; +import LocationIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { _t } from "../../../languageHandler"; @@ -14,7 +15,6 @@ import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import BaseAvatar from "../avatars/BaseAvatar"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import Heading from "../typography/Heading"; -import { Icon as LocationIcon } from "../../../../res/img/element-icons/location.svg"; import { LocationShareType } from "./shareLocation"; import StyledLiveBeaconIcon from "../beacon/StyledLiveBeaconIcon"; diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 0776c51437..fdd0200429 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -28,11 +28,11 @@ import { ReplyIcon, DeleteIcon, RestartIcon, + ThreadsIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { Icon as EditIcon } from "../../../../res/img/element-icons/room/message-bar/edit.svg"; import { Icon as EmojiIcon } from "../../../../res/img/element-icons/room/message-bar/emoji.svg"; -import { Icon as ThreadIcon } from "../../../../res/img/element-icons/message/thread.svg"; import { Icon as ExpandMessageIcon } from "../../../../res/img/element-icons/expand-message.svg"; import { Icon as CollapseMessageIcon } from "../../../../res/img/element-icons/collapse-message.svg"; import type { Relations } from "matrix-js-sdk/src/matrix"; @@ -243,7 +243,7 @@ const ReplyInThreadButton: React.FC = ({ mxEvent }) => { onContextMenu={onClick} placement="left" > - + ); }; diff --git a/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx b/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx index 603bc9953e..ad1a6ce9a6 100644 --- a/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx +++ b/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx @@ -7,11 +7,11 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; +import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link"; import { RovingAccessibleButton } from "../../../../accessibility/RovingTabIndex"; import Toolbar from "../../../../accessibility/Toolbar"; import { _t } from "../../../../languageHandler"; -import { Icon as LinkIcon } from "../../../../../res/img/element-icons/link.svg"; import { Icon as ViewInRoomIcon } from "../../../../../res/img/element-icons/view-in-room.svg"; import { ButtonEvent } from "../../elements/AccessibleButton"; diff --git a/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx index 41ebbaf669..0971ece699 100644 --- a/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx @@ -7,10 +7,13 @@ Please see LICENSE files in the repository root for full details. */ import React, { ChangeEvent, useMemo } from "react"; -import { VideoCallSolidIcon, HomeSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { + VideoCallSolidIcon, + HomeSolidIcon, + UserProfileSolidIcon, + FavouriteSolidIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; -import { Icon as FavoriteIcon } from "../../../../../../res/img/element-icons/roomlist/favorite.svg"; -import { Icon as MembersIcon } from "../../../../../../res/img/element-icons/room/members.svg"; import { Icon as HashCircleIcon } from "../../../../../../res/img/element-icons/roomlist/hash-circle.svg"; import { _t } from "../../../../../languageHandler"; import SettingsStore from "../../../../../settings/SettingsStore"; @@ -112,7 +115,7 @@ const SidebarUserSettingsTab: React.FC = () => { className="mx_SidebarUserSettingsTab_checkbox" > - + {_t("common|favourites")} @@ -126,7 +129,7 @@ const SidebarUserSettingsTab: React.FC = () => { className="mx_SidebarUserSettingsTab_checkbox" > - + {_t("common|people")} diff --git a/src/components/views/spaces/QuickSettingsButton.tsx b/src/components/views/spaces/QuickSettingsButton.tsx index c21e0a71e2..161290fca8 100644 --- a/src/components/views/spaces/QuickSettingsButton.tsx +++ b/src/components/views/spaces/QuickSettingsButton.tsx @@ -8,7 +8,11 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import classNames from "classnames"; -import EllipsisIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; +import { + OverflowHorizontalIcon, + UserProfileSolidIcon, + FavouriteSolidIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../../../languageHandler"; import ContextMenu, { alwaysAboveRightOf, ChevronFace, useContextMenu } from "../../structures/ContextMenu"; @@ -22,8 +26,6 @@ import { Action } from "../../../dispatcher/actions"; import { UserTab } from "../dialogs/UserTab"; import QuickThemeSwitcher from "./QuickThemeSwitcher"; import { Icon as PinUprightIcon } from "../../../../res/img/element-icons/room/pin-upright.svg"; -import { Icon as MembersIcon } from "../../../../res/img/element-icons/room/members.svg"; -import { Icon as FavoriteIcon } from "../../../../res/img/element-icons/roomlist/favorite.svg"; import Modal from "../../../Modal"; import DevtoolsDialog from "../dialogs/DevtoolsDialog"; import { SdkContextClass } from "../../../contexts/SDKContext"; @@ -89,7 +91,7 @@ const QuickSettingsButton: React.FC<{ checked={!!favouritesEnabled} onChange={onMetaSpaceChangeFactory(MetaSpace.Favourites, "WebQuickSettingsPinToSidebarCheckbox")} > - + {_t("common|favourites")} - + {_t("common|people")} - + {_t("quick_settings|sidebar_settings")} diff --git a/test/unit-tests/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap b/test/unit-tests/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap index a87001baae..1bf8cba6bb 100644 --- a/test/unit-tests/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap +++ b/test/unit-tests/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap @@ -8,9 +8,18 @@ exports[` renders a fallback when there are no locations 1`]
-
+ fill="currentColor" + height="1em" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + + diff --git a/test/unit-tests/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap b/test/unit-tests/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap index edd05cc260..36152bc0f4 100644 --- a/test/unit-tests/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap +++ b/test/unit-tests/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap @@ -13,9 +13,18 @@ exports[` renders map correctly 1`] = `
-
+ fill="currentColor" + height="1em" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + +
diff --git a/test/unit-tests/components/views/location/__snapshots__/Marker-test.tsx.snap b/test/unit-tests/components/views/location/__snapshots__/Marker-test.tsx.snap index e7fce5e5a2..635119d55c 100644 --- a/test/unit-tests/components/views/location/__snapshots__/Marker-test.tsx.snap +++ b/test/unit-tests/components/views/location/__snapshots__/Marker-test.tsx.snap @@ -9,9 +9,18 @@ exports[` renders with location icon when no room member 1`] = `
-
+ fill="currentColor" + height="1em" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + +
diff --git a/test/unit-tests/components/views/location/__snapshots__/SmartMarker-test.tsx.snap b/test/unit-tests/components/views/location/__snapshots__/SmartMarker-test.tsx.snap index 1e043c9db8..f2b3e4cc8b 100644 --- a/test/unit-tests/components/views/location/__snapshots__/SmartMarker-test.tsx.snap +++ b/test/unit-tests/components/views/location/__snapshots__/SmartMarker-test.tsx.snap @@ -9,9 +9,18 @@ exports[` creates a marker on mount 1`] = `
-
+ fill="currentColor" + height="1em" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + +
@@ -27,9 +36,18 @@ exports[` removes marker on unmount 1`] = `
-
+ fill="currentColor" + height="1em" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + +
diff --git a/test/unit-tests/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap b/test/unit-tests/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap index 5a61ada30f..7b919b5326 100644 --- a/test/unit-tests/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap +++ b/test/unit-tests/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap @@ -49,9 +49,18 @@ exports[`MLocationBody without error renders map correctly 1`] =
-
+ fill="currentColor" + height="1em" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + +
diff --git a/test/unit-tests/components/views/rooms/EventTile/__snapshots__/EventTileThreadToolbar-test.tsx.snap b/test/unit-tests/components/views/rooms/EventTile/__snapshots__/EventTileThreadToolbar-test.tsx.snap index 133531b447..4597bd83bc 100644 --- a/test/unit-tests/components/views/rooms/EventTile/__snapshots__/EventTileThreadToolbar-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/EventTile/__snapshots__/EventTileThreadToolbar-test.tsx.snap @@ -22,7 +22,17 @@ exports[`EventTileThreadToolbar renders 1`] = ` role="button" tabindex="-1" > -
+ + +
diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SidebarUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SidebarUserSettingsTab-test.tsx.snap index 145210fa7b..cd87bbb165 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SidebarUserSettingsTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SidebarUserSettingsTab-test.tsx.snap @@ -135,7 +135,17 @@ exports[` renders sidebar settings with guest spa url
-
+ + + Favourites
renders sidebar settings with guest spa url
-
+ + + + People
renders sidebar settings without guest spa u
-
+ + + Favourites
renders sidebar settings without guest spa u
-
+ + + + People
Date: Mon, 18 Nov 2024 18:27:34 -0500 Subject: [PATCH 09/37] Listen to events so that encryption icon updates when status changes (#28407) * listen to events so that encryption icon updates when status changes * remove debugging message --- src/hooks/useEncryptionStatus.ts | 37 +++++++++--- .../views/rooms/RoomHeader-test.tsx | 60 ++++++++++++++++++- 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/hooks/useEncryptionStatus.ts b/src/hooks/useEncryptionStatus.ts index 30417f7821..686f68f25e 100644 --- a/src/hooks/useEncryptionStatus.ts +++ b/src/hooks/useEncryptionStatus.ts @@ -6,21 +6,40 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; -import { useEffect, useState } from "react"; +import { CryptoEvent, MatrixClient, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { useEffect, useMemo, useState } from "react"; +import { throttle } from "lodash"; import { E2EStatus, shieldStatusForRoom } from "../utils/ShieldUtils"; +import { useTypedEventEmitter } from "./useEventEmitter"; export function useEncryptionStatus(client: MatrixClient, room: Room): E2EStatus | null { const [e2eStatus, setE2eStatus] = useState(null); - useEffect(() => { - if (client.getCrypto()) { - shieldStatusForRoom(client, room).then((e2eStatus) => { - setE2eStatus(e2eStatus); - }); - } - }, [client, room]); + const updateEncryptionStatus = useMemo( + () => + throttle( + () => { + if (client.getCrypto()) { + shieldStatusForRoom(client, room).then((e2eStatus) => { + setE2eStatus(e2eStatus); + }); + } + }, + 250, + { leading: true, trailing: true }, + ), + [client, room], + ); + + useEffect(updateEncryptionStatus, [updateEncryptionStatus]); + + // shieldStatusForRoom depends on the room membership, each member's trust + // status for each member, and each member's devices, so we update the + // status whenever any of those changes. + useTypedEventEmitter(room, RoomStateEvent.Members, updateEncryptionStatus); + useTypedEventEmitter(client, CryptoEvent.UserTrustStatusChanged, updateEncryptionStatus); + useTypedEventEmitter(client, CryptoEvent.DevicesUpdated, updateEncryptionStatus); return e2eStatus; } diff --git a/test/unit-tests/components/views/rooms/RoomHeader-test.tsx b/test/unit-tests/components/views/rooms/RoomHeader-test.tsx index a7e556e452..1be9c77713 100644 --- a/test/unit-tests/components/views/rooms/RoomHeader-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomHeader-test.tsx @@ -8,9 +8,19 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; -import { EventType, JoinRule, MatrixEvent, PendingEventOrdering, Room, RoomMember } from "matrix-js-sdk/src/matrix"; -import { KnownMembership } from "matrix-js-sdk/src/types"; import { + EventType, + JoinRule, + MatrixEvent, + PendingEventOrdering, + Room, + RoomStateEvent, + RoomMember, +} from "matrix-js-sdk/src/matrix"; +import { KnownMembership } from "matrix-js-sdk/src/types"; +import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; +import { + act, createEvent, fireEvent, getAllByLabelText, @@ -632,6 +642,52 @@ describe("RoomHeader", () => { expect(asFragment()).toMatchSnapshot(); }); + + it("updates the icon when the encryption status changes", async () => { + // The room starts verified + jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Verified); + render(, getWrapper()); + await waitFor(() => expect(getByLabelText(document.body, "Verified")).toBeInTheDocument()); + + // A new member joins, and the room becomes unverified + jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Warning); + act(() => { + room.emit( + RoomStateEvent.Members, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "join", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + room.currentState, + new RoomMember(room.roomId, "@alice:example.org"), + ); + }); + await waitFor(() => expect(getByLabelText(document.body, "Untrusted")).toBeInTheDocument()); + + // The user becomes verified + jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Verified); + act(() => { + MatrixClientPeg.get()!.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(true, true, true, false), + ); + }); + await waitFor(() => expect(getByLabelText(document.body, "Verified")).toBeInTheDocument()); + + // An unverified device is added + jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Warning); + act(() => { + MatrixClientPeg.get()!.emit(CryptoEvent.DevicesUpdated, ["@alice:example.org"], false); + }); + await waitFor(() => expect(getByLabelText(document.body, "Untrusted")).toBeInTheDocument()); + }); }); it("renders additionalButtons", async () => { From 0ae74a9e1f3f253228d095311498a9f69338df48 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Mon, 18 Nov 2024 22:17:24 -0500 Subject: [PATCH 10/37] Reset cross-signing before backup when resetting both (#28402) * reset cross-signing before backup when resetting both * add test for AccessSecretStorageDialog * fix unit test --- src/SecurityManager.ts | 26 +++++++--- .../security/CreateSecretStorageDialog.tsx | 22 ++++++++- .../security/AccessSecretStorageDialog.tsx | 31 +++--------- .../security/RestoreKeyBackupDialog.tsx | 2 +- .../views/settings/SecureBackupPanel.tsx | 2 +- src/stores/SetupEncryptionStore.ts | 49 ++++--------------- test/unit-tests/SecurityManager-test.ts | 2 +- .../AccessSecretStorageDialog-test.tsx | 30 ++++++++++++ .../CreateSecretStorageDialog-test.tsx | 34 +++++++++++++ .../stores/SetupEncryptionStore-test.ts | 13 ++--- 10 files changed, 127 insertions(+), 84 deletions(-) diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index cf8d40acc8..f97dff786f 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -186,6 +186,15 @@ export async function withSecretStorageKeyCache(func: () => Promise): Prom } } +export interface AccessSecretStorageOpts { + /** Reset secret storage even if it's already set up. */ + forceReset?: boolean; + /** Create new cross-signing keys. Only applicable if `forceReset` is `true`. */ + resetCrossSigning?: boolean; + /** The cached account password, if available. */ + accountPassword?: string; +} + /** * This helper should be used whenever you need to access secret storage. It * ensures that secret storage (and also cross-signing since they each depend on @@ -205,14 +214,17 @@ export async function withSecretStorageKeyCache(func: () => Promise): Prom * * @param {Function} [func] An operation to perform once secret storage has been * bootstrapped. Optional. - * @param {bool} [forceReset] Reset secret storage even if it's already set up + * @param [opts] The options to use when accessing secret storage. */ -export async function accessSecretStorage(func = async (): Promise => {}, forceReset = false): Promise { - await withSecretStorageKeyCache(() => doAccessSecretStorage(func, forceReset)); +export async function accessSecretStorage( + func = async (): Promise => {}, + opts: AccessSecretStorageOpts = {}, +): Promise { + await withSecretStorageKeyCache(() => doAccessSecretStorage(func, opts)); } /** Helper for {@link #accessSecretStorage} */ -async function doAccessSecretStorage(func: () => Promise, forceReset: boolean): Promise { +async function doAccessSecretStorage(func: () => Promise, opts: AccessSecretStorageOpts): Promise { try { const cli = MatrixClientPeg.safeGet(); const crypto = cli.getCrypto(); @@ -221,7 +233,7 @@ async function doAccessSecretStorage(func: () => Promise, forceReset: bool } let createNew = false; - if (forceReset) { + if (opts.forceReset) { logger.debug("accessSecretStorage: resetting 4S"); createNew = true; } else if (!(await cli.secretStorage.hasKey())) { @@ -234,9 +246,7 @@ async function doAccessSecretStorage(func: () => Promise, forceReset: bool // passphrase creation. const { finished } = Modal.createDialog( lazy(() => import("./async-components/views/dialogs/security/CreateSecretStorageDialog")), - { - forceReset, - }, + opts, undefined, /* priority = */ false, /* static = */ true, diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 1e87b5b826..1258bde2ca 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -58,6 +58,7 @@ interface IProps { hasCancel?: boolean; accountPassword?: string; forceReset?: boolean; + resetCrossSigning?: boolean; onFinished(ok?: boolean): void; } @@ -91,6 +92,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent = { hasCancel: true, forceReset: false, + resetCrossSigning: false, }; private recoveryKey?: GeneratedSecretStorageKey; private recoveryKeyNode = createRef(); @@ -270,7 +272,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent => { const cli = MatrixClientPeg.safeGet(); const crypto = cli.getCrypto()!; - const { forceReset } = this.props; + const { forceReset, resetCrossSigning } = this.props; let backupInfo; // First, unless we know we want to do a reset, we see if there is an existing key backup @@ -292,12 +294,28 @@ export default class CreateSecretStorageDialog extends React.PureComponent this.recoveryKey!, - setupNewKeyBackup: true, setupNewSecretStorage: true, }); + if (resetCrossSigning) { + logger.log("Resetting cross signing"); + await crypto.bootstrapCrossSigning({ + authUploadDeviceSigningKeys: this.doBootstrapUIAuth, + setupNewCrossSigning: true, + }); + } + logger.log("Resetting key backup"); + await crypto.resetKeyBackup(); } else { // For password authentication users after 2020-09, this cross-signing // step will be a no-op since it is now setup during registration or login diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index 7361e3982d..d9c97261dd 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -19,7 +19,6 @@ import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton"; import { _t } from "../../../../languageHandler"; import { accessSecretStorage } from "../../../../SecurityManager"; import Modal from "../../../../Modal"; -import InteractiveAuthDialog from "../InteractiveAuthDialog"; import DialogButtons from "../../elements/DialogButtons"; import BaseDialog from "../BaseDialog"; import { chromeFileInputFix } from "../../../../utils/BrowserWorkarounds"; @@ -226,28 +225,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent => { - // Now reset cross-signing so everything Just Works™ again. - const cli = MatrixClientPeg.safeGet(); - await cli.getCrypto()?.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async (makeRequest): Promise => { - const { finished } = Modal.createDialog(InteractiveAuthDialog, { - title: _t("encryption|bootstrap_title"), - matrixClient: cli, - makeRequest, - }); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Cross-signing key upload auth canceled"); - } - }, - setupNewCrossSigning: true, - }); - - // Now we can indicate that the user is done pressing buttons, finally. - // Upstream flows will detect the new secret storage, key backup, etc and use it. - this.props.onFinished({}); - }, true); + await accessSecretStorage( + async (): Promise => { + // Now we can indicate that the user is done pressing buttons, finally. + // Upstream flows will detect the new secret storage, key backup, etc and use it. + this.props.onFinished({}); + }, + { forceReset: true, resetCrossSigning: true }, + ); } catch (e) { logger.error(e); this.props.onFinished(false); diff --git a/src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx b/src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx index af84feb848..ec85e72ac9 100644 --- a/src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx +++ b/src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx @@ -109,7 +109,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { this.props.onFinished(false); - accessSecretStorage(async (): Promise => {}, /* forceReset = */ true); + accessSecretStorage(async (): Promise => {}, { forceReset: true }); }; /** diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index db165eb115..06c67c7d0b 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -209,7 +209,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { private resetSecretStorage = async (): Promise => { this.setState({ error: false }); try { - await accessSecretStorage(async (): Promise => {}, /* forceReset = */ true); + await accessSecretStorage(async (): Promise => {}, { forceReset: true }); } catch (e) { logger.error("Error resetting secret storage", e); if (this.unmounted) return; diff --git a/src/stores/SetupEncryptionStore.ts b/src/stores/SetupEncryptionStore.ts index 5422f68d7b..2fb9c6a9ca 100644 --- a/src/stores/SetupEncryptionStore.ts +++ b/src/stores/SetupEncryptionStore.ts @@ -19,9 +19,6 @@ import { Device, SecretStorage } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { AccessCancelledError, accessSecretStorage } from "../SecurityManager"; -import Modal from "../Modal"; -import InteractiveAuthDialog from "../components/views/dialogs/InteractiveAuthDialog"; -import { _t } from "../languageHandler"; import { SdkContextClass } from "../contexts/SDKContext"; import { asyncSome } from "../utils/arrays"; import { initialiseDehydration } from "../utils/device/dehydration"; @@ -230,42 +227,16 @@ export class SetupEncryptionStore extends EventEmitter { // secret storage key if they had one. Start by resetting // secret storage and setting up a new recovery key, then // create new cross-signing keys once that succeeds. - await accessSecretStorage(async (): Promise => { - const cli = MatrixClientPeg.safeGet(); - await cli.getCrypto()?.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async (makeRequest): Promise => { - const cachedPassword = SdkContextClass.instance.accountPasswordStore.getPassword(); - - if (cachedPassword) { - await makeRequest({ - type: "m.login.password", - identifier: { - type: "m.id.user", - user: cli.getSafeUserId(), - }, - user: cli.getSafeUserId(), - password: cachedPassword, - }); - return; - } - - const { finished } = Modal.createDialog(InteractiveAuthDialog, { - title: _t("encryption|bootstrap_title"), - matrixClient: cli, - makeRequest, - }); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Cross-signing key upload auth canceled"); - } - }, - setupNewCrossSigning: true, - }); - - await initialiseDehydration(true); - - this.phase = Phase.Finished; - }, true); + await accessSecretStorage( + async (): Promise => { + this.phase = Phase.Finished; + }, + { + forceReset: true, + resetCrossSigning: true, + accountPassword: SdkContextClass.instance.accountPasswordStore.getPassword(), + }, + ); } catch (e) { logger.error("Error resetting cross-signing", e); this.phase = Phase.Intro; diff --git a/test/unit-tests/SecurityManager-test.ts b/test/unit-tests/SecurityManager-test.ts index 63143d4644..574549d8b2 100644 --- a/test/unit-tests/SecurityManager-test.ts +++ b/test/unit-tests/SecurityManager-test.ts @@ -68,7 +68,7 @@ describe("SecurityManager", () => { stubClient(); const func = jest.fn(); - accessSecretStorage(func, true); + accessSecretStorage(func, { forceReset: true }); expect(spy).toHaveBeenCalledTimes(1); await expect(spy.mock.lastCall![0]).resolves.toEqual(expect.objectContaining({ __test: true })); diff --git a/test/unit-tests/components/views/dialogs/AccessSecretStorageDialog-test.tsx b/test/unit-tests/components/views/dialogs/AccessSecretStorageDialog-test.tsx index 30e1151d53..f5b0b1e074 100644 --- a/test/unit-tests/components/views/dialogs/AccessSecretStorageDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/AccessSecretStorageDialog-test.tsx @@ -122,4 +122,34 @@ describe("AccessSecretStorageDialog", () => { expect(screen.getByPlaceholderText("Security Phrase")).toHaveFocus(); }); + + it("Can reset secret storage", async () => { + jest.spyOn(mockClient.secretStorage, "checkKey").mockResolvedValue(true); + + const onFinished = jest.fn(); + const checkPrivateKey = jest.fn().mockResolvedValue(true); + renderComponent({ onFinished, checkPrivateKey }); + + await userEvent.click(screen.getByText("Reset all"), { delay: null }); + + // It will prompt the user to confirm resetting + expect(screen.getByText("Reset everything")).toBeInTheDocument(); + await userEvent.click(screen.getByText("Reset"), { delay: null }); + + // Then it will prompt the user to create a key/passphrase + await screen.findByText("Set up Secure Backup"); + document.execCommand = jest.fn().mockReturnValue(true); + jest.spyOn(mockClient.getCrypto()!, "createRecoveryKeyFromPassphrase").mockResolvedValue({ + privateKey: new Uint8Array(), + encodedPrivateKey: securityKey, + }); + screen.getByRole("button", { name: "Continue" }).click(); + + await screen.findByText(/Save your Security Key/); + screen.getByRole("button", { name: "Copy" }).click(); + await screen.findByText("Copied!"); + screen.getByRole("button", { name: "Continue" }).click(); + + await screen.findByText("Secure Backup successful"); + }); }); diff --git a/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx b/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx index b9d0514148..fa1d74955d 100644 --- a/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx @@ -97,4 +97,38 @@ describe("CreateSecretStorageDialog", () => { await screen.findByText("Your keys are now being backed up from this device."); }); }); + + it("resets keys in the right order when resetting secret storage and cross-signing", async () => { + const result = renderComponent({ forceReset: true, resetCrossSigning: true }); + + await result.findByText(/Set up Secure Backup/); + jest.spyOn(mockClient.getCrypto()!, "createRecoveryKeyFromPassphrase").mockResolvedValue({ + privateKey: new Uint8Array(), + encodedPrivateKey: "abcd efgh ijkl", + }); + result.getByRole("button", { name: "Continue" }).click(); + + await result.findByText(/Save your Security Key/); + result.getByRole("button", { name: "Copy" }).click(); + + // Resetting should reset secret storage, cross signing, and key + // backup. We make sure that all three are reset, and done in the + // right order. + const resetFunctionCallLog: string[] = []; + jest.spyOn(mockClient.getCrypto()!, "bootstrapSecretStorage").mockImplementation(async () => { + resetFunctionCallLog.push("bootstrapSecretStorage"); + }); + jest.spyOn(mockClient.getCrypto()!, "bootstrapCrossSigning").mockImplementation(async () => { + resetFunctionCallLog.push("bootstrapCrossSigning"); + }); + jest.spyOn(mockClient.getCrypto()!, "resetKeyBackup").mockImplementation(async () => { + resetFunctionCallLog.push("resetKeyBackup"); + }); + + result.getByRole("button", { name: "Continue" }).click(); + + await result.findByText("Your keys are now being backed up from this device."); + + expect(resetFunctionCallLog).toEqual(["bootstrapSecretStorage", "bootstrapCrossSigning", "resetKeyBackup"]); + }); }); diff --git a/test/unit-tests/stores/SetupEncryptionStore-test.ts b/test/unit-tests/stores/SetupEncryptionStore-test.ts index 388f1965d7..b9ab29b94b 100644 --- a/test/unit-tests/stores/SetupEncryptionStore-test.ts +++ b/test/unit-tests/stores/SetupEncryptionStore-test.ts @@ -170,15 +170,10 @@ describe("SetupEncryptionStore", () => { await setupEncryptionStore.resetConfirm(); - expect(mocked(accessSecretStorage)).toHaveBeenCalledWith(expect.any(Function), true); - expect(makeRequest).toHaveBeenCalledWith({ - identifier: { - type: "m.id.user", - user: "@userId:matrix.org", - }, - password: cachedPassword, - type: "m.login.password", - user: "@userId:matrix.org", + expect(mocked(accessSecretStorage)).toHaveBeenCalledWith(expect.any(Function), { + accountPassword: cachedPassword, + forceReset: true, + resetCrossSigning: true, }); }); }); From c8e4ffe1dd8589252847d4e89403de594e6fce8f Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Tue, 19 Nov 2024 06:21:07 +0000 Subject: [PATCH 11/37] [create-pull-request] automated change (#28489) Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com> --- playwright/plugins/homeserver/synapse/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index 824ee3273e..ea95a6bbdc 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -20,7 +20,7 @@ import { randB64Bytes } from "../../utils/rand"; // Docker tag to use for synapse docker image. // We target a specific digest as every now and then a Synapse update will break our CI. // This digest is updated by the playwright-image-updates.yaml workflow periodically. -const DOCKER_TAG = "develop@sha256:b1b5693fa954ec0124e330dba8a28260ac1cc4d9948a778724a421be9f934284"; +const DOCKER_TAG = "develop@sha256:d947f40999b060ad4856c0af741b8619fa131430a29922606e374fdba532082b"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); From d4ab40990bf5864825a24b3164ccc2bd5d032336 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 19 Nov 2024 11:09:25 +0100 Subject: [PATCH 12/37] First batch: Replace `MatrixClient.isRoomEncrypted` by `MatrixClient.CryptoApi.isEncryptionEnabledInRoom` (#28242) * Replace `MatrixClient.isRoomEncrypted` by `MatrixClient.CryptoApi.isEncryptionEnabledInRoom` in `DeviceListener.ts` * Replace `MatrixClient.isRoomEncrypted` by `MatrixClient.CryptoApi.isEncryptionEnabledInRoom` in `Searching.ts` * Replace `MatrixClient.isRoomEncrypted` by `MatrixClient.CryptoApi.isEncryptionEnabledInRoom` in `SlidingSyncManager.ts` * Replace `MatrixClient.isRoomEncrypted` by `MatrixClient.CryptoApi.isEncryptionEnabledInRoom` in `EncryptionEvent.tsx` * Replace `MatrixClient.isRoomEncrypted` by `MatrixClient.CryptoApi.isEncryptionEnabledInRoom` in `ReportEventDialog.tsx` * Replace `MatrixClient.isRoomEncrypted` by `MatrixClient.CryptoApi.isEncryptionEnabledInRoom` in `RoomNotifications.tsx` * Fix MessagePanel-test.tsx * ReplaceReplace `MatrixCient..isRoomEncrypted` by `MatrixClient.CryptoApi.isEncryptionEnabledInRoom` in `shouldSkipSetupEncryption.ts` * Add missing `await` * Use `Promise.any` instead of `asyncSome` * Add `asyncSomeParallel` * Use `asyncSomeParallel` instead of `asyncSome` --- src/DeviceListener.ts | 10 +++-- src/Searching.ts | 4 +- src/SlidingSyncManager.ts | 2 +- src/components/structures/MatrixChat.tsx | 2 +- .../views/dialogs/ReportEventDialog.tsx | 18 ++++++++- .../dialogs/devtools/RoomNotifications.tsx | 6 +-- .../views/messages/EncryptionEvent.tsx | 10 ++--- src/utils/arrays.ts | 22 +++++++++++ src/utils/crypto/shouldSkipSetupEncryption.ts | 11 +++++- test/test-utils/client.ts | 1 + test/unit-tests/DeviceListener-test.ts | 6 +-- .../components/structures/MatrixChat-test.tsx | 10 +++-- .../structures/MessagePanel-test.tsx | 2 + .../components/structures/RoomView-test.tsx | 11 +++++- .../views/messages/EncryptionEvent-test.tsx | 37 +++++++++++-------- test/unit-tests/utils/arrays-test.ts | 20 ++++++++++ 16 files changed, 129 insertions(+), 43 deletions(-) diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 02e26729d2..4f47cd7eac 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -46,6 +46,7 @@ import SettingsStore, { CallbackFn } from "./settings/SettingsStore"; import { UIFeature } from "./settings/UIFeature"; import { isBulkUnverifiedDeviceReminderSnoozed } from "./utils/device/snoozeBulkUnverifiedDeviceReminder"; import { getUserDeviceIds } from "./utils/crypto/deviceInfo"; +import { asyncSomeParallel } from "./utils/arrays.ts"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -240,13 +241,16 @@ export default class DeviceListener { return this.keyBackupInfo; } - private shouldShowSetupEncryptionToast(): boolean { + private async shouldShowSetupEncryptionToast(): Promise { // If we're in the middle of a secret storage operation, we're likely // modifying the state involved here, so don't add new toasts to setup. if (isSecretStorageBeingAccessed()) return false; // Show setup toasts once the user is in at least one encrypted room. const cli = this.client; - return cli?.getRooms().some((r) => cli.isRoomEncrypted(r.roomId)) ?? false; + const cryptoApi = cli?.getCrypto(); + if (!cli || !cryptoApi) return false; + + return await asyncSomeParallel(cli.getRooms(), ({ roomId }) => cryptoApi.isEncryptionEnabledInRoom(roomId)); } private recheck(): void { @@ -283,7 +287,7 @@ export default class DeviceListener { hideSetupEncryptionToast(); this.checkKeyBackupStatus(); - } else if (this.shouldShowSetupEncryptionToast()) { + } else if (await this.shouldShowSetupEncryptionToast()) { // make sure our keys are finished downloading await crypto.getUserDeviceInfo([cli.getSafeUserId()]); diff --git a/src/Searching.ts b/src/Searching.ts index 85483eb23c..252d4378ad 100644 --- a/src/Searching.ts +++ b/src/Searching.ts @@ -596,7 +596,7 @@ async function combinedPagination( return result; } -function eventIndexSearch( +async function eventIndexSearch( client: MatrixClient, term: string, roomId?: string, @@ -605,7 +605,7 @@ function eventIndexSearch( let searchPromise: Promise; if (roomId !== undefined) { - if (client.isRoomEncrypted(roomId)) { + if (await client.getCrypto()?.isEncryptionEnabledInRoom(roomId)) { // The search is for a single encrypted room, use our local // search method. searchPromise = localSearchProcess(client, term, roomId); diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts index e3ca77f988..11872d059e 100644 --- a/src/SlidingSyncManager.ts +++ b/src/SlidingSyncManager.ts @@ -229,7 +229,7 @@ export class SlidingSyncManager { subscriptions.delete(roomId); } const room = this.client?.getRoom(roomId); - let shouldLazyLoad = !this.client?.isRoomEncrypted(roomId); + let shouldLazyLoad = !(await this.client?.getCrypto()?.isEncryptionEnabledInRoom(roomId)); if (!room) { // default to safety: request all state if we can't work it out. This can happen if you // refresh the app whilst viewing a room: we call setRoomVisible before we know anything diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index afd444c952..e51dd96647 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -427,7 +427,7 @@ export default class MatrixChat extends React.PureComponent { } } else if ( (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) && - !shouldSkipSetupEncryption(cli) + !(await shouldSkipSetupEncryption(cli)) ) { // if cross-signing is not yet set up, do so now if possible. this.setStateForNewView({ view: Views.E2E_SETUP }); diff --git a/src/components/views/dialogs/ReportEventDialog.tsx b/src/components/views/dialogs/ReportEventDialog.tsx index 75b9977dc4..3234c2be35 100644 --- a/src/components/views/dialogs/ReportEventDialog.tsx +++ b/src/components/views/dialogs/ReportEventDialog.tsx @@ -43,6 +43,10 @@ interface IState { // If we know it, the nature of the abuse, as specified by MSC3215. nature?: ExtendedNature; ignoreUserToo: boolean; // if true, user will be ignored/blocked on submit + /* + * Whether the room is encrypted. + */ + isRoomEncrypted: boolean; } const MODERATED_BY_STATE_EVENT_TYPE = [ @@ -188,9 +192,20 @@ export default class ReportEventDialog extends React.Component { // If specified, the nature of the abuse, as specified by MSC3215. nature: undefined, ignoreUserToo: false, // default false, for now. Could easily be argued as default true + isRoomEncrypted: false, // async, will be set later }; } + public componentDidMount = async (): Promise => { + const crypto = MatrixClientPeg.safeGet().getCrypto(); + const roomId = this.props.mxEvent.getRoomId(); + if (!crypto || !roomId) return; + + this.setState({ + isRoomEncrypted: await crypto.isEncryptionEnabledInRoom(roomId), + }); + }; + private onIgnoreUserTooChanged = (newVal: boolean): void => { this.setState({ ignoreUserToo: newVal }); }; @@ -319,7 +334,6 @@ export default class ReportEventDialog extends React.Component { if (this.moderation) { // Display report-to-moderator dialog. // We let the user pick a nature. - const client = MatrixClientPeg.safeGet(); const homeServerName = SdkConfig.get("validated_server_config")!.hsName; let subtitle: string; switch (this.state.nature) { @@ -336,7 +350,7 @@ export default class ReportEventDialog extends React.Component { subtitle = _t("report_content|nature_spam"); break; case NonStandardValue.Admin: - if (client.isRoomEncrypted(this.props.mxEvent.getRoomId()!)) { + if (this.state.isRoomEncrypted) { subtitle = _t("report_content|nature_nonstandard_admin_encrypted", { homeserver: homeServerName, }); diff --git a/src/components/views/dialogs/devtools/RoomNotifications.tsx b/src/components/views/dialogs/devtools/RoomNotifications.tsx index c54e695006..1bcff78487 100644 --- a/src/components/views/dialogs/devtools/RoomNotifications.tsx +++ b/src/components/views/dialogs/devtools/RoomNotifications.tsx @@ -17,6 +17,7 @@ import { determineUnreadState } from "../../../../RoomNotifs"; import { humanReadableNotificationLevel } from "../../../../stores/notifications/NotificationLevel"; import { doesRoomOrThreadHaveUnreadMessages } from "../../../../Unread"; import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool"; +import { useIsEncrypted } from "../../../../hooks/useIsEncrypted.ts"; function UserReadUpTo({ target }: { target: ReadReceipt }): JSX.Element { const cli = useContext(MatrixClientContext); @@ -59,6 +60,7 @@ function UserReadUpTo({ target }: { target: ReadReceipt }): JSX.Elemen export default function RoomNotifications({ onBack }: IDevtoolsProps): JSX.Element { const { room } = useContext(DevtoolsContext); const cli = useContext(MatrixClientContext); + const isRoomEncrypted = useIsEncrypted(cli, room); const { level, count } = determineUnreadState(room, undefined, false); const [notificationState] = useNotificationState(room); @@ -93,9 +95,7 @@ export default function RoomNotifications({ onBack }: IDevtoolsProps): JSX.Eleme
  • {_t( - cli.isRoomEncrypted(room.roomId!) - ? _td("devtools|room_encrypted") - : _td("devtools|room_not_encrypted"), + isRoomEncrypted ? _td("devtools|room_encrypted") : _td("devtools|room_not_encrypted"), {}, { strong: (sub) => {sub}, diff --git a/src/components/views/messages/EncryptionEvent.tsx b/src/components/views/messages/EncryptionEvent.tsx index e721662cb5..bc6680d300 100644 --- a/src/components/views/messages/EncryptionEvent.tsx +++ b/src/components/views/messages/EncryptionEvent.tsx @@ -6,18 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { forwardRef, useContext } from "react"; +import React, { forwardRef } from "react"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types"; import { _t } from "../../../languageHandler"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import EventTileBubble from "./EventTileBubble"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import DMRoomMap from "../../../utils/DMRoomMap"; import { objectHasDiff } from "../../../utils/objects"; import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../utils/crypto"; +import { useIsEncrypted } from "../../../hooks/useIsEncrypted.ts"; interface IProps { mxEvent: MatrixEvent; @@ -25,9 +25,9 @@ interface IProps { } const EncryptionEvent = forwardRef(({ mxEvent, timestamp }, ref) => { - const cli = useContext(MatrixClientContext); + const cli = useMatrixClientContext(); const roomId = mxEvent.getRoomId()!; - const isRoomEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId); + const isRoomEncrypted = useIsEncrypted(cli, cli.getRoom(roomId) || undefined); const prevContent = mxEvent.getPrevContent() as RoomEncryptionEventContent; const content = mxEvent.getContent(); diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 99c69b9891..d1a35f2950 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -328,6 +328,28 @@ export async function asyncSome(values: Iterable, predicate: (value: T) => return false; } +/** + * Async version of Array.some that runs all promises in parallel. + * @param values + * @param predicate + */ +export async function asyncSomeParallel( + values: Array, + predicate: (value: T) => Promise, +): Promise { + try { + return await Promise.any( + values.map((value) => + predicate(value).then((result) => (result ? Promise.resolve(true) : Promise.reject(false))), + ), + ); + } catch (e) { + // If the array is empty or all the promises are false, Promise.any will reject an AggregateError + if (e instanceof AggregateError) return false; + throw e; + } +} + export function filterBoolean(values: Array): T[] { return values.filter(Boolean) as T[]; } diff --git a/src/utils/crypto/shouldSkipSetupEncryption.ts b/src/utils/crypto/shouldSkipSetupEncryption.ts index 51d7a9303c..d4dbb27d1b 100644 --- a/src/utils/crypto/shouldSkipSetupEncryption.ts +++ b/src/utils/crypto/shouldSkipSetupEncryption.ts @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { shouldForceDisableEncryption } from "./shouldForceDisableEncryption"; +import { asyncSomeParallel } from "../arrays.ts"; /** * If encryption is force disabled AND the user is not in any encrypted rooms @@ -16,7 +17,13 @@ import { shouldForceDisableEncryption } from "./shouldForceDisableEncryption"; * @param client * @returns {boolean} true when we can skip settings up encryption */ -export const shouldSkipSetupEncryption = (client: MatrixClient): boolean => { +export const shouldSkipSetupEncryption = async (client: MatrixClient): Promise => { const isEncryptionForceDisabled = shouldForceDisableEncryption(client); - return isEncryptionForceDisabled && !client.getRooms().some((r) => client.isRoomEncrypted(r.roomId)); + const crypto = client.getCrypto(); + if (!crypto) return true; + + return ( + isEncryptionForceDisabled && + !(await asyncSomeParallel(client.getRooms(), ({ roomId }) => crypto.isEncryptionEnabledInRoom(roomId))) + ); }; diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index 0a5798d8a1..7842afbfe5 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -162,6 +162,7 @@ export const mockClientMethodsCrypto = (): Partial< getVersion: jest.fn().mockReturnValue("Version 0"), getOwnDeviceKeys: jest.fn().mockReturnValue(new Promise(() => {})), getCrossSigningKeyId: jest.fn(), + isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false), }), }); diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index 0862c6b385..906826e456 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -95,6 +95,7 @@ describe("DeviceListener", () => { }, }), getSessionBackupPrivateKey: jest.fn(), + isEncryptionEnabledInRoom: jest.fn(), } as unknown as Mocked; mockClient = getMockClientWithEventEmitter({ isGuest: jest.fn(), @@ -105,7 +106,6 @@ describe("DeviceListener", () => { isVersionSupported: jest.fn().mockResolvedValue(true), isInitialSyncComplete: jest.fn().mockReturnValue(true), waitForClientWellKnown: jest.fn(), - isRoomEncrypted: jest.fn(), getClientWellKnown: jest.fn(), getDeviceId: jest.fn().mockReturnValue(deviceId), setAccountData: jest.fn(), @@ -292,7 +292,7 @@ describe("DeviceListener", () => { mockCrypto!.isCrossSigningReady.mockResolvedValue(false); mockCrypto!.isSecretStorageReady.mockResolvedValue(false); mockClient!.getRooms.mockReturnValue(rooms); - mockClient!.isRoomEncrypted.mockReturnValue(true); + jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); }); it("hides setup encryption toast when cross signing and secret storage are ready", async () => { @@ -317,7 +317,7 @@ describe("DeviceListener", () => { }); it("does not show any toasts when no rooms are encrypted", async () => { - mockClient!.isRoomEncrypted.mockReturnValue(false); + jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); await createAndStart(); expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled(); diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index 16106ee0d2..b3766bfc89 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -146,7 +146,6 @@ describe("", () => { matrixRTC: createStubMatrixRTC(), getDehydratedDevice: jest.fn(), whoami: jest.fn(), - isRoomEncrypted: jest.fn(), logout: jest.fn(), getDeviceId: jest.fn(), getKeyBackupVersion: jest.fn().mockResolvedValue(null), @@ -1011,6 +1010,7 @@ describe("", () => { userHasCrossSigningKeys: jest.fn().mockResolvedValue(false), // This needs to not finish immediately because we need to test the screen appears bootstrapCrossSigning: jest.fn().mockImplementation(() => bootstrapDeferred.promise), + isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false), }; loginClient.getCrypto.mockReturnValue(mockCrypto as any); }); @@ -1058,9 +1058,11 @@ describe("", () => { }, }); - loginClient.isRoomEncrypted.mockImplementation((roomId) => { - return roomId === encryptedRoom.roomId; - }); + jest.spyOn(loginClient.getCrypto()!, "isEncryptionEnabledInRoom").mockImplementation( + async (roomId) => { + return roomId === encryptedRoom.roomId; + }, + ); }); it("should go straight to logged in view when user is not in any encrypted rooms", async () => { diff --git a/test/unit-tests/components/structures/MessagePanel-test.tsx b/test/unit-tests/components/structures/MessagePanel-test.tsx index 037a57bb06..cf44716ba9 100644 --- a/test/unit-tests/components/structures/MessagePanel-test.tsx +++ b/test/unit-tests/components/structures/MessagePanel-test.tsx @@ -23,6 +23,7 @@ import { createTestClient, getMockClientWithEventEmitter, makeBeaconInfoEvent, + mockClientMethodsCrypto, mockClientMethodsEvents, mockClientMethodsUser, } from "../../../test-utils"; @@ -42,6 +43,7 @@ describe("MessagePanel", function () { const client = getMockClientWithEventEmitter({ ...mockClientMethodsUser(userId), ...mockClientMethodsEvents(), + ...mockClientMethodsCrypto(), getAccountData: jest.fn(), isUserIgnored: jest.fn().mockReturnValue(false), isRoomEncrypted: jest.fn().mockReturnValue(false), diff --git a/test/unit-tests/components/structures/RoomView-test.tsx b/test/unit-tests/components/structures/RoomView-test.tsx index 02bed8cf4f..f30db3d80e 100644 --- a/test/unit-tests/components/structures/RoomView-test.tsx +++ b/test/unit-tests/components/structures/RoomView-test.tsx @@ -21,6 +21,7 @@ import { SearchResult, IEvent, } from "matrix-js-sdk/src/matrix"; +import { CryptoApi, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { fireEvent, render, screen, RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; @@ -72,6 +73,7 @@ describe("RoomView", () => { let rooms: Map; let roomCount = 0; let stores: SdkContextClass; + let crypto: CryptoApi; // mute some noise filterConsole("RVS update", "does not have an m.room.create event", "Current version: 1", "Version capability"); @@ -97,6 +99,7 @@ describe("RoomView", () => { stores.rightPanelStore.useUnitTestClient(cli); jest.spyOn(VoipUserMapper.sharedInstance(), "getVirtualRoomForRoom").mockResolvedValue(undefined); + crypto = cli.getCrypto()!; jest.spyOn(cli, "getCrypto").mockReturnValue(undefined); }); @@ -341,7 +344,13 @@ describe("RoomView", () => { describe("that is encrypted", () => { beforeEach(() => { + // Not all the calls to cli.isRoomEncrypted are migrated, so we need to mock both. mocked(cli.isRoomEncrypted).mockReturnValue(true); + jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); + jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); + jest.spyOn(cli.getCrypto()!, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, true, false), + ); localRoom.encrypted = true; localRoom.currentState.setStateEvents([ new MatrixEvent({ @@ -360,7 +369,7 @@ describe("RoomView", () => { it("should match the snapshot", async () => { const { container } = await renderRoomView(); - expect(container).toMatchSnapshot(); + await waitFor(() => expect(container).toMatchSnapshot()); }); }); }); diff --git a/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx b/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx index 3a78ef55e8..ca5f3d04b9 100644 --- a/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx +++ b/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx @@ -10,6 +10,7 @@ import React from "react"; import { mocked } from "jest-mock"; import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { render, screen } from "jest-matrix-react"; +import { waitFor } from "@testing-library/dom"; import EncryptionEvent from "../../../../../src/components/views/messages/EncryptionEvent"; import { createTestClient, mkMessage } from "../../../../test-utils"; @@ -55,17 +56,19 @@ describe("EncryptionEvent", () => { describe("for an encrypted room", () => { beforeEach(() => { event.event.content!.algorithm = algorithm; - mocked(client.isRoomEncrypted).mockReturnValue(true); + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); const room = new Room(roomId, client, client.getUserId()!); mocked(client.getRoom).mockReturnValue(room); }); - it("should show the expected texts", () => { + it("should show the expected texts", async () => { renderEncryptionEvent(client, event); - checkTexts( - "Encryption enabled", - "Messages in this room are end-to-end encrypted. " + - "When people join, you can verify them in their profile, just tap on their profile picture.", + await waitFor(() => + checkTexts( + "Encryption enabled", + "Messages in this room are end-to-end encrypted. " + + "When people join, you can verify them in their profile, just tap on their profile picture.", + ), ); }); @@ -76,9 +79,9 @@ describe("EncryptionEvent", () => { }); }); - it("should show the expected texts", () => { + it("should show the expected texts", async () => { renderEncryptionEvent(client, event); - checkTexts("Encryption enabled", "Some encryption parameters have been changed."); + await waitFor(() => checkTexts("Encryption enabled", "Some encryption parameters have been changed.")); }); }); @@ -87,36 +90,38 @@ describe("EncryptionEvent", () => { event.event.content!.algorithm = "unknown"; }); - it("should show the expected texts", () => { + it("should show the expected texts", async () => { renderEncryptionEvent(client, event); - checkTexts("Encryption enabled", "Ignored attempt to disable encryption"); + await waitFor(() => checkTexts("Encryption enabled", "Ignored attempt to disable encryption")); }); }); }); describe("for an unencrypted room", () => { beforeEach(() => { - mocked(client.isRoomEncrypted).mockReturnValue(false); + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); renderEncryptionEvent(client, event); }); - it("should show the expected texts", () => { - expect(client.isRoomEncrypted).toHaveBeenCalledWith(roomId); - checkTexts("Encryption not enabled", "The encryption used by this room isn't supported."); + it("should show the expected texts", async () => { + expect(client.getCrypto()!.isEncryptionEnabledInRoom).toHaveBeenCalledWith(roomId); + await waitFor(() => + checkTexts("Encryption not enabled", "The encryption used by this room isn't supported."), + ); }); }); describe("for an encrypted local room", () => { beforeEach(() => { event.event.content!.algorithm = algorithm; - mocked(client.isRoomEncrypted).mockReturnValue(true); + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); const localRoom = new LocalRoom(roomId, client, client.getUserId()!); mocked(client.getRoom).mockReturnValue(localRoom); renderEncryptionEvent(client, event); }); it("should show the expected texts", () => { - expect(client.isRoomEncrypted).toHaveBeenCalledWith(roomId); + expect(client.getCrypto()!.isEncryptionEnabledInRoom).toHaveBeenCalledWith(roomId); checkTexts("Encryption enabled", "Messages in this chat will be end-to-end encrypted."); }); }); diff --git a/test/unit-tests/utils/arrays-test.ts b/test/unit-tests/utils/arrays-test.ts index 53baed8be3..52b0053147 100644 --- a/test/unit-tests/utils/arrays-test.ts +++ b/test/unit-tests/utils/arrays-test.ts @@ -23,6 +23,7 @@ import { concat, asyncEvery, asyncSome, + asyncSomeParallel, } from "../../../src/utils/arrays"; type TestParams = { input: number[]; output: number[] }; @@ -460,4 +461,23 @@ describe("arrays", () => { expect(predicate).toHaveBeenCalledWith(2); }); }); + + describe("asyncSomeParallel", () => { + it("when called with an empty array, it should return false", async () => { + expect(await asyncSomeParallel([], jest.fn().mockResolvedValue(true))).toBe(false); + }); + + it("when all the predicates return false", async () => { + expect(await asyncSomeParallel([1, 2, 3], jest.fn().mockResolvedValue(false))).toBe(false); + }); + + it("when all the predicates return true", async () => { + expect(await asyncSomeParallel([1, 2, 3], jest.fn().mockResolvedValue(true))).toBe(true); + }); + + it("when one of the predicate return true", async () => { + const predicate = jest.fn().mockImplementation((value) => Promise.resolve(value === 2)); + expect(await asyncSomeParallel([1, 2, 3], predicate)).toBe(true); + }); + }); }); From 0d5c9a338beeed1548c2a81946aaf5c256658b18 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 19 Nov 2024 11:28:30 +0100 Subject: [PATCH 13/37] Fix media captions in bubble layout (#28480) --- res/css/views/rooms/_EventBubbleTile.pcss | 1 - 1 file changed, 1 deletion(-) diff --git a/res/css/views/rooms/_EventBubbleTile.pcss b/res/css/views/rooms/_EventBubbleTile.pcss index 3a42cde9bb..7b1af0c771 100644 --- a/res/css/views/rooms/_EventBubbleTile.pcss +++ b/res/css/views/rooms/_EventBubbleTile.pcss @@ -334,7 +334,6 @@ Please see LICENSE files in the repository root for full details. .mx_MImageBody { width: 100%; - height: 100%; .mx_MImageBody_thumbnail.mx_MImageBody_thumbnail--blurhash { position: unset; From 7e33f03a029319936b15a25b87b7bc5e381574f8 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 19 Nov 2024 14:19:36 +0000 Subject: [PATCH 14/37] Upgrade dependency to matrix-js-sdk@34.12.0 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index ec7dc63e89..494665eb5f 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "maplibre-gl": "^2.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "34.12.0-rc.0", + "matrix-js-sdk": "34.12.0", "matrix-widget-api": "^1.10.0", "memoize-one": "^6.0.0", "mime": "^4.0.4", diff --git a/yarn.lock b/yarn.lock index 7cc8109395..8dde7a3f98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8179,10 +8179,10 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -matrix-js-sdk@34.12.0-rc.0: - version "34.12.0-rc.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-34.12.0-rc.0.tgz#d7ff6e5a5daa82a5c8465016cd3cb168d709576a" - integrity sha512-hT7tzLYI9Jy3d+8bpzv5p+5MV1R4YxJ8IgMZQ8cy+65/bzkPbSi/XphCfAXcG1KDdFW28l0GYvAk4K7WTOQA8Q== +matrix-js-sdk@34.12.0: + version "34.12.0" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-34.12.0.tgz#d62d45cdde71a1fafb3109621e96379e476b8c07" + integrity sha512-k6jG4r4Bh8vwP7T7eIZTz3Y9+vEVq+VZUdn9Xz0t0AfhziNCALP2KneW2mrYWA2wHtEkIRfFYKHBJIUxw4LiKw== dependencies: "@babel/runtime" "^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm" "^9.0.0" From 3bcc27a44496e866ff3f79349eb1b0965f27a156 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 19 Nov 2024 14:23:06 +0000 Subject: [PATCH 15/37] v1.11.86 --- CHANGELOG.md | 20 ++++++++++++++++++++ package.json | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6260a72f99..a554890dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +Changes in [1.11.86](https://github.com/element-hq/element-web/releases/tag/v1.11.86) (2024-11-19) +================================================================================================== +## ✨ Features + +* Deduplicate icons using Compound Design Tokens ([#28419](https://github.com/element-hq/element-web/pull/28419)). Contributed by @t3chguy. +* Let widget driver send error details ([#28357](https://github.com/element-hq/element-web/pull/28357)). Contributed by @AndrewFerr. +* Deduplicate icons using Compound Design Tokens ([#28381](https://github.com/element-hq/element-web/pull/28381)). Contributed by @t3chguy. +* Auto approvoce `io.element.call.reaction` capability for element call widgets ([#28401](https://github.com/element-hq/element-web/pull/28401)). Contributed by @toger5. +* Show message type prefix in thread root \& reply previews ([#28361](https://github.com/element-hq/element-web/pull/28361)). Contributed by @t3chguy. +* Support sending encrypted to device messages from widgets ([#28315](https://github.com/element-hq/element-web/pull/28315)). Contributed by @hughns. + +## 🐛 Bug Fixes + +* Feed events to widgets as they are decrypted (even if out of order) ([#28376](https://github.com/element-hq/element-web/pull/28376)). Contributed by @robintown. +* Handle authenticated media when downloading from ImageView ([#28379](https://github.com/element-hq/element-web/pull/28379)). Contributed by @t3chguy. +* Ignore `m.3pid_changes` for Identity service 3PID changes ([#28375](https://github.com/element-hq/element-web/pull/28375)). Contributed by @t3chguy. +* Fix markdown escaping wrongly passing html through ([#28363](https://github.com/element-hq/element-web/pull/28363)). Contributed by @t3chguy. +* Remove "Upgrade your encryption" flow in `CreateSecretStorageDialog` ([#28290](https://github.com/element-hq/element-web/pull/28290)). Contributed by @florianduros. + + Changes in [1.11.85](https://github.com/element-hq/element-web/releases/tag/v1.11.85) (2024-11-12) ================================================================================================== # Security diff --git a/package.json b/package.json index 494665eb5f..f6bcb150bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.11.86-rc.0", + "version": "1.11.86", "description": "A feature-rich client for Matrix.org", "author": "New Vector Ltd.", "repository": { From f0af77712f33dd9043d747de73e1f3b2f807663a Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 19 Nov 2024 14:26:10 +0000 Subject: [PATCH 16/37] Reset matrix-js-sdk back to develop branch --- package.json | 2 +- yarn.lock | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 8ca6577118..622269f3c0 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "maplibre-gl": "^4.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "34.12.0", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-widget-api": "^1.10.0", "memoize-one": "^6.0.0", "mime": "^4.0.4", diff --git a/yarn.lock b/yarn.lock index 166160665b..20f545347e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8352,10 +8352,9 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -matrix-js-sdk@34.12.0: +"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": version "34.12.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-34.12.0.tgz#d62d45cdde71a1fafb3109621e96379e476b8c07" - integrity sha512-k6jG4r4Bh8vwP7T7eIZTz3Y9+vEVq+VZUdn9Xz0t0AfhziNCALP2KneW2mrYWA2wHtEkIRfFYKHBJIUxw4LiKw== + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/544ac86d2080da8e55d0b727cae826e42600c490" dependencies: "@babel/runtime" "^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm" "^9.0.0" From 48fd330dd9e2fdffff68406488d9f5cd52be8458 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Wed, 20 Nov 2024 06:20:25 +0000 Subject: [PATCH 17/37] [create-pull-request] automated change (#28495) Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com> --- playwright/plugins/homeserver/synapse/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index ea95a6bbdc..578135e2ad 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -20,7 +20,7 @@ import { randB64Bytes } from "../../utils/rand"; // Docker tag to use for synapse docker image. // We target a specific digest as every now and then a Synapse update will break our CI. // This digest is updated by the playwright-image-updates.yaml workflow periodically. -const DOCKER_TAG = "develop@sha256:d947f40999b060ad4856c0af741b8619fa131430a29922606e374fdba532082b"; +const DOCKER_TAG = "develop@sha256:f457c57b91bd677e7ebdbc314c8524b1a7b61f8d1d45cccc845b34db226deb01"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); From ca33d9165ae7797f38816191d8914a998f2b8075 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 20 Nov 2024 13:29:23 +0000 Subject: [PATCH 18/37] Migrate to React 18 createRoot API (#28256) * Migrate to React 18 createRoot API Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Discard changes to src/components/views/settings/devices/DeviceDetails.tsx * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Attempt to stabilise test Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * legacyRoot? Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update snapshots Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../structures/auth/ForgotPassword.tsx | 7 + src/vector/init.tsx | 14 +- test/test-utils/jest-matrix-react.tsx | 1 - test/test-utils/utilities.ts | 2 +- .../accessibility/RovingTabIndex-test.tsx | 14 +- .../components/structures/MatrixChat-test.tsx | 28 +- .../structures/PipContainer-test.tsx | 22 +- .../components/structures/RoomView-test.tsx | 478 +++++++++--------- .../structures/ThreadPanel-test.tsx | 46 +- .../structures/TimelinePanel-test.tsx | 33 +- .../structures/auth/ForgotPassword-test.tsx | 100 ++-- .../views/dialogs/SpotlightDialog-test.tsx | 2 +- .../CreateSecretStorageDialog-test.tsx | 3 +- .../security/ExportE2eKeysDialog-test.tsx | 14 +- .../views/elements/AppTile-test.tsx | 95 ++-- .../components/views/elements/Pill-test.tsx | 10 +- .../__snapshots__/AppTile-test.tsx.snap | 24 +- .../views/emojipicker/EmojiPicker-test.tsx | 8 +- .../views/location/LocationShareMenu-test.tsx | 4 +- .../views/messages/DateSeparator-test.tsx | 14 +- .../views/messages/EncryptionEvent-test.tsx | 10 +- .../views/messages/MPollBody-test.tsx | 28 +- .../views/messages/MPollEndBody-test.tsx | 3 +- .../polls/pollHistory/PollHistory-test.tsx | 12 +- .../__snapshots__/PollHistory-test.tsx.snap | 4 +- .../views/right_panel/UserInfo-test.tsx | 51 +- .../components/views/rooms/EventTile-test.tsx | 12 +- .../views/rooms/MemberList-test.tsx | 25 +- .../views/rooms/MessageComposer-test.tsx | 132 +++-- .../views/rooms/SendMessageComposer-test.tsx | 6 +- .../EditWysiwygComposer-test.tsx | 6 +- .../settings/AddRemoveThreepids-test.tsx | 110 ++-- .../AddRemoveThreepids-test.tsx.snap | 12 +- .../settings/devices/LoginWithQR-test.tsx | 4 +- .../tabs/user/SessionManagerTab-test.tsx | 50 +- .../AccountUserSettingsTab-test.tsx.snap | 8 +- .../SessionManagerTab-test.tsx.snap | 2 +- .../toasts/VerificationRequestToast-test.tsx | 14 +- .../toasts/UnverifiedSessionToast-test.tsx | 3 +- .../media/requestMediaPermissions-test.tsx | 2 +- .../vector/__snapshots__/init-test.ts.snap | 3 + test/unit-tests/vector/init-test.ts | 21 +- .../VoiceBroadcastPreRecordingPip-test.tsx | 4 +- .../VoiceBroadcastRecordingPip-test.tsx | 9 +- 44 files changed, 719 insertions(+), 731 deletions(-) diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index e0a9318e9a..5c631edb97 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -75,6 +75,7 @@ interface State { } export default class ForgotPassword extends React.Component { + private unmounted = false; private reset: PasswordReset; private fieldPassword: Field | null = null; private fieldPasswordConfirm: Field | null = null; @@ -108,14 +109,20 @@ export default class ForgotPassword extends React.Component { } } + public componentWillUnmount(): void { + this.unmounted = true; + } + private async checkServerLiveliness(serverConfig: ValidatedServerConfig): Promise { try { await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(serverConfig.hsUrl, serverConfig.isUrl); + if (this.unmounted) return; this.setState({ serverIsAlive: true, }); } catch (e: any) { + if (this.unmounted) return; const { serverIsAlive, serverDeadError } = AutoDiscoveryUtils.authComponentStateForError( e, "forgot_password", diff --git a/src/vector/init.tsx b/src/vector/init.tsx index 97b203cd5b..a3d5624cb4 100644 --- a/src/vector/init.tsx +++ b/src/vector/init.tsx @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import * as ReactDOM from "react-dom"; +import { createRoot } from "react-dom/client"; import React, { StrictMode } from "react"; import { logger } from "matrix-js-sdk/src/logger"; @@ -93,7 +93,9 @@ export async function loadApp(fragParams: {}): Promise { function setWindowMatrixChat(matrixChat: MatrixChat): void { window.matrixChat = matrixChat; } - ReactDOM.render(await module.loadApp(fragParams, setWindowMatrixChat), document.getElementById("matrixchat")); + const app = await module.loadApp(fragParams, setWindowMatrixChat); + const root = createRoot(document.getElementById("matrixchat")!); + root.render(app); } export async function showError(title: string, messages?: string[]): Promise { @@ -101,11 +103,11 @@ export async function showError(title: string, messages?: string[]): Promise , - document.getElementById("matrixchat"), ); } @@ -114,11 +116,11 @@ export async function showIncompatibleBrowser(onAccept: () => void): Promise , - document.getElementById("matrixchat"), ); } diff --git a/test/test-utils/jest-matrix-react.tsx b/test/test-utils/jest-matrix-react.tsx index 4fbb0dc77d..2aad5d45ff 100644 --- a/test/test-utils/jest-matrix-react.tsx +++ b/test/test-utils/jest-matrix-react.tsx @@ -27,7 +27,6 @@ const wrapWithTooltipProvider = (Wrapper: RenderOptions["wrapper"]) => { const customRender = (ui: ReactElement, options: RenderOptions = {}) => { return render(ui, { - legacyRoot: true, ...options, wrapper: wrapWithTooltipProvider(options?.wrapper) as RenderOptions["wrapper"], }) as ReturnType; diff --git a/test/test-utils/utilities.ts b/test/test-utils/utilities.ts index 29b25fda21..5285a840b2 100644 --- a/test/test-utils/utilities.ts +++ b/test/test-utils/utilities.ts @@ -197,7 +197,7 @@ export const clearAllModals = async (): Promise => { // Prevent modals from leaking and polluting other tests let keepClosingModals = true; while (keepClosingModals) { - keepClosingModals = Modal.closeCurrentModal(); + keepClosingModals = await act(() => Modal.closeCurrentModal()); // Then wait for the screen to update (probably React rerender and async/await). // Important for tests using Jest fake timers to not get into an infinite loop diff --git a/test/unit-tests/accessibility/RovingTabIndex-test.tsx b/test/unit-tests/accessibility/RovingTabIndex-test.tsx index c814502732..520103bca1 100644 --- a/test/unit-tests/accessibility/RovingTabIndex-test.tsx +++ b/test/unit-tests/accessibility/RovingTabIndex-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { HTMLAttributes } from "react"; -import { render } from "jest-matrix-react"; +import { act, render } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { @@ -79,15 +79,15 @@ describe("RovingTabIndex", () => { checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); // focus on 2nd button and test it is the only active one - container.querySelectorAll("button")[2].focus(); + act(() => container.querySelectorAll("button")[2].focus()); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); // focus on 1st button and test it is the only active one - container.querySelectorAll("button")[1].focus(); + act(() => container.querySelectorAll("button")[1].focus()); checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); // check that the active button does not change even on an explicit blur event - container.querySelectorAll("button")[1].blur(); + act(() => container.querySelectorAll("button")[1].blur()); checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); // update the children, it should remain on the same button @@ -162,7 +162,7 @@ describe("RovingTabIndex", () => { checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); // focus on 2nd button and test it is the only active one - container.querySelectorAll("button")[2].focus(); + act(() => container.querySelectorAll("button")[2].focus()); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); }); @@ -390,7 +390,7 @@ describe("RovingTabIndex", () => { , ); - container.querySelectorAll("button")[0].focus(); + act(() => container.querySelectorAll("button")[0].focus()); checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); await userEvent.keyboard("[ArrowDown]"); @@ -423,7 +423,7 @@ describe("RovingTabIndex", () => { , ); - container.querySelectorAll("button")[0].focus(); + act(() => container.querySelectorAll("button")[0].focus()); checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); const button = container.querySelectorAll("button")[1]; diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index b3766bfc89..c9662c64f4 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -11,7 +11,7 @@ Please see LICENSE files in the repository root for full details. import "core-js/stable/structured-clone"; import "fake-indexeddb/auto"; import React, { ComponentProps } from "react"; -import { fireEvent, render, RenderResult, screen, waitFor, within } from "jest-matrix-react"; +import { fireEvent, render, RenderResult, screen, waitFor, within, act } from "jest-matrix-react"; import fetchMock from "fetch-mock-jest"; import { Mocked, mocked } from "jest-mock"; import { ClientEvent, MatrixClient, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix"; @@ -163,7 +163,7 @@ describe("", () => { let initPromise: Promise | undefined; let defaultProps: ComponentProps; const getComponent = (props: Partial> = {}) => - render(); + render(, { legacyRoot: true }); // make test results readable filterConsole( @@ -201,7 +201,7 @@ describe("", () => { // we are logged in, but are still waiting for the /sync to complete await screen.findByText("Syncing…"); // initial sync - client.emit(ClientEvent.Sync, SyncState.Prepared, null); + await act(() => client.emit(ClientEvent.Sync, SyncState.Prepared, null)); } // let things settle @@ -263,7 +263,7 @@ describe("", () => { // emit a loggedOut event so that all of the Store singletons forget about their references to the mock client // (must be sync otherwise the next test will start before it happens) - defaultDispatcher.dispatch({ action: Action.OnLoggedOut }, true); + act(() => defaultDispatcher.dispatch({ action: Action.OnLoggedOut }, true)); localStorage.clear(); }); @@ -328,7 +328,7 @@ describe("", () => { expect(within(dialog).getByText(errorMessage)).toBeInTheDocument(); // just check we're back on welcome page - await expect(await screen.findByTestId("mx_welcome_screen")).toBeInTheDocument(); + await expect(screen.findByTestId("mx_welcome_screen")).resolves.toBeInTheDocument(); }; beforeEach(() => { @@ -956,9 +956,11 @@ describe("", () => { await screen.findByText("Powered by Matrix"); // go to login page - defaultDispatcher.dispatch({ - action: "start_login", - }); + act(() => + defaultDispatcher.dispatch({ + action: "start_login", + }), + ); await flushPromises(); @@ -1126,9 +1128,11 @@ describe("", () => { await getComponentAndLogin(); - bootstrapDeferred.resolve(); + act(() => bootstrapDeferred.resolve()); - await expect(await screen.findByRole("heading", { name: "You're in", level: 1 })).toBeInTheDocument(); + await expect( + screen.findByRole("heading", { name: "You're in", level: 1 }), + ).resolves.toBeInTheDocument(); }); }); }); @@ -1397,7 +1401,9 @@ describe("", () => { function simulateSessionLockClaim() { localStorage.setItem("react_sdk_session_lock_claimant", "testtest"); - window.dispatchEvent(new StorageEvent("storage", { key: "react_sdk_session_lock_claimant" })); + act(() => + window.dispatchEvent(new StorageEvent("storage", { key: "react_sdk_session_lock_claimant" })), + ); } it("after a session is restored", async () => { diff --git a/test/unit-tests/components/structures/PipContainer-test.tsx b/test/unit-tests/components/structures/PipContainer-test.tsx index d55905d4b4..446727c74e 100644 --- a/test/unit-tests/components/structures/PipContainer-test.tsx +++ b/test/unit-tests/components/structures/PipContainer-test.tsx @@ -81,9 +81,7 @@ describe("PipContainer", () => { let voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore; const actFlushPromises = async () => { - await act(async () => { - await flushPromises(); - }); + await flushPromises(); }; beforeEach(async () => { @@ -165,12 +163,12 @@ describe("PipContainer", () => { if (!(call instanceof MockedCall)) throw new Error("Failed to create call"); const widget = new Widget(call.widget); - WidgetStore.instance.addVirtualWidget(call.widget, room.roomId); - WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { - stop: () => {}, - } as unknown as ClientWidgetApi); - await act(async () => { + WidgetStore.instance.addVirtualWidget(call.widget, room.roomId); + WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { + stop: () => {}, + } as unknown as ClientWidgetApi); + await call.start(); ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true); }); @@ -178,9 +176,11 @@ describe("PipContainer", () => { await fn(call); cleanup(); - call.destroy(); - ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId); - WidgetStore.instance.removeVirtualWidget(widget.id, room.roomId); + act(() => { + call.destroy(); + ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId); + WidgetStore.instance.removeVirtualWidget(widget.id, room.roomId); + }); }; const withWidget = async (fn: () => Promise): Promise => { diff --git a/test/unit-tests/components/structures/RoomView-test.tsx b/test/unit-tests/components/structures/RoomView-test.tsx index f30db3d80e..b6fbd2e850 100644 --- a/test/unit-tests/components/structures/RoomView-test.tsx +++ b/test/unit-tests/components/structures/RoomView-test.tsx @@ -23,14 +23,22 @@ import { } from "matrix-js-sdk/src/matrix"; import { CryptoApi, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import { KnownMembership } from "matrix-js-sdk/src/types"; -import { fireEvent, render, screen, RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react"; +import { + fireEvent, + render, + screen, + RenderResult, + waitForElementToBeRemoved, + waitFor, + act, + cleanup, +} from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { stubClient, mockPlatformPeg, unmockPlatformPeg, - wrapInMatrixClientContext, flushPromises, mkEvent, setupAsyncStoreWithClient, @@ -45,7 +53,7 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { Action } from "../../../../src/dispatcher/actions"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { ViewRoomPayload } from "../../../../src/dispatcher/payloads/ViewRoomPayload"; -import { RoomView as _RoomView } from "../../../../src/components/structures/RoomView"; +import { RoomView } from "../../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../../src/settings/SettingLevel"; @@ -64,8 +72,7 @@ import WidgetStore from "../../../../src/stores/WidgetStore"; import { ViewRoomErrorPayload } from "../../../../src/dispatcher/payloads/ViewRoomErrorPayload"; import { SearchScope } from "../../../../src/Searching"; import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../../src/utils/crypto"; - -const RoomView = wrapInMatrixClientContext(_RoomView); +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; describe("RoomView", () => { let cli: MockedObject; @@ -106,9 +113,10 @@ describe("RoomView", () => { afterEach(() => { unmockPlatformPeg(); jest.clearAllMocks(); + cleanup(); }); - const mountRoomView = async (ref?: RefObject<_RoomView>): Promise => { + const mountRoomView = async (ref?: RefObject): Promise => { if (stores.roomViewStore.getRoomId() !== room.roomId) { const switchedRoom = new Promise((resolve) => { const subFn = () => { @@ -120,26 +128,30 @@ describe("RoomView", () => { stores.roomViewStore.on(UPDATE_EVENT, subFn); }); - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - room_id: room.roomId, - metricsTrigger: undefined, - }); + act(() => + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + metricsTrigger: undefined, + }), + ); await switchedRoom; } const roomView = render( - - - , + + + + + , ); await flushPromises(); return roomView; @@ -167,22 +179,24 @@ describe("RoomView", () => { } const roomView = render( - - - , + + + + + , ); await flushPromises(); return roomView; }; - const getRoomViewInstance = async (): Promise<_RoomView> => { - const ref = createRef<_RoomView>(); + const getRoomViewInstance = async (): Promise => { + const ref = createRef(); await mountRoomView(ref); return ref.current!; }; @@ -193,7 +207,7 @@ describe("RoomView", () => { }); describe("when there is an old room", () => { - let instance: _RoomView; + let instance: RoomView; let oldRoom: Room; beforeEach(async () => { @@ -217,11 +231,11 @@ describe("RoomView", () => { describe("and feature_dynamic_room_predecessors is enabled", () => { beforeEach(() => { - instance.setState({ msc3946ProcessDynamicPredecessor: true }); + act(() => instance.setState({ msc3946ProcessDynamicPredecessor: true })); }); afterEach(() => { - instance.setState({ msc3946ProcessDynamicPredecessor: false }); + act(() => instance.setState({ msc3946ProcessDynamicPredecessor: false })); }); it("should pass the setting to findPredecessor", async () => { @@ -252,15 +266,17 @@ describe("RoomView", () => { cli.isRoomEncrypted.mockReturnValue(true); // and fake an encryption event into the room to prompt it to re-check - room.addLiveEvents([ - new MatrixEvent({ - type: "m.room.encryption", - sender: cli.getUserId()!, - content: {}, - event_id: "someid", - room_id: room.roomId, - }), - ]); + await act(() => + room.addLiveEvents([ + new MatrixEvent({ + type: "m.room.encryption", + sender: cli.getUserId()!, + content: {}, + event_id: "someid", + room_id: room.roomId, + }), + ]), + ); // URL previews should now be disabled expect(roomViewInstance.state.showUrlPreview).toBe(false); @@ -270,7 +286,7 @@ describe("RoomView", () => { const roomViewInstance = await getRoomViewInstance(); const oldTimeline = roomViewInstance.state.liveTimeline; - room.getUnfilteredTimelineSet().resetLiveTimeline(); + act(() => room.getUnfilteredTimelineSet().resetLiveTimeline()); expect(roomViewInstance.state.liveTimeline).not.toEqual(oldTimeline); }); @@ -287,7 +303,7 @@ describe("RoomView", () => { await renderRoomView(); expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledWith(room.roomId); - cli.emit(ClientEvent.Room, room); + act(() => cli.emit(ClientEvent.Room, room)); // called again after room event expect(VoipUserMapper.sharedInstance().getVirtualRoomForRoom).toHaveBeenCalledTimes(2); @@ -429,6 +445,194 @@ describe("RoomView", () => { }); }); + it("should show error view if failed to look up room alias", async () => { + const { asFragment, findByText } = await renderRoomView(false); + + act(() => + defaultDispatcher.dispatch({ + action: Action.ViewRoomError, + room_alias: "#addy:server", + room_id: null, + err: new MatrixError({ errcode: "M_NOT_FOUND" }), + }), + ); + await emitPromise(stores.roomViewStore, UPDATE_EVENT); + + await findByText("Are you sure you're at the right place?"); + expect(asFragment()).toMatchSnapshot(); + }); + + describe("knock rooms", () => { + const client = createTestClient(); + + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join"); + jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock); + jest.spyOn(defaultDispatcher, "dispatch"); + }); + + it("allows to request to join", async () => { + jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); + jest.spyOn(client, "knockRoom").mockResolvedValue({ room_id: room.roomId }); + + await mountRoomView(); + fireEvent.click(screen.getByRole("button", { name: "Request access" })); + await untilDispatch(Action.SubmitAskToJoin, defaultDispatcher); + + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: "submit_ask_to_join", + roomId: room.roomId, + opts: { reason: undefined }, + }); + }); + + it("allows to cancel a join request", async () => { + jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); + jest.spyOn(client, "leave").mockResolvedValue({}); + jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Knock); + + await mountRoomView(); + fireEvent.click(screen.getByRole("button", { name: "Cancel request" })); + await untilDispatch(Action.CancelAskToJoin, defaultDispatcher); + + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: "cancel_ask_to_join", + roomId: room.roomId, + }); + }); + }); + + it("should close search results when edit is clicked", async () => { + room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); + + const eventMapper = (obj: Partial) => new MatrixEvent(obj); + + const roomViewRef = createRef(); + const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef); + await waitFor(() => expect(roomViewRef.current).toBeTruthy()); + // @ts-ignore - triggering a search organically is a lot of work + act(() => + roomViewRef.current!.setState({ + search: { + searchId: 1, + roomId: room.roomId, + term: "search term", + scope: SearchScope.Room, + promise: Promise.resolve({ + results: [ + SearchResult.fromJson( + { + rank: 1, + result: { + content: { + body: "search term", + msgtype: "m.text", + }, + type: "m.room.message", + event_id: "$eventId", + sender: cli.getSafeUserId(), + origin_server_ts: 123456789, + room_id: room.roomId, + }, + context: { + events_before: [], + events_after: [], + profile_info: {}, + }, + }, + eventMapper, + ), + ], + highlights: [], + count: 1, + }), + inProgress: false, + count: 1, + }, + }), + ); + + await waitFor(() => { + expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); + }); + const prom = waitForElementToBeRemoved(() => container.querySelector(".mx_RoomView_searchResultsPanel")); + + await userEvent.hover(getByText("search term")); + await userEvent.click(await findByLabelText("Edit")); + + await prom; + }); + + it("should switch rooms when edit is clicked on a search result for a different room", async () => { + const room2 = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); + rooms.set(room2.roomId, room2); + + room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); + + const eventMapper = (obj: Partial) => new MatrixEvent(obj); + + const roomViewRef = createRef(); + const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef); + await waitFor(() => expect(roomViewRef.current).toBeTruthy()); + // @ts-ignore - triggering a search organically is a lot of work + act(() => + roomViewRef.current!.setState({ + search: { + searchId: 1, + roomId: room.roomId, + term: "search term", + scope: SearchScope.All, + promise: Promise.resolve({ + results: [ + SearchResult.fromJson( + { + rank: 1, + result: { + content: { + body: "search term", + msgtype: "m.text", + }, + type: "m.room.message", + event_id: "$eventId", + sender: cli.getSafeUserId(), + origin_server_ts: 123456789, + room_id: room2.roomId, + }, + context: { + events_before: [], + events_after: [], + profile_info: {}, + }, + }, + eventMapper, + ), + ], + highlights: [], + count: 1, + }), + inProgress: false, + count: 1, + }, + }), + ); + + await waitFor(() => { + expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); + }); + const prom = untilDispatch(Action.ViewRoom, defaultDispatcher); + + await userEvent.hover(getByText("search term")); + await userEvent.click(await findByLabelText("Edit")); + + await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId })); + }); + + it("fires Action.RoomLoaded", async () => { + jest.spyOn(defaultDispatcher, "dispatch"); + await mountRoomView(); + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.RoomLoaded }); + }); + describe("when there is a RoomView", () => { const widget1Id = "widget1"; const widget2Id = "widget2"; @@ -514,184 +718,4 @@ describe("RoomView", () => { }); }); }); - - it("should show error view if failed to look up room alias", async () => { - const { asFragment, findByText } = await renderRoomView(false); - - defaultDispatcher.dispatch({ - action: Action.ViewRoomError, - room_alias: "#addy:server", - room_id: null, - err: new MatrixError({ errcode: "M_NOT_FOUND" }), - }); - await emitPromise(stores.roomViewStore, UPDATE_EVENT); - - await findByText("Are you sure you're at the right place?"); - expect(asFragment()).toMatchSnapshot(); - }); - - describe("knock rooms", () => { - const client = createTestClient(); - - beforeEach(() => { - jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join"); - jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock); - jest.spyOn(defaultDispatcher, "dispatch"); - }); - - it("allows to request to join", async () => { - jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); - jest.spyOn(client, "knockRoom").mockResolvedValue({ room_id: room.roomId }); - - await mountRoomView(); - fireEvent.click(screen.getByRole("button", { name: "Request access" })); - await untilDispatch(Action.SubmitAskToJoin, defaultDispatcher); - - expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ - action: "submit_ask_to_join", - roomId: room.roomId, - opts: { reason: undefined }, - }); - }); - - it("allows to cancel a join request", async () => { - jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); - jest.spyOn(client, "leave").mockResolvedValue({}); - jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Knock); - - await mountRoomView(); - fireEvent.click(screen.getByRole("button", { name: "Cancel request" })); - await untilDispatch(Action.CancelAskToJoin, defaultDispatcher); - - expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ - action: "cancel_ask_to_join", - roomId: room.roomId, - }); - }); - }); - - it("should close search results when edit is clicked", async () => { - room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); - - const eventMapper = (obj: Partial) => new MatrixEvent(obj); - - const roomViewRef = createRef<_RoomView>(); - const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef); - // @ts-ignore - triggering a search organically is a lot of work - roomViewRef.current!.setState({ - search: { - searchId: 1, - roomId: room.roomId, - term: "search term", - scope: SearchScope.Room, - promise: Promise.resolve({ - results: [ - SearchResult.fromJson( - { - rank: 1, - result: { - content: { - body: "search term", - msgtype: "m.text", - }, - type: "m.room.message", - event_id: "$eventId", - sender: cli.getSafeUserId(), - origin_server_ts: 123456789, - room_id: room.roomId, - }, - context: { - events_before: [], - events_after: [], - profile_info: {}, - }, - }, - eventMapper, - ), - ], - highlights: [], - count: 1, - }), - inProgress: false, - count: 1, - }, - }); - - await waitFor(() => { - expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); - }); - const prom = waitForElementToBeRemoved(() => container.querySelector(".mx_RoomView_searchResultsPanel")); - - await userEvent.hover(getByText("search term")); - await userEvent.click(await findByLabelText("Edit")); - - await prom; - }); - - it("should switch rooms when edit is clicked on a search result for a different room", async () => { - const room2 = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); - rooms.set(room2.roomId, room2); - - room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); - - const eventMapper = (obj: Partial) => new MatrixEvent(obj); - - const roomViewRef = createRef<_RoomView>(); - const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef); - // @ts-ignore - triggering a search organically is a lot of work - roomViewRef.current!.setState({ - search: { - searchId: 1, - roomId: room.roomId, - term: "search term", - scope: SearchScope.All, - promise: Promise.resolve({ - results: [ - SearchResult.fromJson( - { - rank: 1, - result: { - content: { - body: "search term", - msgtype: "m.text", - }, - type: "m.room.message", - event_id: "$eventId", - sender: cli.getSafeUserId(), - origin_server_ts: 123456789, - room_id: room2.roomId, - }, - context: { - events_before: [], - events_after: [], - profile_info: {}, - }, - }, - eventMapper, - ), - ], - highlights: [], - count: 1, - }), - inProgress: false, - count: 1, - }, - }); - - await waitFor(() => { - expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); - }); - const prom = untilDispatch(Action.ViewRoom, defaultDispatcher); - - await userEvent.hover(getByText("search term")); - await userEvent.click(await findByLabelText("Edit")); - - await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId })); - }); - - it("fires Action.RoomLoaded", async () => { - jest.spyOn(defaultDispatcher, "dispatch"); - await mountRoomView(); - expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.RoomLoaded }); - }); }); diff --git a/test/unit-tests/components/structures/ThreadPanel-test.tsx b/test/unit-tests/components/structures/ThreadPanel-test.tsx index 1b4d59d9af..c19127de25 100644 --- a/test/unit-tests/components/structures/ThreadPanel-test.tsx +++ b/test/unit-tests/components/structures/ThreadPanel-test.tsx @@ -215,34 +215,33 @@ describe("ThreadPanel", () => { myThreads!.addLiveEvent(mixedThread.rootEvent); myThreads!.addLiveEvent(ownThread.rootEvent); - let events: EventData[] = []; const renderResult = render(); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); await waitFor(() => { - events = findEvents(renderResult.container); - expect(findEvents(renderResult.container)).toHaveLength(3); + const events = findEvents(renderResult.container); + expect(events).toHaveLength(3); + expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); + expect(events[1]).toEqual(toEventData(mixedThread.rootEvent)); + expect(events[2]).toEqual(toEventData(ownThread.rootEvent)); }); - expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); - expect(events[1]).toEqual(toEventData(mixedThread.rootEvent)); - expect(events[2]).toEqual(toEventData(ownThread.rootEvent)); await waitFor(() => expect(renderResult.container.querySelector(".mx_ThreadPanel_dropdown")).toBeTruthy()); toggleThreadFilter(renderResult.container, ThreadFilterType.My); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); await waitFor(() => { - events = findEvents(renderResult.container); - expect(findEvents(renderResult.container)).toHaveLength(2); + const events = findEvents(renderResult.container); + expect(events).toHaveLength(2); + expect(events[0]).toEqual(toEventData(mixedThread.rootEvent)); + expect(events[1]).toEqual(toEventData(ownThread.rootEvent)); }); - expect(events[0]).toEqual(toEventData(mixedThread.rootEvent)); - expect(events[1]).toEqual(toEventData(ownThread.rootEvent)); toggleThreadFilter(renderResult.container, ThreadFilterType.All); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); await waitFor(() => { - events = findEvents(renderResult.container); - expect(findEvents(renderResult.container)).toHaveLength(3); + const events = findEvents(renderResult.container); + expect(events).toHaveLength(3); + expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); + expect(events[1]).toEqual(toEventData(mixedThread.rootEvent)); + expect(events[2]).toEqual(toEventData(ownThread.rootEvent)); }); - expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); - expect(events[1]).toEqual(toEventData(mixedThread.rootEvent)); - expect(events[2]).toEqual(toEventData(ownThread.rootEvent)); }); it("correctly filters Thread List with a single, unparticipated thread", async () => { @@ -261,28 +260,27 @@ describe("ThreadPanel", () => { const [allThreads] = room.threadsTimelineSets; allThreads!.addLiveEvent(otherThread.rootEvent); - let events: EventData[] = []; const renderResult = render(); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); await waitFor(() => { - events = findEvents(renderResult.container); - expect(findEvents(renderResult.container)).toHaveLength(1); + const events = findEvents(renderResult.container); + expect(events).toHaveLength(1); + expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); }); - expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); await waitFor(() => expect(renderResult.container.querySelector(".mx_ThreadPanel_dropdown")).toBeTruthy()); toggleThreadFilter(renderResult.container, ThreadFilterType.My); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); await waitFor(() => { - events = findEvents(renderResult.container); - expect(findEvents(renderResult.container)).toHaveLength(0); + const events = findEvents(renderResult.container); + expect(events).toHaveLength(0); }); toggleThreadFilter(renderResult.container, ThreadFilterType.All); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); await waitFor(() => { - events = findEvents(renderResult.container); - expect(findEvents(renderResult.container)).toHaveLength(1); + const events = findEvents(renderResult.container); + expect(events).toHaveLength(1); + expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); }); - expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); }); }); }); diff --git a/test/unit-tests/components/structures/TimelinePanel-test.tsx b/test/unit-tests/components/structures/TimelinePanel-test.tsx index 4a66351779..cee7e143d5 100644 --- a/test/unit-tests/components/structures/TimelinePanel-test.tsx +++ b/test/unit-tests/components/structures/TimelinePanel-test.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import { render, waitFor, screen } from "jest-matrix-react"; +import { render, waitFor, screen, act } from "jest-matrix-react"; import { ReceiptType, EventTimelineSet, @@ -205,8 +205,10 @@ describe("TimelinePanel", () => { manageReadReceipts={true} ref={ref} />, + { legacyRoot: true }, ); await flushPromises(); + await waitFor(() => expect(ref.current).toBeTruthy()); timelinePanel = ref.current!; }; @@ -255,14 +257,16 @@ describe("TimelinePanel", () => { describe("and reading the timeline", () => { beforeEach(async () => { - await renderTimelinePanel(); - timelineSet.addLiveEvent(ev1, {}); - await flushPromises(); + await act(async () => { + await renderTimelinePanel(); + timelineSet.addLiveEvent(ev1, {}); + await flushPromises(); - // @ts-ignore - await timelinePanel.sendReadReceipts(); - // @ts-ignore Simulate user activity by calling updateReadMarker on the TimelinePanel. - await timelinePanel.updateReadMarker(); + // @ts-ignore + await timelinePanel.sendReadReceipts(); + // @ts-ignore Simulate user activity by calling updateReadMarker on the TimelinePanel. + await timelinePanel.updateReadMarker(); + }); }); it("should send a fully read marker and a public receipt", async () => { @@ -276,7 +280,7 @@ describe("TimelinePanel", () => { client.setRoomReadMarkers.mockClear(); // @ts-ignore Simulate user activity by calling updateReadMarker on the TimelinePanel. - await timelinePanel.updateReadMarker(); + await act(() => timelinePanel.updateReadMarker()); }); it("should not send receipts again", () => { @@ -315,7 +319,7 @@ describe("TimelinePanel", () => { it("should send a fully read marker and a private receipt", async () => { await renderTimelinePanel(); - timelineSet.addLiveEvent(ev1, {}); + act(() => timelineSet.addLiveEvent(ev1, {})); await flushPromises(); // @ts-ignore @@ -326,6 +330,7 @@ describe("TimelinePanel", () => { // Expect the fully_read marker not to be send yet expect(client.setRoomReadMarkers).not.toHaveBeenCalled(); + await flushPromises(); client.sendReadReceipt.mockClear(); // @ts-ignore simulate user activity @@ -334,7 +339,7 @@ describe("TimelinePanel", () => { // It should not send the receipt again. expect(client.sendReadReceipt).not.toHaveBeenCalledWith(ev1, ReceiptType.ReadPrivate); // Expect the fully_read marker to be sent after user activity. - expect(client.setRoomReadMarkers).toHaveBeenCalledWith(roomId, ev1.getId()); + await waitFor(() => expect(client.setRoomReadMarkers).toHaveBeenCalledWith(roomId, ev1.getId())); }); }); }); @@ -361,11 +366,11 @@ describe("TimelinePanel", () => { it("should send receipts but no fully_read when reading the thread timeline", async () => { await renderTimelinePanel(); - timelineSet.addLiveEvent(threadEv1, {}); + act(() => timelineSet.addLiveEvent(threadEv1, {})); await flushPromises(); // @ts-ignore - await timelinePanel.sendReadReceipts(); + await act(() => timelinePanel.sendReadReceipts()); // fully_read is not supported for threads per spec expect(client.setRoomReadMarkers).not.toHaveBeenCalled(); @@ -1021,7 +1026,7 @@ describe("TimelinePanel", () => { await waitFor(() => expectEvents(container, [events[1]])); }); - defaultDispatcher.fire(Action.DumpDebugLogs); + act(() => defaultDispatcher.fire(Action.DumpDebugLogs)); await waitFor(() => expect(spy).toHaveBeenCalledWith(expect.stringContaining("TimelinePanel(Room): Debugging info for roomId")), diff --git a/test/unit-tests/components/structures/auth/ForgotPassword-test.tsx b/test/unit-tests/components/structures/auth/ForgotPassword-test.tsx index db6ce005c0..413acfbafa 100644 --- a/test/unit-tests/components/structures/auth/ForgotPassword-test.tsx +++ b/test/unit-tests/components/structures/auth/ForgotPassword-test.tsx @@ -8,19 +8,13 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { mocked } from "jest-mock"; -import { act, render, RenderResult, screen, waitFor } from "jest-matrix-react"; +import { render, RenderResult, screen, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { MatrixClient, createClient } from "matrix-js-sdk/src/matrix"; import ForgotPassword from "../../../../../src/components/structures/auth/ForgotPassword"; import { ValidatedServerConfig } from "../../../../../src/utils/ValidatedServerConfig"; -import { - clearAllModals, - filterConsole, - flushPromisesWithFakeTimers, - stubClient, - waitEnoughCyclesForModal, -} from "../../../../test-utils"; +import { clearAllModals, filterConsole, stubClient, waitEnoughCyclesForModal } from "../../../../test-utils"; import AutoDiscoveryUtils from "../../../../../src/utils/AutoDiscoveryUtils"; jest.mock("matrix-js-sdk/src/matrix", () => ({ @@ -39,11 +33,7 @@ describe("", () => { let renderResult: RenderResult; const typeIntoField = async (label: string, value: string): Promise => { - await act(async () => { - await userEvent.type(screen.getByLabelText(label), value, { delay: null }); - // the message is shown after some time - jest.advanceTimersByTime(500); - }); + await userEvent.type(screen.getByLabelText(label), value, { delay: null }); }; const click = async (element: Element): Promise => { @@ -80,18 +70,11 @@ describe("", () => { await clearAllModals(); }); - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - describe("when starting a password reset flow", () => { beforeEach(() => { renderResult = render( , + { legacyRoot: true }, ); }); @@ -128,8 +111,10 @@ describe("", () => { await typeIntoField("Email address", "not en email"); }); - it("should show a message about the wrong format", () => { - expect(screen.getByText("The email address doesn't appear to be valid.")).toBeInTheDocument(); + it("should show a message about the wrong format", async () => { + await expect( + screen.findByText("The email address doesn't appear to be valid."), + ).resolves.toBeInTheDocument(); }); }); @@ -142,8 +127,8 @@ describe("", () => { await click(screen.getByText("Send email")); }); - it("should show an email not found message", () => { - expect(screen.getByText("This email address was not found")).toBeInTheDocument(); + it("should show an email not found message", async () => { + await expect(screen.findByText("This email address was not found")).resolves.toBeInTheDocument(); }); }); @@ -156,13 +141,12 @@ describe("", () => { await click(screen.getByText("Send email")); }); - it("should show an info about that", () => { - expect( - screen.getByText( - "Cannot reach homeserver: " + - "Ensure you have a stable internet connection, or get in touch with the server admin", + it("should show an info about that", async () => { + await expect( + screen.findByText( + "Cannot reach homeserver: Ensure you have a stable internet connection, or get in touch with the server admin", ), - ).toBeInTheDocument(); + ).resolves.toBeInTheDocument(); }); }); @@ -178,8 +162,8 @@ describe("", () => { await click(screen.getByText("Send email")); }); - it("should show the server error", () => { - expect(screen.queryByText("server down")).toBeInTheDocument(); + it("should show the server error", async () => { + await expect(screen.findByText("server down")).resolves.toBeInTheDocument(); }); }); @@ -215,8 +199,6 @@ describe("", () => { describe("and clicking »Resend«", () => { beforeEach(async () => { await click(screen.getByText("Resend")); - // the message is shown after some time - jest.advanceTimersByTime(500); }); it("should should resend the mail and show the tooltip", () => { @@ -246,8 +228,10 @@ describe("", () => { await typeIntoField("Confirm new password", testPassword + "asd"); }); - it("should show an info about that", () => { - expect(screen.getByText("New passwords must match each other.")).toBeInTheDocument(); + it("should show an info about that", async () => { + await expect( + screen.findByText("New passwords must match each other."), + ).resolves.toBeInTheDocument(); }); }); @@ -284,7 +268,7 @@ describe("", () => { await click(screen.getByText("Reset password")); }); - it("should send the new password (once)", () => { + it("should send the new password (once)", async () => { expect(client.setPassword).toHaveBeenCalledWith( { type: "m.login.email.identity", @@ -297,19 +281,15 @@ describe("", () => { false, ); - // be sure that the next attempt to set the password would have been sent - jest.advanceTimersByTime(3000); // it should not retry to set the password - expect(client.setPassword).toHaveBeenCalledTimes(1); + await waitFor(() => expect(client.setPassword).toHaveBeenCalledTimes(1)); }); }); describe("and submitting it", () => { beforeEach(async () => { await click(screen.getByText("Reset password")); - await waitEnoughCyclesForModal({ - useFakeTimers: true, - }); + await waitEnoughCyclesForModal(); }); it("should send the new password and show the click validation link dialog", async () => { @@ -367,23 +347,22 @@ describe("", () => { expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument(); }); }); + }); - describe("and validating the link from the mail", () => { - beforeEach(async () => { - mocked(client.setPassword).mockResolvedValue({}); - // be sure the next set password attempt was sent - jest.advanceTimersByTime(3000); - // quad flush promises for the modal to disappear - await flushPromisesWithFakeTimers(); - await flushPromisesWithFakeTimers(); - await flushPromisesWithFakeTimers(); - await flushPromisesWithFakeTimers(); - }); + describe("and validating the link from the mail", () => { + beforeEach(async () => { + mocked(client.setPassword).mockResolvedValue({}); + await click(screen.getByText("Reset password")); + // flush promises for the modal to disappear + await waitEnoughCyclesForModal(); + await waitEnoughCyclesForModal(); + }); - it("should display the confirm reset view and now show the dialog", () => { - expect(screen.queryByText("Your password has been reset.")).toBeInTheDocument(); - expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument(); - }); + it("should display the confirm reset view and now show the dialog", async () => { + await expect( + screen.findByText("Your password has been reset."), + ).resolves.toBeInTheDocument(); + expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument(); }); }); @@ -391,9 +370,6 @@ describe("", () => { beforeEach(async () => { await click(screen.getByText("Sign out of all devices")); await click(screen.getByText("Reset password")); - await waitEnoughCyclesForModal({ - useFakeTimers: true, - }); }); it("should show the sign out warning dialog", async () => { diff --git a/test/unit-tests/components/views/dialogs/SpotlightDialog-test.tsx b/test/unit-tests/components/views/dialogs/SpotlightDialog-test.tsx index 5cc95b96ee..54d21e147b 100644 --- a/test/unit-tests/components/views/dialogs/SpotlightDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/SpotlightDialog-test.tsx @@ -239,7 +239,7 @@ describe("Spotlight Dialog", () => { }); it("should call getVisibleRooms with MSC3946 dynamic room predecessors", async () => { - render( null} />, { legacyRoot: false }); + render( null} />); jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); expect(mockedClient.getVisibleRooms).toHaveBeenCalledWith(true); diff --git a/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx b/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx index fa1d74955d..9e792a48f3 100644 --- a/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx @@ -13,7 +13,7 @@ import { mocked, MockedObject } from "jest-mock"; import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { sleep } from "matrix-js-sdk/src/utils"; -import { filterConsole, stubClient } from "../../../../../test-utils"; +import { filterConsole, flushPromises, stubClient } from "../../../../../test-utils"; import CreateSecretStorageDialog from "../../../../../../src/async-components/views/dialogs/security/CreateSecretStorageDialog"; describe("CreateSecretStorageDialog", () => { @@ -125,6 +125,7 @@ describe("CreateSecretStorageDialog", () => { resetFunctionCallLog.push("resetKeyBackup"); }); + await flushPromises(); result.getByRole("button", { name: "Continue" }).click(); await result.findByText("Your keys are now being backed up from this device."); diff --git a/test/unit-tests/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx b/test/unit-tests/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx index b0ee3531e2..6e8837c50d 100644 --- a/test/unit-tests/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { screen, fireEvent, render, waitFor } from "jest-matrix-react"; +import { screen, fireEvent, render, waitFor, act } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { Crypto, IMegolmSessionData } from "matrix-js-sdk/src/matrix"; @@ -23,12 +23,12 @@ describe("ExportE2eKeysDialog", () => { expect(asFragment()).toMatchSnapshot(); }); - it("should have disabled submit button initially", () => { + it("should have disabled submit button initially", async () => { const cli = createTestClient(); const onFinished = jest.fn(); const { container } = render(); - fireEvent.click(container.querySelector("[type=submit]")!); - expect(screen.getByText("Enter passphrase")).toBeInTheDocument(); + await act(() => fireEvent.click(container.querySelector("[type=submit]")!)); + expect(screen.getByLabelText("Enter passphrase")).toBeInTheDocument(); }); it("should complain about weak passphrases", async () => { @@ -38,7 +38,7 @@ describe("ExportE2eKeysDialog", () => { const { container } = render(); const input = screen.getByLabelText("Enter passphrase"); await userEvent.type(input, "password"); - fireEvent.click(container.querySelector("[type=submit]")!); + await act(() => fireEvent.click(container.querySelector("[type=submit]")!)); await expect(screen.findByText("This is a top-10 common password")).resolves.toBeInTheDocument(); }); @@ -49,7 +49,7 @@ describe("ExportE2eKeysDialog", () => { const { container } = render(); await userEvent.type(screen.getByLabelText("Enter passphrase"), "ThisIsAMoreSecurePW123$$"); await userEvent.type(screen.getByLabelText("Confirm passphrase"), "ThisIsAMoreSecurePW124$$"); - fireEvent.click(container.querySelector("[type=submit]")!); + await act(() => fireEvent.click(container.querySelector("[type=submit]")!)); await expect(screen.findByText("Passphrases must match")).resolves.toBeInTheDocument(); }); @@ -74,7 +74,7 @@ describe("ExportE2eKeysDialog", () => { const { container } = render(); await userEvent.type(screen.getByLabelText("Enter passphrase"), passphrase); await userEvent.type(screen.getByLabelText("Confirm passphrase"), passphrase); - fireEvent.click(container.querySelector("[type=submit]")!); + await act(() => fireEvent.click(container.querySelector("[type=submit]")!)); // Then it exports keys and encrypts them await waitFor(() => expect(exportRoomKeysAsJson).toHaveBeenCalled()); diff --git a/test/unit-tests/components/views/elements/AppTile-test.tsx b/test/unit-tests/components/views/elements/AppTile-test.tsx index 95ce95d3f4..12363f56f0 100644 --- a/test/unit-tests/components/views/elements/AppTile-test.tsx +++ b/test/unit-tests/components/views/elements/AppTile-test.tsx @@ -10,7 +10,7 @@ import React from "react"; import { Room, MatrixClient } from "matrix-js-sdk/src/matrix"; import { ClientWidgetApi, IWidget, MatrixWidgetType } from "matrix-widget-api"; import { Optional } from "matrix-events-sdk"; -import { act, render, RenderResult } from "jest-matrix-react"; +import { act, render, RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { ApprovalOpts, @@ -29,7 +29,6 @@ import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext import SettingsStore from "../../../../../src/settings/SettingsStore"; import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases"; import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore"; -import { UPDATE_EVENT } from "../../../../../src/stores/AsyncStore"; import WidgetStore, { IApp } from "../../../../../src/stores/WidgetStore"; import ActiveWidgetStore from "../../../../../src/stores/ActiveWidgetStore"; import AppTile from "../../../../../src/components/views/elements/AppTile"; @@ -59,16 +58,6 @@ describe("AppTile", () => { let app1: IApp; let app2: IApp; - const waitForRps = (roomId: string) => - new Promise((resolve) => { - const update = () => { - if (RightPanelStore.instance.currentCardForRoom(roomId).phase !== RightPanelPhases.Widget) return; - RightPanelStore.instance.off(UPDATE_EVENT, update); - resolve(); - }; - RightPanelStore.instance.on(UPDATE_EVENT, update); - }); - beforeAll(async () => { stubClient(); cli = MatrixClientPeg.safeGet(); @@ -160,29 +149,28 @@ describe("AppTile", () => { /> , ); - // Wait for RPS room 1 updates to fire - const rpsUpdated = waitForRps("r1"); - dis.dispatch({ - action: Action.ViewRoom, - room_id: "r1", - }); - await rpsUpdated; + act(() => + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r1", + }), + ); - expect(renderResult.getByText("Example 1")).toBeInTheDocument(); + await expect(renderResult.findByText("Example 1")).resolves.toBeInTheDocument(); expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(true); - const { container, asFragment } = renderResult; - expect(container.getElementsByClassName("mx_Spinner").length).toBeTruthy(); + const { asFragment } = renderResult; expect(asFragment()).toMatchSnapshot(); - // We want to verify that as we change to room 2, we should close the // right panel and destroy the widget. // Switch to room 2 - dis.dispatch({ - action: Action.ViewRoom, - room_id: "r2", - }); + act(() => + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r2", + }), + ); renderResult.rerender( @@ -233,16 +221,17 @@ describe("AppTile", () => { /> , ); - // Wait for RPS room 1 updates to fire - const rpsUpdated1 = waitForRps("r1"); - dis.dispatch({ - action: Action.ViewRoom, - room_id: "r1", - }); - await rpsUpdated1; + act(() => + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r1", + }), + ); - expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(true); - expect(ActiveWidgetStore.instance.isLive("1", "r2")).toBe(false); + await waitFor(() => { + expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(true); + expect(ActiveWidgetStore.instance.isLive("1", "r2")).toBe(false); + }); jest.spyOn(SettingsStore, "getValue").mockImplementation((name, roomId) => { if (name === "RightPanel.phases") { @@ -263,13 +252,13 @@ describe("AppTile", () => { } return realGetValue(name, roomId); }); - // Wait for RPS room 2 updates to fire - const rpsUpdated2 = waitForRps("r2"); // Switch to room 2 - dis.dispatch({ - action: Action.ViewRoom, - room_id: "r2", - }); + act(() => + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r2", + }), + ); renderResult.rerender( { /> , ); - await rpsUpdated2; - expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(false); - expect(ActiveWidgetStore.instance.isLive("1", "r2")).toBe(true); + await waitFor(() => { + expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(false); + expect(ActiveWidgetStore.instance.isLive("1", "r2")).toBe(true); + }); }); it("preserves non-persisted widget on container move", async () => { @@ -345,7 +335,7 @@ describe("AppTile", () => { let renderResult: RenderResult; let moveToContainerSpy: jest.SpyInstance; - beforeEach(() => { + beforeEach(async () => { renderResult = render( @@ -353,12 +343,12 @@ describe("AppTile", () => { ); moveToContainerSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer"); + await waitForElementToBeRemoved(() => renderResult.queryByRole("progressbar")); }); it("should render", () => { - const { container, asFragment } = renderResult; + const { asFragment } = renderResult; - expect(container.querySelector(".mx_Spinner")).toBeFalsy(); // Assert that the spinner is gone expect(asFragment()).toMatchSnapshot(); // Take a snapshot of the pinned widget }); @@ -459,18 +449,19 @@ describe("AppTile", () => { describe("for a persistent app", () => { let renderResult: RenderResult; - beforeEach(() => { + beforeEach(async () => { renderResult = render( , ); + + await waitForElementToBeRemoved(() => renderResult.queryByRole("progressbar")); }); - it("should render", () => { - const { container, asFragment } = renderResult; + it("should render", async () => { + const { asFragment } = renderResult; - expect(container.querySelector(".mx_Spinner")).toBeFalsy(); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/test/unit-tests/components/views/elements/Pill-test.tsx b/test/unit-tests/components/views/elements/Pill-test.tsx index 24fb2ca5dd..716b4513ce 100644 --- a/test/unit-tests/components/views/elements/Pill-test.tsx +++ b/test/unit-tests/components/views/elements/Pill-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { act, render, RenderResult, screen } from "jest-matrix-react"; +import { render, RenderResult, screen } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { mocked, Mocked } from "jest-mock"; import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; @@ -214,9 +214,7 @@ describe("", () => { }); // wait for profile query via API - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(renderResult.asFragment()).toMatchSnapshot(); }); @@ -228,9 +226,7 @@ describe("", () => { }); // wait for profile query via API - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(renderResult.asFragment()).toMatchSnapshot(); }); diff --git a/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap b/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap index b3b5fc3b89..f039d94514 100644 --- a/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap +++ b/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap @@ -60,29 +60,9 @@ exports[`AppTile destroys non-persisted right panel widget on room change 1`] = id="1" >
    -
    -
    -
    - Loading… -
    -   -
    -
    -
    +
    diff --git a/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx b/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx index d069d663b8..e67334ca61 100644 --- a/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx +++ b/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { createRef } from "react"; -import { render, waitFor } from "jest-matrix-react"; +import { render, waitFor, act } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import EmojiPicker from "../../../../../src/components/views/emojipicker/EmojiPicker"; @@ -27,12 +27,12 @@ describe("EmojiPicker", function () { // Apply a filter and assert that the HTML has changed //@ts-ignore private access - ref.current!.onChangeFilter("test"); + act(() => ref.current!.onChangeFilter("test")); expect(beforeHtml).not.toEqual(container.innerHTML); // Clear the filter and assert that the HTML matches what it was before filtering //@ts-ignore private access - ref.current!.onChangeFilter(""); + act(() => ref.current!.onChangeFilter("")); await waitFor(() => expect(beforeHtml).toEqual(container.innerHTML)); }); @@ -40,7 +40,7 @@ describe("EmojiPicker", function () { const ep = new EmojiPicker({ onChoose: (str: string) => false, onFinished: jest.fn() }); //@ts-ignore private access - ep.onChangeFilter("heart"); + act(() => ep.onChangeFilter("heart")); //@ts-ignore private access expect(ep.memoizedDataByCategory["people"][0].shortcodes[0]).toEqual("heart"); diff --git a/test/unit-tests/components/views/location/LocationShareMenu-test.tsx b/test/unit-tests/components/views/location/LocationShareMenu-test.tsx index 672580e952..84c5e91ea0 100644 --- a/test/unit-tests/components/views/location/LocationShareMenu-test.tsx +++ b/test/unit-tests/components/views/location/LocationShareMenu-test.tsx @@ -139,7 +139,7 @@ describe("", () => { const [, onGeolocateCallback] = mocked(mockGeolocate.on).mock.calls.find(([event]) => event === "geolocate")!; // set the location - onGeolocateCallback(position); + act(() => onGeolocateCallback(position)); }; const setLocationClick = () => { @@ -151,7 +151,7 @@ describe("", () => { lngLat: { lng: position.coords.longitude, lat: position.coords.latitude }, } as unknown as maplibregl.MapMouseEvent; // set the location - onMapClickCallback(event); + act(() => onMapClickCallback(event)); }; const shareTypeLabels: Record = { diff --git a/test/unit-tests/components/views/messages/DateSeparator-test.tsx b/test/unit-tests/components/views/messages/DateSeparator-test.tsx index 0c953a1738..aade46a2e2 100644 --- a/test/unit-tests/components/views/messages/DateSeparator-test.tsx +++ b/test/unit-tests/components/views/messages/DateSeparator-test.tsx @@ -48,6 +48,7 @@ describe("DateSeparator", () => { , + { legacyRoot: true }, ); type TestCase = [string, number, string]; @@ -264,10 +265,12 @@ describe("DateSeparator", () => { fireEvent.click(jumpToLastWeekButton); // Expect error to be shown. We have to wait for the UI to transition. - expect(await screen.findByTestId("jump-to-date-error-content")).toBeInTheDocument(); + await expect(screen.findByTestId("jump-to-date-error-content")).resolves.toBeInTheDocument(); // Expect an option to submit debug logs to be shown when a non-network error occurs - expect(await screen.findByTestId("jump-to-date-error-submit-debug-logs-button")).toBeInTheDocument(); + await expect( + screen.findByTestId("jump-to-date-error-submit-debug-logs-button"), + ).resolves.toBeInTheDocument(); }); [ @@ -280,19 +283,20 @@ describe("DateSeparator", () => { ), ].forEach((fakeError) => { it(`should show error dialog without submit debug logs option when networking error (${fakeError.name}) occurs`, async () => { + // Try to jump to "last week" but we want a network error to occur + mockClient.timestampToEvent.mockRejectedValue(fakeError); + // Render the component getComponent(); // Open the jump to date context menu fireEvent.click(screen.getByTestId("jump-to-date-separator-button")); - // Try to jump to "last week" but we want a network error to occur - mockClient.timestampToEvent.mockRejectedValue(fakeError); const jumpToLastWeekButton = await screen.findByTestId("jump-to-date-last-week"); fireEvent.click(jumpToLastWeekButton); // Expect error to be shown. We have to wait for the UI to transition. - expect(await screen.findByTestId("jump-to-date-error-content")).toBeInTheDocument(); + await expect(screen.findByTestId("jump-to-date-error-content")).resolves.toBeInTheDocument(); // The submit debug logs option should *NOT* be shown for network errors. // diff --git a/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx b/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx index ca5f3d04b9..5788daebc0 100644 --- a/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx +++ b/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx @@ -27,9 +27,9 @@ const renderEncryptionEvent = (client: MatrixClient, event: MatrixEvent) => { ); }; -const checkTexts = (title: string, subTitle: string) => { - screen.getByText(title); - screen.getByText(subTitle); +const checkTexts = async (title: string, subTitle: string) => { + await screen.findByText(title); + await screen.findByText(subTitle); }; describe("EncryptionEvent", () => { @@ -120,9 +120,9 @@ describe("EncryptionEvent", () => { renderEncryptionEvent(client, event); }); - it("should show the expected texts", () => { + it("should show the expected texts", async () => { expect(client.getCrypto()!.isEncryptionEnabledInRoom).toHaveBeenCalledWith(roomId); - checkTexts("Encryption enabled", "Messages in this chat will be end-to-end encrypted."); + await checkTexts("Encryption enabled", "Messages in this chat will be end-to-end encrypted."); }); }); }); diff --git a/test/unit-tests/components/views/messages/MPollBody-test.tsx b/test/unit-tests/components/views/messages/MPollBody-test.tsx index 598542d297..982fadad20 100644 --- a/test/unit-tests/components/views/messages/MPollBody-test.tsx +++ b/test/unit-tests/components/views/messages/MPollBody-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { fireEvent, render, RenderResult, waitFor } from "jest-matrix-react"; +import { act, fireEvent, render, RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react"; import { MatrixEvent, Relations, @@ -226,7 +226,7 @@ describe("MPollBody", () => { clickOption(renderResult, "pizza"); // When a new vote from me comes in - await room.processPollEvents([responseEvent("@me:example.com", "wings", 101)]); + await act(() => room.processPollEvents([responseEvent("@me:example.com", "wings", 101)])); // Then the new vote is counted, not the old one expect(votesCount(renderResult, "pizza")).toBe("0 votes"); @@ -255,7 +255,7 @@ describe("MPollBody", () => { clickOption(renderResult, "pizza"); // When a new vote from someone else comes in - await room.processPollEvents([responseEvent("@xx:example.com", "wings", 101)]); + await act(() => room.processPollEvents([responseEvent("@xx:example.com", "wings", 101)])); // Then my vote is still for pizza // NOTE: the new event does not affect the counts for other people - @@ -596,11 +596,13 @@ describe("MPollBody", () => { ]; const renderResult = await newMPollBody(votes, ends); - expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); - expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "wings")).toBe('
    3 votes'); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); + await waitFor(() => { + expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); + expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); + expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); + expect(endedVotesCount(renderResult, "wings")).toBe('
    3 votes'); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); + }); }); it("ignores votes that arrived after the first end poll event", async () => { @@ -890,12 +892,14 @@ async function newMPollBody( room_id: "#myroom:example.com", content: newPollStart(answers, undefined, disclosed), }); - const result = newMPollBodyFromEvent(mxEvent, relationEvents, endEvents); - // flush promises from loading relations + const prom = newMPollBodyFromEvent(mxEvent, relationEvents, endEvents); if (waitForResponsesLoad) { - await flushPromises(); + const result = await prom; + if (result.queryByTestId("spinner")) { + await waitForElementToBeRemoved(() => result.getByTestId("spinner")); + } } - return result; + return prom; } function getMPollBodyPropsFromEvent(mxEvent: MatrixEvent): IBodyProps { diff --git a/test/unit-tests/components/views/messages/MPollEndBody-test.tsx b/test/unit-tests/components/views/messages/MPollEndBody-test.tsx index e3883b7033..5bf7ab55ea 100644 --- a/test/unit-tests/components/views/messages/MPollEndBody-test.tsx +++ b/test/unit-tests/components/views/messages/MPollEndBody-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { render, waitFor } from "jest-matrix-react"; +import { render, waitFor, waitForElementToBeRemoved } from "jest-matrix-react"; import { EventTimeline, MatrixEvent, Room, M_TEXT } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; @@ -127,6 +127,7 @@ describe("", () => { expect(container).toMatchSnapshot(); await waitFor(() => expect(getByRole("progressbar")).toBeInTheDocument()); + await waitForElementToBeRemoved(() => getByRole("progressbar")); expect(mockClient.fetchRoomEvent).toHaveBeenCalledWith(roomId, pollStartEvent.getId()); diff --git a/test/unit-tests/components/views/polls/pollHistory/PollHistory-test.tsx b/test/unit-tests/components/views/polls/pollHistory/PollHistory-test.tsx index 96aeffb03c..1e0f0a658c 100644 --- a/test/unit-tests/components/views/polls/pollHistory/PollHistory-test.tsx +++ b/test/unit-tests/components/views/polls/pollHistory/PollHistory-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { act, fireEvent, render } from "jest-matrix-react"; +import { fireEvent, render } from "jest-matrix-react"; import { Filter, EventTimeline, Room, MatrixEvent, M_POLL_START } from "matrix-js-sdk/src/matrix"; import { PollHistory } from "../../../../../../src/components/views/polls/pollHistory/PollHistory"; @@ -110,7 +110,7 @@ describe("", () => { expect(getByText("Loading polls")).toBeInTheDocument(); // flush filter creation request - await act(flushPromises); + await flushPromises(); expect(liveTimeline.getPaginationToken).toHaveBeenCalledWith(EventTimeline.BACKWARDS); expect(mockClient.paginateEventTimeline).toHaveBeenCalledWith(liveTimeline, { backwards: true }); @@ -140,7 +140,7 @@ describe("", () => { ); // flush filter creation request - await act(flushPromises); + await flushPromises(); // once per page expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(3); @@ -175,7 +175,7 @@ describe("", () => { it("renders a no polls message when there are no active polls in the room", async () => { const { getByText } = getComponent(); - await act(flushPromises); + await flushPromises(); expect(getByText("There are no active polls in this room")).toBeTruthy(); }); @@ -199,7 +199,7 @@ describe("", () => { .mockReturnValueOnce("test-pagination-token-3"); const { getByText } = getComponent(); - await act(flushPromises); + await flushPromises(); expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(1); @@ -212,7 +212,7 @@ describe("", () => { // load more polls button still in UI, with loader expect(getByText("Load more polls")).toMatchSnapshot(); - await act(flushPromises); + await flushPromises(); // no more spinner expect(getByText("Load more polls")).toMatchSnapshot(); diff --git a/test/unit-tests/components/views/polls/pollHistory/__snapshots__/PollHistory-test.tsx.snap b/test/unit-tests/components/views/polls/pollHistory/__snapshots__/PollHistory-test.tsx.snap index b6bd7b72d8..360eeda061 100644 --- a/test/unit-tests/components/views/polls/pollHistory/__snapshots__/PollHistory-test.tsx.snap +++ b/test/unit-tests/components/views/polls/pollHistory/__snapshots__/PollHistory-test.tsx.snap @@ -91,7 +91,7 @@ exports[` renders a list of active polls when there are polls in tabindex="0" >
    @@ -116,7 +116,7 @@ exports[` renders a list of active polls when there are polls in tabindex="0" >
    diff --git a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx index dbf5645ca8..441afec700 100644 --- a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx +++ b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { fireEvent, render, screen, waitFor, cleanup, act, within } from "jest-matrix-react"; +import { fireEvent, render, screen, cleanup, act, within } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { Mocked, mocked } from "jest-mock"; import { Room, User, MatrixClient, RoomMember, MatrixEvent, EventType, Device } from "matrix-js-sdk/src/matrix"; @@ -199,6 +199,7 @@ describe("", () => { return render(, { wrapper: Wrapper, + legacyRoot: true, }); }; @@ -439,7 +440,7 @@ describe("", () => { it("renders a device list which can be expanded", async () => { renderComponent(); - await act(flushPromises); + await flushPromises(); // check the button exists with the expected text const devicesButton = screen.getByRole("button", { name: "1 session" }); @@ -459,9 +460,9 @@ describe("", () => { verificationRequest, room: mockRoom, }); - await act(flushPromises); + await flushPromises(); - await waitFor(() => expect(screen.getByRole("button", { name: "Verify" })).toBeInTheDocument()); + await expect(screen.findByRole("button", { name: "Verify" })).resolves.toBeInTheDocument(); expect(container).toMatchSnapshot(); }); @@ -490,7 +491,7 @@ describe("", () => { mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); renderComponent({ room: mockRoom }); - await act(flushPromises); + await flushPromises(); // check the button exists with the expected text (the dehydrated device shouldn't be counted) const devicesButton = screen.getByRole("button", { name: "1 session" }); @@ -538,7 +539,7 @@ describe("", () => { } as DeviceVerificationStatus); renderComponent({ room: mockRoom }); - await act(flushPromises); + await flushPromises(); // check the button exists with the expected text (the dehydrated device shouldn't be counted) const devicesButton = screen.getByRole("button", { name: "1 verified session" }); @@ -583,7 +584,7 @@ describe("", () => { mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, true, true)); renderComponent({ room: mockRoom }); - await act(flushPromises); + await flushPromises(); // the dehydrated device should be shown as an unverified device, which means // there should now be a button with the device id ... @@ -618,7 +619,7 @@ describe("", () => { mockCrypto.getUserDeviceInfo.mockResolvedValue(userDeviceMap); renderComponent({ room: mockRoom }); - await act(flushPromises); + await flushPromises(); // check the button exists with the expected text (the dehydrated device shouldn't be counted) const devicesButton = screen.getByRole("button", { name: "2 sessions" }); @@ -653,7 +654,7 @@ describe("", () => { room: mockRoom, }); - await waitFor(() => expect(screen.getByRole("button", { name: "Deactivate user" })).toBeInTheDocument()); + await expect(screen.findByRole("button", { name: "Deactivate user" })).resolves.toBeInTheDocument(); expect(container).toMatchSnapshot(); }); }); @@ -666,7 +667,7 @@ describe("", () => { it("renders unverified user info", async () => { mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false)); renderComponent({ room: mockRoom }); - await act(flushPromises); + await flushPromises(); const userHeading = screen.getByRole("heading", { name: /@user:example.com/ }); @@ -677,7 +678,7 @@ describe("", () => { it("renders verified user info", async () => { mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(true, false, false)); renderComponent({ room: mockRoom }); - await act(flushPromises); + await flushPromises(); const userHeading = screen.getByRole("heading", { name: /@user:example.com/ }); @@ -768,7 +769,7 @@ describe("", () => { it("with unverified user and device, displays button without a label", async () => { renderComponent(); - await act(flushPromises); + await flushPromises(); expect(screen.getByRole("button", { name: device.displayName! })).toBeInTheDocument(); expect(screen.queryByText(/trusted/i)).not.toBeInTheDocument(); @@ -776,7 +777,7 @@ describe("", () => { it("with verified user only, displays button with a 'Not trusted' label", async () => { renderComponent({ isUserVerified: true }); - await act(flushPromises); + await flushPromises(); const button = screen.getByRole("button", { name: device.displayName }); expect(button).toHaveTextContent(`${device.displayName}Not trusted`); @@ -785,7 +786,7 @@ describe("", () => { it("with verified device only, displays no button without a label", async () => { setMockDeviceTrust(true); renderComponent(); - await act(flushPromises); + await flushPromises(); expect(screen.getByText(device.displayName!)).toBeInTheDocument(); expect(screen.queryByText(/trusted/)).not.toBeInTheDocument(); @@ -798,7 +799,7 @@ describe("", () => { mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId); mockClient.getUserId.mockReturnValueOnce(defaultUserId); renderComponent(); - await act(flushPromises); + await flushPromises(); // set trust to be false for isVerified, true for isCrossSigningVerified deferred.resolve({ @@ -814,7 +815,7 @@ describe("", () => { it("with verified user and device, displays no button and a 'Trusted' label", async () => { setMockDeviceTrust(true); renderComponent({ isUserVerified: true }); - await act(flushPromises); + await flushPromises(); expect(screen.queryByRole("button")).not.toBeInTheDocument(); expect(screen.getByText(device.displayName!)).toBeInTheDocument(); @@ -824,7 +825,7 @@ describe("", () => { it("does not call verifyDevice if client.getUser returns null", async () => { mockClient.getUser.mockReturnValueOnce(null); renderComponent(); - await act(flushPromises); + await flushPromises(); const button = screen.getByRole("button", { name: device.displayName! }); expect(button).toBeInTheDocument(); @@ -839,7 +840,7 @@ describe("", () => { // even more mocking mockClient.isGuest.mockReturnValueOnce(true); renderComponent(); - await act(flushPromises); + await flushPromises(); const button = screen.getByRole("button", { name: device.displayName! }); expect(button).toBeInTheDocument(); @@ -851,7 +852,7 @@ describe("", () => { it("with display name", async () => { const { container } = renderComponent(); - await act(flushPromises); + await flushPromises(); expect(container).toMatchSnapshot(); }); @@ -859,7 +860,7 @@ describe("", () => { it("without display name", async () => { const device = { deviceId: "deviceId" } as Device; const { container } = renderComponent({ device, userId: defaultUserId }); - await act(flushPromises); + await flushPromises(); expect(container).toMatchSnapshot(); }); @@ -867,7 +868,7 @@ describe("", () => { it("ambiguous display name", async () => { const device = { deviceId: "deviceId", ambiguous: true, displayName: "my display name" }; const { container } = renderComponent({ device, userId: defaultUserId }); - await act(flushPromises); + await flushPromises(); expect(container).toMatchSnapshot(); }); @@ -1033,9 +1034,7 @@ describe("", () => { expect(inviteSpy).toHaveBeenCalledWith([member.userId]); // check that the test error message is displayed - await waitFor(() => { - expect(screen.getByText(mockErrorMessage.message)).toBeInTheDocument(); - }); + await expect(screen.findByText(mockErrorMessage.message)).resolves.toBeInTheDocument(); }); it("if calling .invite throws something strange, show default error message", async () => { @@ -1048,9 +1047,7 @@ describe("", () => { await userEvent.click(inviteButton); // check that the default test error message is displayed - await waitFor(() => { - expect(screen.getByText(/operation failed/i)).toBeInTheDocument(); - }); + await expect(screen.findByText(/operation failed/i)).resolves.toBeInTheDocument(); }); it.each([ diff --git a/test/unit-tests/components/views/rooms/EventTile-test.tsx b/test/unit-tests/components/views/rooms/EventTile-test.tsx index b2835d15c0..4cb2296760 100644 --- a/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -260,7 +260,7 @@ describe("EventTile", () => { } as EventEncryptionInfo); const { container } = getComponent(); - await act(flushPromises); + await flushPromises(); const eventTiles = container.getElementsByClassName("mx_EventTile"); expect(eventTiles).toHaveLength(1); @@ -285,7 +285,7 @@ describe("EventTile", () => { } as EventEncryptionInfo); const { container } = getComponent(); - await act(flushPromises); + await flushPromises(); const eventTiles = container.getElementsByClassName("mx_EventTile"); expect(eventTiles).toHaveLength(1); @@ -314,7 +314,7 @@ describe("EventTile", () => { } as EventEncryptionInfo); const { container } = getComponent(); - await act(flushPromises); + await flushPromises(); const e2eIcons = container.getElementsByClassName("mx_EventTile_e2eIcon"); expect(e2eIcons).toHaveLength(1); @@ -346,7 +346,7 @@ describe("EventTile", () => { await mxEvent.attemptDecryption(mockCrypto); const { container } = getComponent(); - await act(flushPromises); + await flushPromises(); const eventTiles = container.getElementsByClassName("mx_EventTile"); expect(eventTiles).toHaveLength(1); @@ -400,7 +400,7 @@ describe("EventTile", () => { const roomContext = getRoomContext(room, {}); const { container, rerender } = render(); - await act(flushPromises); + await flushPromises(); const eventTiles = container.getElementsByClassName("mx_EventTile"); expect(eventTiles).toHaveLength(1); @@ -451,7 +451,7 @@ describe("EventTile", () => { const roomContext = getRoomContext(room, {}); const { container, rerender } = render(); - await act(flushPromises); + await flushPromises(); const eventTiles = container.getElementsByClassName("mx_EventTile"); expect(eventTiles).toHaveLength(1); diff --git a/test/unit-tests/components/views/rooms/MemberList-test.tsx b/test/unit-tests/components/views/rooms/MemberList-test.tsx index 3e17f7ce86..34c37d2ba5 100644 --- a/test/unit-tests/components/views/rooms/MemberList-test.tsx +++ b/test/unit-tests/components/views/rooms/MemberList-test.tsx @@ -8,7 +8,16 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { act, fireEvent, render, RenderResult, screen, waitFor, waitForElementToBeRemoved } from "jest-matrix-react"; +import { + act, + fireEvent, + render, + RenderResult, + screen, + waitFor, + waitForElementToBeRemoved, + cleanup, +} from "jest-matrix-react"; import { Room, MatrixClient, RoomState, RoomMember, User, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { mocked, MockedObject } from "jest-mock"; @@ -361,6 +370,7 @@ describe("MemberList", () => { afterEach(() => { jest.restoreAllMocks(); + cleanup(); }); const renderComponent = () => { @@ -397,21 +407,22 @@ describe("MemberList", () => { jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join); jest.spyOn(room, "canInvite").mockReturnValue(false); - renderComponent(); - await flushPromises(); + const { findByLabelText } = renderComponent(); // button rendered but disabled - expect(screen.getByText("Invite to this room")).toHaveAttribute("aria-disabled", "true"); + await expect(findByLabelText("You do not have permission to invite users")).resolves.toHaveAttribute( + "aria-disabled", + "true", + ); }); it("renders enabled invite button when current user is a member and has rights to invite", async () => { jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join); jest.spyOn(room, "canInvite").mockReturnValue(true); - renderComponent(); - await flushPromises(); + const { findByText } = renderComponent(); - expect(screen.getByText("Invite to this room")).not.toBeDisabled(); + await expect(findByText("Invite to this room")).resolves.not.toBeDisabled(); }); it("opens room inviter on button click", async () => { diff --git a/test/unit-tests/components/views/rooms/MessageComposer-test.tsx b/test/unit-tests/components/views/rooms/MessageComposer-test.tsx index c2e0c4848e..7d8112c2f8 100644 --- a/test/unit-tests/components/views/rooms/MessageComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/MessageComposer-test.tsx @@ -42,17 +42,13 @@ import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/t import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; const openStickerPicker = async (): Promise => { - await act(async () => { - await userEvent.click(screen.getByLabelText("More options")); - await userEvent.click(screen.getByLabelText("Sticker")); - }); + await userEvent.click(screen.getByLabelText("More options")); + await userEvent.click(screen.getByLabelText("Sticker")); }; const startVoiceMessage = async (): Promise => { - await act(async () => { - await userEvent.click(screen.getByLabelText("More options")); - await userEvent.click(screen.getByLabelText("Voice Message")); - }); + await userEvent.click(screen.getByLabelText("More options")); + await userEvent.click(screen.getByLabelText("Voice Message")); }; const setCurrentBroadcastRecording = (room: Room, state: VoiceBroadcastInfoState): void => { @@ -61,7 +57,7 @@ const setCurrentBroadcastRecording = (room: Room, state: VoiceBroadcastInfoState MatrixClientPeg.safeGet(), state, ); - SdkContextClass.instance.voiceBroadcastRecordingsStore.setCurrent(recording); + act(() => SdkContextClass.instance.voiceBroadcastRecordingsStore.setCurrent(recording)); }; const expectVoiceMessageRecordingTriggered = (): void => { @@ -97,6 +93,45 @@ describe("MessageComposer", () => { }); }); + it("wysiwyg correctly persists state to and from localStorage", async () => { + const room = mkStubRoom("!roomId:server", "Room 1", cli); + const messageText = "Test Text"; + await SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, true); + const { renderResult, rawComponent } = wrapAndRender({ room }); + const { unmount } = renderResult; + + await flushPromises(); + + const key = `mx_wysiwyg_state_${room.roomId}`; + + await userEvent.click(screen.getByRole("textbox")); + fireEvent.input(screen.getByRole("textbox"), { + data: messageText, + inputType: "insertText", + }); + + await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(messageText)); + + // Wait for event dispatch to happen + await flushPromises(); + + // assert there is state persisted + expect(localStorage.getItem(key)).toBeNull(); + + // ensure the right state was persisted to localStorage + unmount(); + + // assert the persisted state + expect(JSON.parse(localStorage.getItem(key)!)).toStrictEqual({ + content: messageText, + isRichText: true, + }); + + // ensure the correct state is re-loaded + render(rawComponent); + await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(messageText)); + }, 10000); + describe("for a Room", () => { const room = mkStubRoom("!roomId:server", "Room 1", cli); @@ -185,14 +220,12 @@ describe("MessageComposer", () => { [true, false].forEach((value: boolean) => { describe(`when ${setting} = ${value}`, () => { beforeEach(async () => { - SettingsStore.setValue(setting, null, SettingLevel.DEVICE, value); + await act(() => SettingsStore.setValue(setting, null, SettingLevel.DEVICE, value)); wrapAndRender({ room }); - await act(async () => { - await userEvent.click(screen.getByLabelText("More options")); - }); + await userEvent.click(screen.getByLabelText("More options")); }); - it(`should${value || "not"} display the button`, () => { + it(`should${value ? "" : " not"} display the button`, () => { if (value) { // eslint-disable-next-line jest/no-conditional-expect expect(screen.getByLabelText(buttonLabel)).toBeInTheDocument(); @@ -205,15 +238,17 @@ describe("MessageComposer", () => { describe(`and setting ${setting} to ${!value}`, () => { beforeEach(async () => { // simulate settings update - await SettingsStore.setValue(setting, null, SettingLevel.DEVICE, !value); - dis.dispatch( - { - action: Action.SettingUpdated, - settingName: setting, - newValue: !value, - }, - true, - ); + await act(async () => { + await SettingsStore.setValue(setting, null, SettingLevel.DEVICE, !value); + dis.dispatch( + { + action: Action.SettingUpdated, + settingName: setting, + newValue: !value, + }, + true, + ); + }); }); it(`should${!value || "not"} display the button`, () => { @@ -273,7 +308,7 @@ describe("MessageComposer", () => { beforeEach(async () => { wrapAndRender({ room }, true, true); await openStickerPicker(); - resizeCallback(UI_EVENTS.Resize, {}); + act(() => resizeCallback(UI_EVENTS.Resize, {})); }); it("should close the menu", () => { @@ -295,7 +330,7 @@ describe("MessageComposer", () => { beforeEach(async () => { wrapAndRender({ room }, true, false); await openStickerPicker(); - resizeCallback(UI_EVENTS.Resize, {}); + act(() => resizeCallback(UI_EVENTS.Resize, {})); }); it("should close the menu", () => { @@ -443,51 +478,6 @@ describe("MessageComposer", () => { expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument(); }); }); - - it("wysiwyg correctly persists state to and from localStorage", async () => { - const room = mkStubRoom("!roomId:server", "Room 1", cli); - const messageText = "Test Text"; - await SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, true); - const { renderResult, rawComponent } = wrapAndRender({ room }); - const { unmount, rerender } = renderResult; - - await act(async () => { - await flushPromises(); - }); - - const key = `mx_wysiwyg_state_${room.roomId}`; - - await act(async () => { - await userEvent.click(screen.getByRole("textbox")); - }); - fireEvent.input(screen.getByRole("textbox"), { - data: messageText, - inputType: "insertText", - }); - - await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(messageText)); - - // Wait for event dispatch to happen - await act(async () => { - await flushPromises(); - }); - - // assert there is state persisted - expect(localStorage.getItem(key)).toBeNull(); - - // ensure the right state was persisted to localStorage - unmount(); - - // assert the persisted state - expect(JSON.parse(localStorage.getItem(key)!)).toStrictEqual({ - content: messageText, - isRichText: true, - }); - - // ensure the correct state is re-loaded - rerender(rawComponent); - await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(messageText)); - }, 10000); }); function wrapAndRender( @@ -529,7 +519,7 @@ function wrapAndRender( ); return { rawComponent: getRawComponent(props, roomContext, mockClient), - renderResult: render(getRawComponent(props, roomContext, mockClient)), + renderResult: render(getRawComponent(props, roomContext, mockClient), { legacyRoot: true }), roomContext, }; } diff --git a/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx b/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx index e423d03ea9..f3a0168833 100644 --- a/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx @@ -385,7 +385,7 @@ describe("", () => { it("correctly persists state to and from localStorage", () => { const props = { replyToEvent: mockEvent }; - const { container, unmount, rerender } = getComponent(props); + let { container, unmount } = getComponent(props); addTextToComposer(container, "Test Text"); @@ -402,7 +402,7 @@ describe("", () => { }); // ensure the correct model is re-loaded - rerender(getRawComponent(props)); + ({ container, unmount } = getComponent(props)); expect(container.textContent).toBe("Test Text"); expect(spyDispatcher).toHaveBeenCalledWith({ action: "reply_to_event", @@ -413,7 +413,7 @@ describe("", () => { // now try with localStorage wiped out unmount(); localStorage.removeItem(key); - rerender(getRawComponent(props)); + ({ container } = getComponent(props)); expect(container.textContent).toBe(""); }); diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index 23384d8a43..5d3c455288 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import "@testing-library/jest-dom"; import React from "react"; -import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react"; +import { fireEvent, render, screen, waitFor } from "jest-matrix-react"; import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; import RoomContext from "../../../../../../src/contexts/RoomContext"; @@ -253,9 +253,7 @@ describe("EditWysiwygComposer", () => { }); // Wait for event dispatch to happen - await act(async () => { - await flushPromises(); - }); + await flushPromises(); // Then we don't get it because we are disabled expect(screen.getByRole("textbox")).not.toHaveFocus(); diff --git a/test/unit-tests/components/views/settings/AddRemoveThreepids-test.tsx b/test/unit-tests/components/views/settings/AddRemoveThreepids-test.tsx index 7fa6619a99..a285a98f3b 100644 --- a/test/unit-tests/components/views/settings/AddRemoveThreepids-test.tsx +++ b/test/unit-tests/components/views/settings/AddRemoveThreepids-test.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import { render, screen, waitFor } from "jest-matrix-react"; +import { render, screen, waitFor, cleanup } from "jest-matrix-react"; import { MatrixClient, MatrixError, ThreepidMedium } from "matrix-js-sdk/src/matrix"; import React from "react"; import userEvent from "@testing-library/user-event"; @@ -48,54 +48,13 @@ describe("AddRemoveThreepids", () => { afterEach(() => { jest.restoreAllMocks(); clearAllModals(); + cleanup(); }); const clientProviderWrapper: React.FC = ({ children }: React.PropsWithChildren) => ( {children} ); - it("should render a loader while loading", async () => { - render( - {}} - />, - ); - - expect(screen.getByLabelText("Loading…")).toBeInTheDocument(); - }); - - it("should render email addresses", async () => { - const { container } = render( - {}} - />, - ); - - expect(container).toMatchSnapshot(); - }); - - it("should render phone numbers", async () => { - const { container } = render( - {}} - />, - ); - - expect(container).toMatchSnapshot(); - }); - it("should handle no email addresses", async () => { const { container } = render( { />, ); + await expect(screen.findByText("Email Address")).resolves.toBeVisible(); expect(container).toMatchSnapshot(); }); @@ -127,7 +87,7 @@ describe("AddRemoveThreepids", () => { }, ); - const input = screen.getByRole("textbox", { name: "Email Address" }); + const input = await screen.findByRole("textbox", { name: "Email Address" }); await userEvent.type(input, EMAIL1.address); const addButton = screen.getByRole("button", { name: "Add" }); await userEvent.click(addButton); @@ -166,7 +126,7 @@ describe("AddRemoveThreepids", () => { }, ); - const input = screen.getByRole("textbox", { name: "Email Address" }); + const input = await screen.findByRole("textbox", { name: "Email Address" }); await userEvent.type(input, EMAIL1.address); const addButton = screen.getByRole("button", { name: "Add" }); await userEvent.click(addButton); @@ -210,7 +170,7 @@ describe("AddRemoveThreepids", () => { }, ); - const countryDropdown = screen.getByRole("button", { name: /Country Dropdown/ }); + const countryDropdown = await screen.findByRole("button", { name: /Country Dropdown/ }); await userEvent.click(countryDropdown); const gbOption = screen.getByRole("option", { name: "🇬🇧 United Kingdom (+44)" }); await userEvent.click(gbOption); @@ -270,7 +230,7 @@ describe("AddRemoveThreepids", () => { }, ); - const removeButton = screen.getByRole("button", { name: /Remove/ }); + const removeButton = await screen.findByRole("button", { name: /Remove/ }); await userEvent.click(removeButton); expect(screen.getByText(`Remove ${EMAIL1.address}?`)).toBeVisible(); @@ -297,7 +257,7 @@ describe("AddRemoveThreepids", () => { }, ); - const removeButton = screen.getByRole("button", { name: /Remove/ }); + const removeButton = await screen.findByRole("button", { name: /Remove/ }); await userEvent.click(removeButton); expect(screen.getByText(`Remove ${EMAIL1.address}?`)).toBeVisible(); @@ -326,7 +286,7 @@ describe("AddRemoveThreepids", () => { }, ); - const removeButton = screen.getByRole("button", { name: /Remove/ }); + const removeButton = await screen.findByRole("button", { name: /Remove/ }); await userEvent.click(removeButton); expect(screen.getByText(`Remove ${PHONE1.address}?`)).toBeVisible(); @@ -357,7 +317,7 @@ describe("AddRemoveThreepids", () => { }, ); - expect(screen.getByText(EMAIL1.address)).toBeVisible(); + await expect(screen.findByText(EMAIL1.address)).resolves.toBeVisible(); const shareButton = screen.getByRole("button", { name: /Share/ }); await userEvent.click(shareButton); @@ -408,7 +368,7 @@ describe("AddRemoveThreepids", () => { }, ); - expect(screen.getByText(PHONE1.address)).toBeVisible(); + await expect(screen.findByText(PHONE1.address)).resolves.toBeVisible(); const shareButton = screen.getByRole("button", { name: /Share/ }); await userEvent.click(shareButton); @@ -452,7 +412,7 @@ describe("AddRemoveThreepids", () => { }, ); - expect(screen.getByText(EMAIL1.address)).toBeVisible(); + await expect(screen.findByText(EMAIL1.address)).resolves.toBeVisible(); const revokeButton = screen.getByRole("button", { name: /Revoke/ }); await userEvent.click(revokeButton); @@ -475,7 +435,7 @@ describe("AddRemoveThreepids", () => { }, ); - expect(screen.getByText(PHONE1.address)).toBeVisible(); + await expect(screen.findByText(PHONE1.address)).resolves.toBeVisible(); const revokeButton = screen.getByRole("button", { name: /Revoke/ }); await userEvent.click(revokeButton); @@ -596,4 +556,48 @@ describe("AddRemoveThreepids", () => { }), ); }); + + it("should render a loader while loading", async () => { + render( + {}} + />, + ); + + expect(screen.getByLabelText("Loading…")).toBeInTheDocument(); + }); + + it("should render email addresses", async () => { + const { container } = render( + {}} + />, + ); + + await expect(screen.findByText(EMAIL1.address)).resolves.toBeVisible(); + expect(container).toMatchSnapshot(); + }); + + it("should render phone numbers", async () => { + const { container } = render( + {}} + />, + ); + + await expect(screen.findByText(PHONE1.address)).resolves.toBeVisible(); + expect(container).toMatchSnapshot(); + }); }); diff --git a/test/unit-tests/components/views/settings/__snapshots__/AddRemoveThreepids-test.tsx.snap b/test/unit-tests/components/views/settings/__snapshots__/AddRemoveThreepids-test.tsx.snap index 52e754d691..0258ce7092 100644 --- a/test/unit-tests/components/views/settings/__snapshots__/AddRemoveThreepids-test.tsx.snap +++ b/test/unit-tests/components/views/settings/__snapshots__/AddRemoveThreepids-test.tsx.snap @@ -11,14 +11,14 @@ exports[`AddRemoveThreepids should handle no email addresses 1`] = ` > @@ -61,14 +61,14 @@ exports[`AddRemoveThreepids should render email addresses 1`] = ` > @@ -148,14 +148,14 @@ exports[`AddRemoveThreepids should render phone numbers 1`] = ` diff --git a/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx b/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx index 218e43ac1f..98a0657eae 100644 --- a/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx +++ b/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx @@ -79,9 +79,7 @@ describe("", () => { describe("MSC4108", () => { const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => ( - - - + ); test("render QR then back", async () => { diff --git a/test/unit-tests/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/unit-tests/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 52c9d3aaa9..87411e18a1 100644 --- a/test/unit-tests/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/unit-tests/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -277,9 +277,7 @@ describe("", () => { mockClient.getDevices.mockRejectedValue({ httpStatus: 404 }); const { container } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(container.getElementsByClassName("mx_Spinner").length).toBeFalsy(); }); @@ -302,9 +300,7 @@ describe("", () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(mockCrypto.getDeviceVerificationStatus).toHaveBeenCalledTimes(3); expect( @@ -337,9 +333,7 @@ describe("", () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); // twice for each device expect(mockClient.getAccountData).toHaveBeenCalledTimes(4); @@ -356,9 +350,7 @@ describe("", () => { const { getByTestId, queryByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); toggleDeviceDetails(getByTestId, alicesDevice.device_id); // application metadata section not rendered @@ -369,9 +361,7 @@ describe("", () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice] }); const { queryByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(queryByTestId("other-sessions-section")).toBeFalsy(); }); @@ -382,9 +372,7 @@ describe("", () => { }); const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(getByTestId("other-sessions-section")).toBeTruthy(); }); @@ -395,9 +383,7 @@ describe("", () => { }); const { getByTestId, container } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); fireEvent.click(getByTestId("unverified-devices-cta")); @@ -908,7 +894,8 @@ describe("", () => { }); it("deletes a device when interactive auth is not required", async () => { - mockClient.deleteMultipleDevices.mockResolvedValue({}); + const deferredDeleteMultipleDevices = defer<{}>(); + mockClient.deleteMultipleDevices.mockReturnValue(deferredDeleteMultipleDevices.promise); mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }); @@ -933,6 +920,7 @@ describe("", () => { fireEvent.click(signOutButton); await confirmSignout(getByTestId); await prom; + deferredDeleteMultipleDevices.resolve({}); // delete called expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( @@ -991,7 +979,7 @@ describe("", () => { const { getByTestId, getByLabelText } = render(getComponent()); - await act(flushPromises); + await flushPromises(); // reset mock count after initial load mockClient.getDevices.mockClear(); @@ -1025,7 +1013,7 @@ describe("", () => { fireEvent.submit(getByLabelText("Password")); }); - await act(flushPromises); + await flushPromises(); // called again with auth expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([alicesMobileDevice.device_id], { @@ -1551,7 +1539,7 @@ describe("", () => { }); const { getByTestId, container } = render(getComponent()); - await act(flushPromises); + await flushPromises(); // filter for inactive sessions await setFilter(container, DeviceSecurityVariation.Inactive); @@ -1577,9 +1565,7 @@ describe("", () => { it("lets you change the pusher state", async () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); @@ -1598,9 +1584,7 @@ describe("", () => { it("lets you change the local notification settings state", async () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); toggleDeviceDetails(getByTestId, alicesDevice.device_id); @@ -1621,9 +1605,7 @@ describe("", () => { it("updates the UI when another session changes the local notifications", async () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); toggleDeviceDetails(getByTestId, alicesDevice.device_id); diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap index 6c51cc41ab..5c6a8ac8ee 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap @@ -42,14 +42,14 @@ exports[` 3pids should display 3pid email addresses an > @@ -145,14 +145,14 @@ exports[` 3pids should display 3pid email addresses an diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap index 38f9e483c8..72f94d29c6 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap @@ -388,7 +388,7 @@ exports[` goes to filtered list from security recommendatio > { otherDeviceId, }); const result = renderComponent({ request }); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(result.container).toMatchSnapshot(); }); @@ -76,9 +74,7 @@ describe("VerificationRequestToast", () => { otherUserId, }); const result = renderComponent({ request }); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(result.container).toMatchSnapshot(); }); @@ -89,9 +85,7 @@ describe("VerificationRequestToast", () => { otherUserId, }); renderComponent({ request, toastKey: "testKey" }); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); const dismiss = jest.spyOn(ToastStore.sharedInstance(), "dismissToast"); Object.defineProperty(request, "accepting", { value: true }); diff --git a/test/unit-tests/toasts/UnverifiedSessionToast-test.tsx b/test/unit-tests/toasts/UnverifiedSessionToast-test.tsx index c7df2a0e6e..8b68b3e378 100644 --- a/test/unit-tests/toasts/UnverifiedSessionToast-test.tsx +++ b/test/unit-tests/toasts/UnverifiedSessionToast-test.tsx @@ -65,7 +65,8 @@ describe("UnverifiedSessionToast", () => { }); }; - it("should render as expected", () => { + it("should render as expected", async () => { + await expect(screen.findByText("New login. Was this you?")).resolves.toBeInTheDocument(); expect(renderResult.baseElement).toMatchSnapshot(); }); diff --git a/test/unit-tests/utils/media/requestMediaPermissions-test.tsx b/test/unit-tests/utils/media/requestMediaPermissions-test.tsx index 14dfa15505..0683ad1b67 100644 --- a/test/unit-tests/utils/media/requestMediaPermissions-test.tsx +++ b/test/unit-tests/utils/media/requestMediaPermissions-test.tsx @@ -21,7 +21,7 @@ describe("requestMediaPermissions", () => { const itShouldLogTheErrorAndShowTheNoMediaPermissionsModal = () => { it("should log the error and show the »No media permissions« modal", async () => { expect(logger.log).toHaveBeenCalledWith("Failed to list userMedia devices", error); - await screen.findByText("No media permissions"); + await expect(screen.findByText("No media permissions")).resolves.toBeInTheDocument(); }); }; diff --git a/test/unit-tests/vector/__snapshots__/init-test.ts.snap b/test/unit-tests/vector/__snapshots__/init-test.ts.snap index eeb5e5967c..4fd8e03459 100644 --- a/test/unit-tests/vector/__snapshots__/init-test.ts.snap +++ b/test/unit-tests/vector/__snapshots__/init-test.ts.snap @@ -103,6 +103,7 @@ exports[`showIncompatibleBrowser should match snapshot 1`] = `

    +
    +
    + ); +}; + +function substituteATag(sub: string): React.ReactNode { + return ( + + {sub} + + ); +} + +function substituteBTag(sub: string): React.ReactNode { + return {sub}; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3b4765b0ad..66428300a0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -905,6 +905,8 @@ "warning": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings." }, "not_supported": "", + "pinned_identity_changed": "%(displayName)s's (%(userId)s) identity appears to have changed. Learn more", + "pinned_identity_changed_no_displayname": "%(userId)s's identity appears to have changed. Learn more", "recovery_method_removed": { "description_1": "This session has detected that your Security Phrase and key for Secure Messages have been removed.", "description_2": "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.", diff --git a/test/unit-tests/components/views/rooms/UserIdentityWarning-test.tsx b/test/unit-tests/components/views/rooms/UserIdentityWarning-test.tsx new file mode 100644 index 0000000000..9a70a88768 --- /dev/null +++ b/test/unit-tests/components/views/rooms/UserIdentityWarning-test.tsx @@ -0,0 +1,534 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { sleep } from "matrix-js-sdk/src/utils"; +import { + EventType, + MatrixClient, + MatrixEvent, + Room, + RoomState, + RoomStateEvent, + RoomMember, +} from "matrix-js-sdk/src/matrix"; +import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; +import { act, render, screen, waitFor } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { stubClient } from "../../../../test-utils"; +import { UserIdentityWarning } from "../../../../../src/components/views/rooms/UserIdentityWarning"; +import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; + +const ROOM_ID = "!room:id"; + +function mockRoom(): Room { + const room = { + getEncryptionTargetMembers: jest.fn(async () => []), + getMember: jest.fn((userId) => {}), + roomId: ROOM_ID, + shouldEncryptForInvitedMembers: jest.fn(() => true), + } as unknown as Room; + + return room; +} + +function mockRoomMember(userId: string, name?: string): RoomMember { + return { + userId, + name: name ?? userId, + rawDisplayName: name ?? userId, + roomId: ROOM_ID, + getMxcAvatarUrl: jest.fn(), + } as unknown as RoomMember; +} + +function dummyRoomState(): RoomState { + return new RoomState(ROOM_ID); +} + +/** + * Get the warning element, given the warning text (excluding the "Learn more" + * link). This is needed because the warning text contains a `` tag, so the + * normal `getByText` doesn't work. + */ +function getWarningByText(text: string): Element { + return screen.getByText((content?: string, element?: Element | null): boolean => { + return ( + !!element && + element.classList.contains("mx_UserIdentityWarning_main") && + element.textContent === text + " Learn more" + ); + }); +} + +function renderComponent(client: MatrixClient, room: Room) { + return render(, { + wrapper: ({ ...rest }) => , + }); +} + +describe("UserIdentityWarning", () => { + let client: MatrixClient; + let room: Room; + + beforeEach(async () => { + client = stubClient(); + room = mockRoom(); + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // This tests the basic functionality of the component. If we have a room + // member whose identity needs accepting, we should display a warning. When + // the "OK" button gets pressed, it should call `pinCurrentUserIdentity`. + it("displays a warning when a user's identity needs approval", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + crypto.pinCurrentUserIdentity = jest.fn(); + renderComponent(client, room); + + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + await userEvent.click(screen.getByRole("button")!); + await waitFor(() => expect(crypto.pinCurrentUserIdentity).toHaveBeenCalledWith("@alice:example.org")); + }); + + // We don't display warnings in non-encrypted rooms, but if encryption is + // enabled, then we should display a warning if there are any users whose + // identity need accepting. + it("displays pending warnings when encryption is enabled", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + // Start the room off unencrypted. We shouldn't display anything. + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(false); + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(); + + // Encryption gets enabled in the room. We should now warn that Alice's + // identity changed. + jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(true); + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomEncryption, + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + }); + + // When a user's identity needs approval, or has been approved, the display + // should update appropriately. + it("updates the display when identity changes", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, false), + ); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(); + + // The user changes their identity, so we should show the warning. + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, true), + ); + }); + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + + // Simulate the user's new identity having been approved, so we no + // longer show the warning. + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, false), + ); + }); + await waitFor(() => + expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(), + ); + }); + + // We only display warnings about users in the room. When someone + // joins/leaves, we should update the warning appropriately. + describe("updates the display when a member joins/leaves", () => { + it("when invited users can see encrypted messages", async () => { + // Nobody in the room yet + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); + jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(true); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + + // Alice joins. Her identity needs approval, so we should show a warning. + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "join", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + await waitFor(() => + expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + + // Bob is invited. His identity needs approval, so we should show a + // warning for him after Alice's warning is resolved by her leaving. + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@bob:example.org", + content: { + membership: "invite", + }, + room_id: ROOM_ID, + sender: "@carol:example.org", + }), + dummyRoomState(), + null, + ); + + // Alice leaves, so we no longer show her warning, but we will show + // a warning for Bob. + act(() => { + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "leave", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + }); + await waitFor(() => + expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(), + ); + await waitFor(() => + expect(getWarningByText("@bob:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + }); + + it("when invited users cannot see encrypted messages", async () => { + // Nobody in the room yet + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); + jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + + // Alice joins. Her identity needs approval, so we should show a warning. + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "join", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + await waitFor(() => + expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + + // Bob is invited. His identity needs approval, but we don't encrypt + // to him, so we won't show a warning. (When Alice leaves, the + // display won't be updated to show a warningfor Bob.) + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@bob:example.org", + content: { + membership: "invite", + }, + room_id: ROOM_ID, + sender: "@carol:example.org", + }), + dummyRoomState(), + null, + ); + + // Alice leaves, so we no longer show her warning, and we don't show + // a warning for Bob. + act(() => { + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "leave", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + }); + await waitFor(() => + expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(), + ); + await waitFor(() => + expect(() => getWarningByText("@bob:example.org's identity appears to have changed.")).toThrow(), + ); + }); + + it("when member leaves immediately after component is loaded", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockImplementation(async () => { + setTimeout(() => { + // Alice immediately leaves after we get the room + // membership, so we shouldn't show the warning any more + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "leave", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + }); + return [mockRoomMember("@alice:example.org")]; + }); + jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + renderComponent(client, room); + + await sleep(10); + expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(); + }); + + it("when member leaves immediately after joining", async () => { + // Nobody in the room yet + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); + jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + + // Alice joins. Her identity needs approval, so we should show a warning. + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "join", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + // ... but she immediately leaves, so we shouldn't show the warning any more + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "leave", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + await sleep(10); // give it some time to finish + expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(); + }); + }); + + // When we have multiple users whose identity needs approval, one user's + // identity no longer needs approval (e.g. their identity was approved), + // then we show the next one. + it("displays the next user when the current user's identity is approved", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + mockRoomMember("@bob:example.org"), + ]); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + + renderComponent(client, room); + // We should warn about Alice's identity first. + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + + // Simulate Alice's new identity having been approved, so now we warn + // about Bob's identity. + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, false), + ); + }); + await waitFor(() => + expect(getWarningByText("@bob:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + }); + + // If we get an update for a user's verification status while we're fetching + // that user's verification status, we should display based on the updated + // value. + describe("handles races between fetching verification status and receiving updates", () => { + // First case: check that if the update says that the user identity + // needs approval, but the fetch says it doesn't, we show the warning. + it("update says identity needs approval", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => { + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, true), + ); + }); + return Promise.resolve(new UserVerificationStatus(false, false, false, false)); + }); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + }); + + // Second case: check that if the update says that the user identity + // doesn't needs approval, but the fetch says it does, we don't show the + // warning. + it("update says identity doesn't need approval", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => { + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, false), + ); + }); + return Promise.resolve(new UserVerificationStatus(false, false, false, true)); + }); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + await waitFor(() => + expect(() => + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toThrow(), + ); + }); + }); +}); From 8df26a54cf437318ba1bfc467b43437c51512799 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 22 Nov 2024 10:24:59 +0000 Subject: [PATCH 37/37] Fix getOidcCallbackUrl for Element Desktop (#28521) Got broken by a Chrome change Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/vector/platform/ElectronPlatform.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/vector/platform/ElectronPlatform.tsx b/src/vector/platform/ElectronPlatform.tsx index d7ebd94bb2..ac6e7a7feb 100644 --- a/src/vector/platform/ElectronPlatform.tsx +++ b/src/vector/platform/ElectronPlatform.tsx @@ -474,10 +474,8 @@ export default class ElectronPlatform extends BasePlatform { const url = super.getOidcCallbackUrl(); url.protocol = "io.element.desktop"; // Trim the double slash into a single slash to comply with https://datatracker.ietf.org/doc/html/rfc8252#section-7.1 - // Chrome seems to have a strange issue where non-standard protocols prevent URL object mutations on pathname - // field, so we cannot mutate `pathname` reliably and instead have to rewrite the href manually. - if (url.pathname.startsWith("//")) { - url.href = url.href.replace(url.pathname, url.pathname.slice(1)); + if (url.href.startsWith(`${url.protocol}://`)) { + url.href = url.href.replace("://", ":/"); } return url; }