From 4bdd4f4f492059e92a2abcc4e35a051b590d4bad Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 12 Nov 2024 14:01:37 +0000 Subject: [PATCH 01/27] 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 2c7e134405a..6e6972208c8 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 63b60de8e0b..7cc81093957 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/27] v1.11.86-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6e6972208c8..ec7dc63e89a 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 048f88e10e22d0b34bca851b44af90f51d7c4d18 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 14 Nov 2024 17:41:59 +0000 Subject: [PATCH 03/27] Update dependency @matrix-org/react-sdk-module-api to v2.5.0 (#28468) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index e8cd5406a85..b3ebc5075d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2006,9 +2006,9 @@ integrity sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q== "@matrix-org/react-sdk-module-api@^2.4.0": - version "2.4.0" - resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.4.0.tgz#5e4552acbe728141f42c1d54d75dcb4efea9301c" - integrity sha512-cPb+YaqllfJkRX0ofcG/0YdHxCvcMAvUbdNMO2olpGL8vwbBP6mHdhbZ87z9pgsRIVOqfFuLUE3WeW0hxWrklQ== + version "2.5.0" + resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.5.0.tgz#df774d0ae0c327fbd40f8994bbb13ed35e26c337" + integrity sha512-l/SmiO47gPIRd6YJJGj+B6qbxyypJF6SEsfYr7j9rSW6E85ZYCqf+TpMM2LmfwZRADyKfCVkaJbbBZYpoD02VA== dependencies: "@babel/runtime" "^7.17.9" @@ -8349,7 +8349,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.10.0, matrix-widget-api@^1.8.2: 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 e3f8a7b13d7feda2b1ed5ceeb7ab926b2bd9542c Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Fri, 15 Nov 2024 06:24:20 +0000 Subject: [PATCH 04/27] [create-pull-request] automated change (#28470) 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 bc8403b350a..824ee3273e9 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:27e36370a0422d275b54d0292c8bf87e925dc004d0fe5b10dbee7ea4ffd27289"; +const DOCKER_TAG = "develop@sha256:b1b5693fa954ec0124e330dba8a28260ac1cc4d9948a778724a421be9f934284"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); From d36cfc37e2332f0c807142fef7f5d231662a6e53 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 15 Nov 2024 09:04:00 +0000 Subject: [PATCH 05/27] Make the version file part of webpack output (#28461) * Make the version file part of webpack output Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix outputFile path for Windows compat Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .github/workflows/end-to-end-tests.yaml | 1 - package.json | 1 + scripts/docker-package.sh | 1 - scripts/package.sh | 2 - webpack.config.js | 25 ++++++++--- yarn.lock | 57 ++++++++++++++++++++++++- 6 files changed, 75 insertions(+), 12 deletions(-) diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index 0abc35d85d6..1784dafe0e3 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -69,7 +69,6 @@ jobs: VERSION: "${{ steps.layered_build.outputs.VERSION }}" run: | yarn build - echo $VERSION > webapp/version - name: Upload Artifact uses: actions/upload-artifact@v4 diff --git a/package.json b/package.json index 1bd9dea0ac0..4ab97ed07dd 100644 --- a/package.json +++ b/package.json @@ -286,6 +286,7 @@ "webpack-bundle-analyzer": "^4.8.0", "webpack-cli": "^5.0.0", "webpack-dev-server": "^5.0.0", + "webpack-version-file-plugin": "^0.5.0", "yaml": "^2.3.3" }, "@casualbot/jest-sonar-reporter": { diff --git a/scripts/docker-package.sh b/scripts/docker-package.sh index 12f207d4b0b..09205871170 100755 --- a/scripts/docker-package.sh +++ b/scripts/docker-package.sh @@ -17,4 +17,3 @@ fi DIST_VERSION=$("$DIR"/normalize-version.sh "$DIST_VERSION") VERSION=$DIST_VERSION yarn build -echo "$DIST_VERSION" > /src/webapp/version diff --git a/scripts/package.sh b/scripts/package.sh index 6a8bf2b9bd5..9937dd20d3b 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -21,8 +21,6 @@ cp -r webapp element-$version # Just in case you have a local config, remove it before packaging rm element-$version/config.json || true -$(dirname $0)/normalize-version.sh ${version} > element-$version/version - # GNU/BSD compatibility workaround tar_perms=(--owner=0 --group=0) && [ "$(uname)" == "Darwin" ] && tar_perms=(--uid=0 --gid=0) tar "${tar_perms[@]}" -chvzf dist/element-$version.tar.gz element-$version diff --git a/webpack.config.js b/webpack.config.js index d35fb8e0d2c..a664d7ea819 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -9,6 +9,7 @@ const TerserPlugin = require("terser-webpack-plugin"); const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); const HtmlWebpackInjectPreload = require("@principalstudio/html-webpack-inject-preload"); const CopyWebpackPlugin = require("copy-webpack-plugin"); +const VersionFilePlugin = require("webpack-version-file-plugin"); // Environment variables // RIOT_OG_IMAGE_URL: specifies the URL to the image which should be used for the opengraph logo. @@ -19,11 +20,6 @@ dotenv.config(); let ogImageUrl = process.env.RIOT_OG_IMAGE_URL; if (!ogImageUrl) ogImageUrl = "https://app.element.io/themes/element/img/logos/opengraph.png"; -if (!process.env.VERSION) { - console.warn("Unset VERSION variable - this may affect build output"); - process.env.VERSION = "!!UNSET!!"; -} - const cssThemes = { // CSS themes "theme-legacy-light": "./res/themes/legacy-light/css/legacy-light.pcss", @@ -97,6 +93,14 @@ module.exports = (env, argv) => { const devMode = nodeEnv !== "production"; const enableMinification = !devMode && !process.env.CI_PACKAGE; + let VERSION = process.env.VERSION; + if (!VERSION) { + VERSION = require("./package.json").version; + if (devMode) { + VERSION += "-dev"; + } + } + const development = {}; if (devMode) { // Embedded source maps for dev builds, can't use eval-source-map due to CSP @@ -651,8 +655,6 @@ module.exports = (env, argv) => { }, }), - new webpack.EnvironmentPlugin(["VERSION"]), - new CopyWebpackPlugin({ patterns: [ "res/apple-app-site-association", @@ -677,6 +679,15 @@ module.exports = (env, argv) => { Buffer: ["buffer", "Buffer"], process: "process/browser", }), + + // We bake the version in so the app knows its version immediately + new webpack.DefinePlugin({ "process.env.VERSION": JSON.stringify(VERSION) }), + // But we also write it to a file which gets polled for update detection + new VersionFilePlugin({ + outputFile: "version", + templateString: "<%= extras.VERSION %>", + extras: { VERSION }, + }), ].filter(Boolean), output: { diff --git a/yarn.lock b/yarn.lock index b3ebc5075d4..f2baf3b37e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3934,6 +3934,11 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +async@^3.2.3: + version "3.2.6" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -4332,7 +4337,7 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: +chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -5365,6 +5370,13 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== +ejs@^3.1.8: + version "3.1.10" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" + integrity sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA== + dependencies: + jake "^10.8.5" + electron-to-chromium@^1.5.41: version "1.5.56" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.56.tgz#3213f369efc3a41091c3b2c05bc0f406108ac1df" @@ -6188,6 +6200,13 @@ file@^0.2.2: resolved "https://registry.yarnpkg.com/file/-/file-0.2.2.tgz#c3dfd8f8cf3535ae455c2b423c2e52635d76b4d3" integrity sha512-gwabMtChzdnpDJdPEpz8Vr/PX0pU85KailuPV71Zw/un5yJVKvzukhB3qf6O3lnTwIe5CxlMYLh3jOK3w5xrLA== +filelist@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" + integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== + dependencies: + minimatch "^5.0.1" + filesize@10.1.6: version "10.1.6" resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.6.tgz#31194da825ac58689c0bce3948f33ce83aabd361" @@ -6342,6 +6361,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fs@latest: + version "0.0.1-security" + resolved "https://registry.yarnpkg.com/fs/-/fs-0.0.1-security.tgz#8a7bd37186b6dddf3813f23858b57ecaaf5e41d4" + integrity sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w== + fsevents@2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" @@ -7362,6 +7386,16 @@ jackspeak@^4.0.1: dependencies: "@isaacs/cliui" "^8.0.2" +jake@^10.8.5: + version "10.9.2" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.2.tgz#6ae487e6a69afec3a5e167628996b59f35ae2b7f" + integrity sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA== + dependencies: + async "^3.2.3" + chalk "^4.0.2" + filelist "^1.0.4" + minimatch "^3.1.2" + jest-canvas-mock@^2.5.2: version "2.5.2" resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz#7e21ebd75e05ab41c890497f6ba8a77f915d2ad6" @@ -8519,6 +8553,13 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +minimatch@^5.0.1: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + minimatch@^8.0.2: version "8.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-8.0.4.tgz#847c1b25c014d4e9a7f68aaf63dedd668a626229" @@ -11566,6 +11607,11 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +underscore@^1.13.6: + version "1.13.7" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.7.tgz#970e33963af9a7dda228f17ebe8399e5fbe63a10" + integrity sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g== + undici-types@~5.26.4: version "5.26.5" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" @@ -11942,6 +11988,15 @@ webpack-sources@^3.2.3: resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== +webpack-version-file-plugin@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/webpack-version-file-plugin/-/webpack-version-file-plugin-0.5.0.tgz#1711baafc06da373f3bb95de86cad831e00217e1" + integrity sha512-Ef5gGkD3OPtXU794XNt6JNzIv1dYmTqN3SfY25qRNg6/auOXGF4XBpPnisO9mJTUbIgBFcSEiV74uXJlrL0xfg== + dependencies: + ejs "^3.1.8" + fs latest + underscore "^1.13.6" + webpack-virtual-modules@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.5.0.tgz#362f14738a56dae107937ab98ea7062e8bdd3b6c" From ae3ca52bd235bd4cce03c125f2cf1bc96cf0def2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 15 Nov 2024 09:11:03 +0000 Subject: [PATCH 06/27] Allow tab completing users in brackets (#28460) * Allow tab completing users in brackets Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Account for range offsets when tab completing to not replace unrelated characters Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/autocomplete/UserProvider.tsx | 2 +- src/editor/autocomplete.ts | 4 +++- src/editor/model.ts | 16 +++++++++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 18c93d0cd03..a8f50ceccbe 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -37,7 +37,7 @@ const USER_REGEX = /\B@\S*/g; // used when you hit 'tab' - we allow some separator chars at the beginning // to allow you to tab-complete /mat into /(matthew) -const FORCED_USER_REGEX = /[^/,:; \t\n]\S*/g; +const FORCED_USER_REGEX = /[^/,.():; \t\n]\S*/g; export default class UserProvider extends AutocompleteProvider { public matcher: QueryMatcher; diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts index 28f86ddf779..542a2bbea51 100644 --- a/src/editor/autocomplete.ts +++ b/src/editor/autocomplete.ts @@ -10,11 +10,12 @@ import { KeyboardEvent } from "react"; import { Part, CommandPartCreator, PartCreator } from "./parts"; import DocumentPosition from "./position"; -import { ICompletion } from "../autocomplete/Autocompleter"; +import { ICompletion, ISelectionRange } from "../autocomplete/Autocompleter"; import Autocomplete from "../components/views/rooms/Autocomplete"; export interface ICallback { replaceParts?: Part[]; + range?: ISelectionRange; close?: boolean; } @@ -82,6 +83,7 @@ export default class AutocompleteWrapperModel { this.updateCallback({ replaceParts: this.partForCompletion(completion), close: true, + range: completion.range, }); } diff --git a/src/editor/model.ts b/src/editor/model.ts index 67b19a3999a..efe294cd214 100644 --- a/src/editor/model.ts +++ b/src/editor/model.ts @@ -250,14 +250,24 @@ export default class EditorModel { return Promise.resolve(); } - private onAutoComplete = ({ replaceParts, close }: ICallback): void => { + private onAutoComplete = ({ replaceParts, close, range }: ICallback): void => { let pos: DocumentPosition | undefined; if (replaceParts) { const autoCompletePartIdx = this.autoCompletePartIdx || 0; - this._parts.splice(autoCompletePartIdx, this.autoCompletePartCount, ...replaceParts); + + this.replaceRange( + new DocumentPosition(autoCompletePartIdx, range?.start ?? 0), + new DocumentPosition( + autoCompletePartIdx + this.autoCompletePartCount - 1, + range?.end ?? this.parts[autoCompletePartIdx + this.autoCompletePartCount - 1].text.length, + ), + replaceParts, + ); + this.autoCompletePartCount = replaceParts.length; const lastPart = replaceParts[replaceParts.length - 1]; - const lastPartIndex = autoCompletePartIdx + replaceParts.length - 1; + // `replaceRange` merges adjacent parts so we need to find it in the new parts list + const lastPartIndex = this.parts.indexOf(lastPart); pos = new DocumentPosition(lastPartIndex, lastPart.text.length); } if (close) { From e7cd322559b40b25dd304fe6b834f49ef36730d3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 15 Nov 2024 11:44:23 +0000 Subject: [PATCH 07/27] Fix download button size in message action bar (#28472) Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- res/css/views/messages/_MessageActionBar.pcss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/views/messages/_MessageActionBar.pcss b/res/css/views/messages/_MessageActionBar.pcss index 3768bfb0213..fd9012ed288 100644 --- a/res/css/views/messages/_MessageActionBar.pcss +++ b/res/css/views/messages/_MessageActionBar.pcss @@ -113,7 +113,7 @@ Please see LICENSE files in the repository root for full details. } &.mx_MessageActionBar_downloadButton { - --MessageActionBar-icon-size: 14px; + --MessageActionBar-icon-size: 20px; &.mx_MessageActionBar_downloadSpinnerButton { svg { From 774b767b8083d9bace223e0f69e7c4e1f2081455 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 15 Nov 2024 11:46:48 +0000 Subject: [PATCH 08/27] Upgrade to compound-design-tokens v2 (#28471) * Upgrade to compound-design-tokens v2 Switch out color/text/placeholder for color/text/secondary Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Upgrade compound to fix Search component placeholder colour Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 4 ++-- res/css/views/emojipicker/_EmojiPicker.pcss | 2 +- .../wysiwyg_composer/components/_Editor.pcss | 2 +- res/themes/dark/css/_dark.pcss | 2 +- res/themes/legacy-dark/css/_legacy-dark.pcss | 2 +- res/themes/legacy-light/css/_legacy-light.pcss | 2 +- res/themes/light/css/_light.pcss | 2 +- yarn.lock | 16 ++++++++-------- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 4ab97ed07dd..0a0d0a477be 100644 --- a/package.json +++ b/package.json @@ -85,8 +85,8 @@ "@matrix-org/react-sdk-module-api": "^2.4.0", "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^8.0.0", - "@vector-im/compound-design-tokens": "^1.8.0", - "@vector-im/compound-web": "^7.1.0", + "@vector-im/compound-design-tokens": "^2.0.1", + "@vector-im/compound-web": "^7.3.0", "@vector-im/matrix-wysiwyg": "2.37.13", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", diff --git a/res/css/views/emojipicker/_EmojiPicker.pcss b/res/css/views/emojipicker/_EmojiPicker.pcss index cd7b2bb7697..d4ae92172d6 100644 --- a/res/css/views/emojipicker/_EmojiPicker.pcss +++ b/res/css/views/emojipicker/_EmojiPicker.pcss @@ -111,7 +111,7 @@ Please see LICENSE files in the repository root for full details. border-radius: 4px 0; &::placeholder { - color: var(--cpd-color-text-placeholder); + color: var(--cpd-color-text-secondary); } } diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss index dfe05afa3ef..5c0d5da9fcc 100644 --- a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss @@ -155,7 +155,7 @@ Please see LICENSE files in the repository root for full details. display: inline-block; pointer-events: none; white-space: nowrap; - color: var(--cpd-color-text-placeholder); + color: var(--cpd-color-text-secondary); } } diff --git a/res/themes/dark/css/_dark.pcss b/res/themes/dark/css/_dark.pcss index 56630763fef..8b0673f692b 100644 --- a/res/themes/dark/css/_dark.pcss +++ b/res/themes/dark/css/_dark.pcss @@ -136,7 +136,7 @@ $input-border-color: rgba(231, 231, 231, 0.2); $input-darker-bg-color: #181b21; $input-darker-fg-color: #61708b; $input-lighter-bg-color: #f2f5f8; -$input-placeholder: var(--cpd-color-text-placeholder); +$input-placeholder: var(--cpd-color-text-secondary); /* ******************** */ /* Dialog */ diff --git a/res/themes/legacy-dark/css/_legacy-dark.pcss b/res/themes/legacy-dark/css/_legacy-dark.pcss index c6840f5b907..45bb1870f1b 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.pcss +++ b/res/themes/legacy-dark/css/_legacy-dark.pcss @@ -54,7 +54,7 @@ $input-border-color: #e7e7e7; $input-darker-bg-color: #181b21; $input-darker-fg-color: #61708b; $input-lighter-bg-color: #f2f5f8; -$input-placeholder: var(--cpd-color-text-placeholder); +$input-placeholder: var(--cpd-color-text-secondary); $resend-button-divider-color: $muted-fg-color; diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss index 398cf0e1f10..76e0eec588a 100644 --- a/res/themes/legacy-light/css/_legacy-light.pcss +++ b/res/themes/legacy-light/css/_legacy-light.pcss @@ -81,7 +81,7 @@ $strong-input-border-color: #c7c7c7; /* used for UserSettings EditableText */ $input-underline-color: rgba(151, 151, 151, 0.5); $input-fg-color: rgba(74, 74, 74, 0.9); -$input-placeholder: var(--cpd-color-text-placeholder); +$input-placeholder: var(--cpd-color-text-secondary); /* scrollbars */ $scrollbar-thumb-color: rgba(0, 0, 0, 0.2); /* context menus */ diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss index d649b6b38da..5f278c6f160 100644 --- a/res/themes/light/css/_light.pcss +++ b/res/themes/light/css/_light.pcss @@ -184,7 +184,7 @@ $input-darker-fg-color: #9fa9ba; $input-lighter-bg-color: $secondary-accent-color; $input-underline-color: rgba(151, 151, 151, 0.5); $input-fg-color: rgba(74, 74, 74, 0.9); -$input-placeholder: var(--cpd-color-text-placeholder); +$input-placeholder: var(--cpd-color-text-secondary); /* ******************** */ /* Dialog */ diff --git a/yarn.lock b/yarn.lock index f2baf3b37e2..1e7dc96440a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3414,15 +3414,15 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== -"@vector-im/compound-design-tokens@^1.8.0": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.9.1.tgz#644dc7ca5ca251fd476af2a7c075e9d740c08871" - integrity sha512-zjI+PhoNLNrJrLU8whEGjzCuxdqIz6tM0ARYBMS8AG1vC+NlGak6Y21TWnzHT3VINNhnF+PiQ9lFWsU65GydOg== +"@vector-im/compound-design-tokens@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-2.0.1.tgz#add14494caab16cdbe98f2bdabe726908739def4" + integrity sha512-4nkPcrPII+sejispn+UkWZYFN7LecN39e4WGBupdceiMq0NJrfXrnVtJ9/6BDLgSqHInb6R/IWQkIbPbzfqRMg== -"@vector-im/compound-web@^7.1.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.2.0.tgz#0ec4a598e5755cc4b3e83fbc232a4986a12bf808" - integrity sha512-wOT2kSo936FSBG1CsZ1vmHLwTTWBq9OBBfq76sM95rUawRSQCCWnjFMLTiacRvxBHucZaSNsfhpJH3oZcrOexw== +"@vector-im/compound-web@^7.3.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.3.0.tgz#9594113ac50bff4794715104a30a60c52d15517d" + integrity sha512-gDppQUtpk5LvNHUg+Zlv9qzo1iBAag0s3g8Ec0qS5q4zGBKG6ruXXrNUKg1aK8rpbo2hYQsGaHM6dD8NqLoq3Q== dependencies: "@floating-ui/react" "^0.26.24" "@radix-ui/react-context-menu" "^2.2.1" From ce928e8d1f73466e25b5b7357da22e6243e649d2 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 15 Nov 2024 09:15:41 -0500 Subject: [PATCH 09/27] Clean up the OpenSans license file (#28353) --- res/fonts/Open_Sans/LICENSE.txt | 222 -------------------------------- 1 file changed, 222 deletions(-) diff --git a/res/fonts/Open_Sans/LICENSE.txt b/res/fonts/Open_Sans/LICENSE.txt index 9df79cc97de..75b52484ea4 100755 --- a/res/fonts/Open_Sans/LICENSE.txt +++ b/res/fonts/Open_Sans/LICENSE.txt @@ -1,224 +1,3 @@ -<<<<<<<< HEAD:res/jitsi_external_api.min.js.LICENSE.txt - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - - -Note: - -This project was originally contributed to the community under the MIT license and with the following notice: - -The MIT License (MIT) - -Copyright (c) 2013 ESTOS GmbH -Copyright (c) 2013 BlueJimp SARL - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -======== Apache License Version 2.0, January 2004 @@ -421,4 +200,3 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ->>>>>>>> repomerge/t3chguy/repomerge:res/fonts/Open_Sans/LICENSE.txt From 28640eec5f3fba5ed15c63972e23b5e507071605 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 15 Nov 2024 10:06:44 -0500 Subject: [PATCH 10/27] Fix matrix-widget-api version in package.json (#28453) matrix-js-sdk#develop now depends on matrix-widget-api^v1.10.0, so update the lockfile to match that. --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 1e7dc96440a..f0e1621d089 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8365,7 +8365,7 @@ matrix-events-sdk@0.0.1: jwt-decode "^4.0.0" loglevel "^1.7.1" matrix-events-sdk "0.0.1" - matrix-widget-api "^1.8.2" + matrix-widget-api "^1.10.0" oidc-client-ts "^3.0.1" p-retry "4" sdp-transform "^2.14.1" From cafa02ccc2dce60ef9f120bca39fac80b2e0aa3d Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Mon, 18 Nov 2024 10:22:42 +0100 Subject: [PATCH 11/27] Remove crypto eslint exception (#28228) --- .eslintrc.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index e95f4834e98..f168a87a066 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 12/27] 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 dc8ae6376bd..fa36402ff14 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 0a0d0a477be..e01c670965c 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 25db241f733..0891d25221b 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 eaa02cd2d22..65ea555ce18 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 7e55743200c..c54bc53dc2b 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 e02533037b4..f1dc1d1ec8b 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 47cce4b47a8..fb781811f1f 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 8900b7d985b..062770f77f0 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 e34c991d89e..499ce870ecc 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 311e0591667..d405381db1c 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 5c0d5da9fcc..34c2a4d626e 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 f0e1621d089..c5e5ecce0b6 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 13/27] 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 a131c3e55bc..30d29483802 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 10de3996e69..9fc32dda291 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 7d397397dc9..c9b80553e5f 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 5ebbe3f1ad5..396bb06ec63 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 67948ed217e..8e33575fecd 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 14/27] 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 8bbca5b309b..cf5a2398148 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 eaeabc641b9..b6ce5415903 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 4a524db97cd..3b4765b0ad2 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 00000000000..cf6ed6ae624 --- /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 e3e2b1cf96d..1b88c416bc5 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 15/27] 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 c5e5ecce0b6..c47cf820294 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 16/27] 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 57196985a9c..0f796dbc96d 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 fdaa9306861..7ea717554b2 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 c54bc53dc2b..d756d51d65b 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 7cf3845027d..741a4e90dca 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 e06782ebe96..a73dab9982c 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 90602538f04..0eb51420bb6 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 7c1828183a0..3b91eddc8b8 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 b332942f75b..b3e38782766 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 f6635b9791b..a6b9fe03045 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 eff7bd0e125..c4f94bfde9d 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 0329f6a63bd..dcec1410f1e 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 fd9012ed288..cdfc3693d53 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 93efded3044..1f9d1e0562d 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 4ceba9a20a7..97b1e76cef8 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 6fbd2a38dbd..fa0e0b24eb3 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 6b070de27f7..f96b705cc2b 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 1550fc84fa4..53f9c10f1b3 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 58cf3659aec..ddda97b4931 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 038866c294e..00000000000 --- 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 b5769074ab7..00000000000 --- 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 773e27d4cea..00000000000 --- 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 07dbdc0ccca..00000000000 --- 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 fc8337a43ba..00000000000 --- 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 8bcc70d0924..00000000000 --- 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 dc23d8c14aa..00000000000 --- 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 c90704752cf..00000000000 --- 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 50aa0aa466c..00000000000 --- 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 c32848a0b00..00000000000 --- 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 b6ac258b189..00000000000 --- 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 d1b8b35c91e..00000000000 --- 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 c601b698085..00000000000 --- 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 71269b54ca1..00000000000 --- 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 cb1a5797644..101a5d80663 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 93a5c288319..58e1ce30fb0 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 f580d4638d3..0aa31b7bd4b 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 0776c514377..fdd02004292 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 603bc9953e1..ad1a6ce9a61 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 41ebbaf669f..0971ece699f 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 c21e0a71e2a..161290fca88 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 a87001baae6..1bf8cba6bb0 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 edd05cc2605..36152bc0f4f 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 e7fce5e5a27..635119d55cc 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 1e043c9db88..f2b3e4cc8be 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 5a61ada30f9..7b919b53260 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 133531b447f..4597bd83bc3 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 145210fa7bb..cd87bbb1653 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 17/27] 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 | 58 ++++++++++++++++++- 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/src/hooks/useEncryptionStatus.ts b/src/hooks/useEncryptionStatus.ts index 30417f78217..686f68f25e2 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 a7e556e4527..1be9c777139 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 { + 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 18/27] 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 cf8d40acc8d..f97dff786fa 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 1e87b5b8260..1258bde2cad 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 7361e3982dd..d9c97261dd3 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 af84feb8480..ec85e72ac9d 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 db165eb115a..06c67c7d0b3 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 5422f68d7b7..2fb9c6a9ca7 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 63143d4644a..574549d8b24 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 30e1151d536..f5b0b1e0742 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 b9d05141481..fa1d74955d9 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 388f1965d74..b9ab29b94b1 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 19/27] [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 824ee3273e9..ea95a6bbdc4 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 20/27] 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 02e26729d26..4f47cd7eac4 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 85483eb23ca..252d4378ade 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 e3ca77f9884..11872d059ee 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 afd444c9524..e51dd966475 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 75b9977dc4f..3234c2be35b 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 c54e6950062..1bcff784879 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 e721662cb5d..bc6680d3000 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 99c69b98912..d1a35f29501 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 51d7a9303c1..d4dbb27d1b2 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 0a5798d8a1a..7842afbfe51 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 0862c6b385c..906826e4564 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 16106ee0d22..b3766bfc896 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 037a57bb061..cf44716ba9e 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 02bed8cf4fc..f30db3d80e6 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 3a78ef55e80..ca5f3d04b9b 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 53baed8be3e..52b0053147b 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 21/27] 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 3a42cde9bbd..7b1af0c771d 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 22/27] 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 ec7dc63e89a..494665eb5f8 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 7cc81093957..8dde7a3f986 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 23/27] 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 6260a72f99d..a554890dc6d 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 494665eb5f8..f6bcb150bc8 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 24/27] 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 8ca6577118c..622269f3c0b 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 166160665b4..20f545347e2 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 25/27] [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 ea95a6bbdc4..578135e2ad4 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 26/27] 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 | 446 +++++++++--------- .../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, 703 insertions(+), 715 deletions(-) diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index e0a9318e9a2..5c631edb974 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 97b203cd5ba..a3d5624cb46 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 4fbb0dc77d5..2aad5d45ffc 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 29b25fda218..5285a840b25 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 c8145027324..520103bca11 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 b3766bfc896..c9662c64f45 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 d55905d4b4f..446727c74e2 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 f30db3d80e6..b6fbd2e8504 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,101 +445,17 @@ describe("RoomView", () => { }); }); - describe("when there is a RoomView", () => { - const widget1Id = "widget1"; - const widget2Id = "widget2"; - const otherUserId = "@other:example.com"; - - const addJitsiWidget = async (id: string, user: string, ts?: number): Promise => { - const widgetEvent = mkEvent({ - event: true, - room: room.roomId, - user, - type: "im.vector.modular.widgets", - content: { - id, - name: "Jitsi", - type: WidgetType.JITSI.preferred, - url: "https://example.com", - }, - skey: id, - ts, - }); - room.addLiveEvents([widgetEvent]); - room.currentState.setStateEvents([widgetEvent]); - cli.emit(RoomStateEvent.Events, widgetEvent, room.currentState, null); - await flushPromises(); - }; - - beforeEach(async () => { - jest.spyOn(WidgetUtils, "setRoomWidget"); - const widgetStore = WidgetStore.instance; - await setupAsyncStoreWithClient(widgetStore, cli); - getRoomViewInstance(); - }); - - const itShouldNotRemoveTheLastWidget = (): void => { - it("should not remove the last widget", (): void => { - expect(WidgetUtils.setRoomWidget).not.toHaveBeenCalledWith(room.roomId, widget2Id); - }); - }; - - describe("and there is a Jitsi widget from another user", () => { - beforeEach(async () => { - await addJitsiWidget(widget1Id, otherUserId, 10_000); - }); - - describe("and the current user adds a Jitsi widget after 10s", () => { - beforeEach(async () => { - await addJitsiWidget(widget2Id, cli.getSafeUserId(), 20_000); - }); - - it("the last Jitsi widget should be removed", () => { - expect(WidgetUtils.setRoomWidget).toHaveBeenCalledWith(cli, room.roomId, widget2Id); - }); - }); - - describe("and the current user adds a Jitsi widget after two minutes", () => { - beforeEach(async () => { - await addJitsiWidget(widget2Id, cli.getSafeUserId(), 130_000); - }); - - itShouldNotRemoveTheLastWidget(); - }); - - describe("and the current user adds a Jitsi widget without timestamp", () => { - beforeEach(async () => { - await addJitsiWidget(widget2Id, cli.getSafeUserId()); - }); - - itShouldNotRemoveTheLastWidget(); - }); - }); - - describe("and there is a Jitsi widget from another user without timestamp", () => { - beforeEach(async () => { - await addJitsiWidget(widget1Id, otherUserId); - }); - - describe("and the current user adds a Jitsi widget", () => { - beforeEach(async () => { - await addJitsiWidget(widget2Id, cli.getSafeUserId(), 10_000); - }); - - itShouldNotRemoveTheLastWidget(); - }); - }); - }); - 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" }), - }); + 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?"); @@ -575,47 +507,50 @@ describe("RoomView", () => { const eventMapper = (obj: Partial) => new MatrixEvent(obj); - const roomViewRef = createRef<_RoomView>(); + 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 - 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", + 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: {}, }, - 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: [], + eventMapper, + ), + ], + highlights: [], + count: 1, + }), + inProgress: false, count: 1, - }), - inProgress: false, - count: 1, - }, - }); + }, + }), + ); await waitFor(() => { expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); @@ -636,47 +571,50 @@ describe("RoomView", () => { const eventMapper = (obj: Partial) => new MatrixEvent(obj); - const roomViewRef = createRef<_RoomView>(); + 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 - 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", + 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: {}, }, - 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: [], + eventMapper, + ), + ], + highlights: [], + count: 1, + }), + inProgress: false, count: 1, - }), - inProgress: false, - count: 1, - }, - }); + }, + }), + ); await waitFor(() => { expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); @@ -694,4 +632,90 @@ describe("RoomView", () => { await mountRoomView(); expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.RoomLoaded }); }); + + describe("when there is a RoomView", () => { + const widget1Id = "widget1"; + const widget2Id = "widget2"; + const otherUserId = "@other:example.com"; + + const addJitsiWidget = async (id: string, user: string, ts?: number): Promise => { + const widgetEvent = mkEvent({ + event: true, + room: room.roomId, + user, + type: "im.vector.modular.widgets", + content: { + id, + name: "Jitsi", + type: WidgetType.JITSI.preferred, + url: "https://example.com", + }, + skey: id, + ts, + }); + room.addLiveEvents([widgetEvent]); + room.currentState.setStateEvents([widgetEvent]); + cli.emit(RoomStateEvent.Events, widgetEvent, room.currentState, null); + await flushPromises(); + }; + + beforeEach(async () => { + jest.spyOn(WidgetUtils, "setRoomWidget"); + const widgetStore = WidgetStore.instance; + await setupAsyncStoreWithClient(widgetStore, cli); + getRoomViewInstance(); + }); + + const itShouldNotRemoveTheLastWidget = (): void => { + it("should not remove the last widget", (): void => { + expect(WidgetUtils.setRoomWidget).not.toHaveBeenCalledWith(room.roomId, widget2Id); + }); + }; + + describe("and there is a Jitsi widget from another user", () => { + beforeEach(async () => { + await addJitsiWidget(widget1Id, otherUserId, 10_000); + }); + + describe("and the current user adds a Jitsi widget after 10s", () => { + beforeEach(async () => { + await addJitsiWidget(widget2Id, cli.getSafeUserId(), 20_000); + }); + + it("the last Jitsi widget should be removed", () => { + expect(WidgetUtils.setRoomWidget).toHaveBeenCalledWith(cli, room.roomId, widget2Id); + }); + }); + + describe("and the current user adds a Jitsi widget after two minutes", () => { + beforeEach(async () => { + await addJitsiWidget(widget2Id, cli.getSafeUserId(), 130_000); + }); + + itShouldNotRemoveTheLastWidget(); + }); + + describe("and the current user adds a Jitsi widget without timestamp", () => { + beforeEach(async () => { + await addJitsiWidget(widget2Id, cli.getSafeUserId()); + }); + + itShouldNotRemoveTheLastWidget(); + }); + }); + + describe("and there is a Jitsi widget from another user without timestamp", () => { + beforeEach(async () => { + await addJitsiWidget(widget1Id, otherUserId); + }); + + describe("and the current user adds a Jitsi widget", () => { + beforeEach(async () => { + await addJitsiWidget(widget2Id, cli.getSafeUserId(), 10_000); + }); + + itShouldNotRemoveTheLastWidget(); + }); + }); + }); }); diff --git a/test/unit-tests/components/structures/ThreadPanel-test.tsx b/test/unit-tests/components/structures/ThreadPanel-test.tsx index 1b4d59d9afb..c19127de259 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 4a663517795..cee7e143d58 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 db6ce005c05..413acfbafa8 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 5cc95b96eec..54d21e147b5 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 fa1d74955d9..9e792a48f30 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 b0ee3531e2a..6e8837c50d3 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 95ce95d3f4f..12363f56f04 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 24fb2ca5ddc..716b4513ceb 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 b3b5fc3b893..f039d945143 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 d069d663b8e..e67334ca61d 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 672580e9526..84c5e91ea00 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 0c953a17385..aade46a2e29 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 ca5f3d04b9b..5788daebc0f 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 598542d297d..982fadad204 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 e3883b7033d..5bf7ab55ea9 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 96aeffb03c4..1e0f0a658c6 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 b6bd7b72d81..360eeda061d 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 dbf5645ca8e..441afec7002 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 b2835d15c03..4cb22967608 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 3e17f7ce862..34c37d2ba58 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 c2e0c4848e6..7d8112c2f8c 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 e423d03ea9f..f3a0168833a 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 23384d8a435..5d3c4552884 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 7fa6619a995..a285a98f3b6 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 52e754d6912..0258ce70929 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 218e43ac1f4..98a0657eae0 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 52c9d3aaa95..87411e18a17 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 6c51cc41abc..5c6a8ac8ee5 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 38f9e483c87..72f94d29c69 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 c7df2a0e6e0..8b68b3e3786 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 14dfa155055..0683ad1b67d 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 eeb5e5967c8..4fd8e034590 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`] = `