diff --git a/.eslintrc.js b/.eslintrc.js index e95f4834e9..f168a87a06 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -117,10 +117,6 @@ module.exports = { "!matrix-js-sdk/src/extensible_events_v1/PollResponseEvent", "!matrix-js-sdk/src/extensible_events_v1/PollEndEvent", "!matrix-js-sdk/src/extensible_events_v1/InvalidEventError", - "!matrix-js-sdk/src/crypto", - "!matrix-js-sdk/src/crypto/keybackup", - "!matrix-js-sdk/src/crypto/deviceinfo", - "!matrix-js-sdk/src/crypto/dehydration", "!matrix-js-sdk/src/oidc", "!matrix-js-sdk/src/oidc/discovery", "!matrix-js-sdk/src/oidc/authorize", diff --git a/.github/labels.yml b/.github/labels.yml index 848a79ba30..80c5408c1e 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -232,6 +232,9 @@ - name: "Z-Flaky-Test" description: "A test is raising false alarms" color: "ededed" +- name: "Z-Flaky-Jest-Test" + description: "A Jest test is raising false alarms" + color: "ededed" - name: "Z-FOSDEM" description: "Issues in chat.fosdem.org" color: "ededed" diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 7252c27b5f..5a11ad5bbd 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -7,6 +7,8 @@ on: branches: - develop +permissions: {} # We use ELEMENT_BOT_TOKEN instead + jobs: backport: name: Backport diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 55f5c1f4a3..381755b606 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,6 +10,7 @@ env: # These must be set for fetchdep.sh to get the right branch REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} +permissions: {} # No permissions required jobs: build: name: "Build on ${{ matrix.image }}" diff --git a/.github/workflows/build_debian.yaml b/.github/workflows/build_debian.yaml index 319dccd9f2..f46678512a 100644 --- a/.github/workflows/build_debian.yaml +++ b/.github/workflows/build_debian.yaml @@ -3,6 +3,7 @@ on: release: types: [published] concurrency: ${{ github.workflow }} +permissions: {} # We use ELEMENT_BOT_TOKEN instead jobs: build: name: Build package diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index b4c96c4eef..c21ab831e6 100644 --- a/.github/workflows/build_develop.yml +++ b/.github/workflows/build_develop.yml @@ -9,6 +9,7 @@ on: concurrency: group: ${{ github.repository_owner }}-${{ github.workflow }}-${{ github.ref_name }} cancel-in-progress: true +permissions: {} jobs: build: name: "Build & Deploy develop.element.io" @@ -16,6 +17,9 @@ jobs: if: github.repository == 'element-hq/element-web' runs-on: ubuntu-24.04 environment: develop + permissions: + checks: read + pages: write env: R2_BUCKET: "element-web-develop" R2_URL: ${{ vars.CF_R2_S3_API }} diff --git a/.github/workflows/dockerhub.yaml b/.github/workflows/dockerhub.yaml index 65457ab8f9..7911cf794a 100644 --- a/.github/workflows/dockerhub.yaml +++ b/.github/workflows/dockerhub.yaml @@ -7,14 +7,14 @@ on: # This job can take a while, and we have usage limits, so just publish develop only twice a day - cron: "0 7/12 * * *" concurrency: ${{ github.workflow }}-${{ github.ref_name }} - -permissions: - id-token: write # needed for signing the images with GitHub OIDC Token +permissions: {} jobs: buildx: name: Docker Buildx runs-on: ubuntu-24.04 environment: dockerhub + permissions: + id-token: write # needed for signing the images with GitHub OIDC Token steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c3f08deb1d..a301b6daf6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,10 +5,7 @@ on: branches: [develop] workflow_dispatch: {} -permissions: - contents: read - pages: write - id-token: write +permissions: {} concurrency: group: "pages" @@ -100,6 +97,9 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write needs: build steps: - name: Deploy to GitHub Pages diff --git a/.github/workflows/end-to-end-tests-netlify.yaml b/.github/workflows/end-to-end-tests-netlify.yaml index a15e02c9ee..e25994ec9d 100644 --- a/.github/workflows/end-to-end-tests-netlify.yaml +++ b/.github/workflows/end-to-end-tests-netlify.yaml @@ -11,6 +11,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }} cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }} +permissions: {} + jobs: report: if: github.event.workflow_run.conclusion != 'cancelled' @@ -20,11 +22,12 @@ jobs: permissions: statuses: write deployments: write + actions: read steps: - name: Download HTML report uses: actions/download-artifact@v4 with: - github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} name: html-report path: playwright-report diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml index 1784dafe0e..1a31f75065 100644 --- a/.github/workflows/end-to-end-tests.yaml +++ b/.github/workflows/end-to-end-tests.yaml @@ -33,6 +33,8 @@ env: # fetchdep.sh needs to know our PR number PR_NUMBER: ${{ github.event.pull_request.number }} +permissions: {} # No permissions required + jobs: build: name: "Build Element-Web" diff --git a/.github/workflows/issue_closed.yml b/.github/workflows/issue_closed.yml index 191f345cc9..2cffae0011 100644 --- a/.github/workflows/issue_closed.yml +++ b/.github/workflows/issue_closed.yml @@ -4,6 +4,7 @@ on: issues: types: [closed] +permissions: {} # We use ELEMENT_BOT_TOKEN instead jobs: tidy: name: Tidy closed issues diff --git a/.github/workflows/localazy_download.yaml b/.github/workflows/localazy_download.yaml index a880c3b2e4..435b8154ba 100644 --- a/.github/workflows/localazy_download.yaml +++ b/.github/workflows/localazy_download.yaml @@ -3,6 +3,7 @@ on: workflow_dispatch: {} schedule: - cron: "0 6 * * 1,3,5" # Every Monday, Wednesday and Friday at 6am UTC +permissions: {} # We use ELEMENT_BOT_TOKEN instead jobs: download: uses: matrix-org/matrix-web-i18n/.github/workflows/localazy_download.yaml@main diff --git a/.github/workflows/localazy_upload.yaml b/.github/workflows/localazy_upload.yaml index 9ba79800db..8cb7743968 100644 --- a/.github/workflows/localazy_upload.yaml +++ b/.github/workflows/localazy_upload.yaml @@ -4,6 +4,7 @@ on: branches: [develop] paths: - "src/i18n/strings/en_EN.json" +permissions: {} # No permissions needed jobs: upload: uses: matrix-org/matrix-web-i18n/.github/workflows/localazy_upload.yaml@main diff --git a/.github/workflows/netlify.yaml b/.github/workflows/netlify.yaml index 174c6579c3..63bac7d33f 100644 --- a/.github/workflows/netlify.yaml +++ b/.github/workflows/netlify.yaml @@ -11,6 +11,9 @@ jobs: if: github.event.workflow_run.conclusion != 'cancelled' && github.event.workflow_run.event == 'pull_request' runs-on: ubuntu-24.04 environment: Netlify + permissions: + actions: read + deployments: write steps: - name: 📝 Create Deployment uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1 @@ -27,7 +30,7 @@ jobs: - name: 📥 Download artifact uses: actions/download-artifact@v4 with: - github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} name: webapp path: webapp diff --git a/.github/workflows/pending-reviews.yaml b/.github/workflows/pending-reviews.yaml index 499da6a9b3..c96ed3f17e 100644 --- a/.github/workflows/pending-reviews.yaml +++ b/.github/workflows/pending-reviews.yaml @@ -6,6 +6,7 @@ on: #schedule: # - cron: "*/10 * * * *" concurrency: ${{ github.workflow }} +permissions: {} # We use ELEMENT_BOT_TOKEN instead jobs: bot: name: Pending reviews bot diff --git a/.github/workflows/playwright-image-updates.yaml b/.github/workflows/playwright-image-updates.yaml index 1492adc736..1613b42dfb 100644 --- a/.github/workflows/playwright-image-updates.yaml +++ b/.github/workflows/playwright-image-updates.yaml @@ -3,9 +3,12 @@ on: workflow_dispatch: {} schedule: - cron: "0 6 * * *" # Every day at 6am UTC +permissions: {} jobs: update: runs-on: ubuntu-24.04 + permissions: + pull-requests: write steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 1f49adfcc4..2f97ccbbb4 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -4,8 +4,11 @@ on: types: [opened, edited, labeled, unlabeled, synchronize] merge_group: types: [checks_requested] +permissions: {} jobs: action: uses: matrix-org/matrix-js-sdk/.github/workflows/pull_request.yaml@develop + permissions: + pull-requests: read secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/pull_request_base_branch.yaml b/.github/workflows/pull_request_base_branch.yaml index 04ad3f3106..6610ee4879 100644 --- a/.github/workflows/pull_request_base_branch.yaml +++ b/.github/workflows/pull_request_base_branch.yaml @@ -2,6 +2,7 @@ name: Pull Request Base Branch on: pull_request: types: [opened, edited, synchronize] +permissions: {} # No permissions required jobs: check_base_branch: name: Check PR base branch diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index d8afa80a9f..c4bf8e6ab3 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -4,6 +4,9 @@ on: branches: [staging] workflow_dispatch: {} concurrency: ${{ github.workflow }} +permissions: {} jobs: draft: + permissions: + contents: write uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop diff --git a/.github/workflows/release-gitflow.yml b/.github/workflows/release-gitflow.yml index 34232d420d..128c6a1e05 100644 --- a/.github/workflows/release-gitflow.yml +++ b/.github/workflows/release-gitflow.yml @@ -4,6 +4,7 @@ on: push: branches: [master] concurrency: ${{ github.repository }}-${{ github.workflow }} +permissions: {} # We use ELEMENT_BOT_TOKEN instead jobs: merge: uses: matrix-org/matrix-js-sdk/.github/workflows/release-gitflow.yml@develop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a9c29e197..2ecc4a4662 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,13 @@ on: - rc - final concurrency: ${{ github.workflow }} +permissions: {} jobs: release: uses: matrix-org/matrix-js-sdk/.github/workflows/release-make.yml@develop + permissions: + contents: write + issues: write secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} @@ -42,6 +46,8 @@ jobs: name: Post release checks needs: release runs-on: ubuntu-24.04 + permissions: + checks: read steps: - name: Wait for dockerhub uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork diff --git a/.github/workflows/release_prepare.yml b/.github/workflows/release_prepare.yml index 5fb969a1c6..b655bb4206 100644 --- a/.github/workflows/release_prepare.yml +++ b/.github/workflows/release_prepare.yml @@ -17,6 +17,7 @@ on: required: true type: boolean default: true +permissions: {} # Uses ELEMENT_BOT_TOKEN instead jobs: prepare: runs-on: ubuntu-24.04 diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index d9b26c78e8..0ee457bac2 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -7,11 +7,16 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} cancel-in-progress: true +permissions: {} jobs: sonarqube: name: 🩻 SonarQube if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group' uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop + permissions: + actions: read + statuses: write + id-token: write # sonar secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 87e5a70730..b7c02c3f2e 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -16,6 +16,8 @@ env: REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} +permissions: {} # No permissions required + jobs: ts_lint: name: "Typescript Syntax Check" @@ -37,6 +39,8 @@ jobs: i18n_lint: name: "i18n Check" uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main + permissions: + pull-requests: read with: hardcoded-words: "Element" allowed-hardcoded-keys: | diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index bb22292a64..fa1be485bb 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -8,6 +8,9 @@ on: - develop paths: - .github/labels.yml + +permissions: {} # We use ELEMENT_BOT_TOKEN instead + jobs: sync-labels: uses: element-hq/element-meta/.github/workflows/sync-labels.yml@develop diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 59fefb2f80..14fd5ffd64 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,6 +26,8 @@ env: # fetchdep.sh needs to know our PR number PR_NUMBER: ${{ github.event.pull_request.number }} +permissions: {} + jobs: jest: name: Jest @@ -94,13 +96,15 @@ jobs: needs: jest if: always() runs-on: ubuntu-24.04 + permissions: + statuses: write steps: - if: needs.jest.result != 'skipped' && needs.jest.result != 'success' run: exit 1 - name: Skip SonarCloud in merge queue if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' - uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1 + uses: guibranco/github-status-action-v2@1f26a0237cd1a57626fbb5a0eb2494c9b8797d07 with: authToken: ${{ secrets.GITHUB_TOKEN }} state: success diff --git a/.github/workflows/triage-assigned.yml b/.github/workflows/triage-assigned.yml index 81d1dff80f..e43eb94618 100644 --- a/.github/workflows/triage-assigned.yml +++ b/.github/workflows/triage-assigned.yml @@ -4,6 +4,8 @@ on: issues: types: [assigned] +permissions: {} # We use ELEMENT_BOT_TOKEN instead + jobs: web-app-team: runs-on: ubuntu-24.04 diff --git a/.github/workflows/triage-incoming.yml b/.github/workflows/triage-incoming.yml index e63017dc3b..b084b4d55e 100644 --- a/.github/workflows/triage-incoming.yml +++ b/.github/workflows/triage-incoming.yml @@ -4,6 +4,8 @@ on: issues: types: [opened] +permissions: {} # We use ELEMENT_BOT_TOKEN instead + jobs: automate-project-columns: runs-on: ubuntu-24.04 diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index 0112f180c1..2cb05a8bcf 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -8,6 +8,8 @@ on: ELEMENT_BOT_TOKEN: required: true +permissions: {} # We use ELEMENT_BOT_TOKEN instead + jobs: apply_Z-Labs_label: name: Add Z-Labs label for features behind labs flags diff --git a/.github/workflows/triage-move-review-requests.yml b/.github/workflows/triage-move-review-requests.yml index 72d9786a4a..d3bcda270b 100644 --- a/.github/workflows/triage-move-review-requests.yml +++ b/.github/workflows/triage-move-review-requests.yml @@ -3,6 +3,7 @@ on: pull_request_target: types: [review_requested] +permissions: {} # Uses ELEMENT_BOT_TOKEN instead jobs: add_design_pr_to_project: name: Move PRs asking for design review to the design board diff --git a/.github/workflows/triage-stale-flaky-tests.yml b/.github/workflows/triage-stale-flaky-tests.yml index d339a136cd..90ba7c40f7 100644 --- a/.github/workflows/triage-stale-flaky-tests.yml +++ b/.github/workflows/triage-stale-flaky-tests.yml @@ -2,6 +2,7 @@ name: Close stale flaky issues on: schedule: - cron: "30 1 * * *" +permissions: {} jobs: close: runs-on: ubuntu-24.04 diff --git a/.github/workflows/triage-unlabelled.yml b/.github/workflows/triage-unlabelled.yml index 1cd1c80afc..efbf80eea9 100644 --- a/.github/workflows/triage-unlabelled.yml +++ b/.github/workflows/triage-unlabelled.yml @@ -3,11 +3,13 @@ name: Move unlabelled from needs info columns to triaged on: issues: types: [unlabeled] - +permissions: {} jobs: Move_Unabeled_Issue_On_Project_Board: name: Move no longer X-Needs-Info issues to Triaged runs-on: ubuntu-24.04 + permissions: + repository-projects: read if: > ${{ !contains(github.event.issue.labels.*.name, 'X-Needs-Info') }} diff --git a/.github/workflows/update-jitsi.yml b/.github/workflows/update-jitsi.yml index 68dbf22e63..bf0414e73a 100644 --- a/.github/workflows/update-jitsi.yml +++ b/.github/workflows/update-jitsi.yml @@ -4,6 +4,7 @@ on: workflow_dispatch: {} schedule: - cron: "0 3 * * 0" # 3am every Sunday +permissions: {} # We use ELEMENT_BOT_TOKEN instead jobs: update: runs-on: ubuntu-24.04 diff --git a/.github/workflows/update-topics.yaml b/.github/workflows/update-topics.yaml index a984fc4f03..cd6c2fc553 100644 --- a/.github/workflows/update-topics.yaml +++ b/.github/workflows/update-topics.yaml @@ -15,6 +15,7 @@ on: required: true type: string concurrency: ${{ github.workflow }} +permissions: {} # No permissions required jobs: bot: name: Release topic update diff --git a/.stylelintrc.js b/.stylelintrc.js index dc8ae6376b..fa36402ff1 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -1,7 +1,7 @@ module.exports = { extends: ["stylelint-config-standard"], customSyntax: "postcss-scss", - plugins: ["stylelint-scss"], + plugins: ["stylelint-scss", "stylelint-value-no-unknown-custom-properties"], rules: { "comment-empty-line-before": null, "declaration-empty-line-before": null, @@ -46,5 +46,33 @@ module.exports = { "number-max-precision": null, "no-invalid-double-slash-comments": true, "media-feature-range-notation": null, + "csstools/value-no-unknown-custom-properties": [ + true, + { + importFrom: [ + { from: "res/css/_common.pcss", type: "css" }, + { from: "res/themes/light/css/_light.pcss", type: "css" }, + // Right now our styles share vars all over the place, this is not ideal but acceptable for now + { from: "res/css/views/rooms/_EventTile.pcss", type: "css" }, + { from: "res/css/views/rooms/_IRCLayout.pcss", type: "css" }, + { from: "res/css/views/rooms/_EventBubbleTile.pcss", type: "css" }, + { from: "res/css/views/rooms/_ReadReceiptGroup.pcss", type: "css" }, + { from: "res/css/views/rooms/_EditMessageComposer.pcss", type: "css" }, + { from: "res/css/views/right_panel/_BaseCard.pcss", type: "css" }, + { from: "res/css/views/messages/_MessageTimestamp.pcss", type: "css" }, + { from: "res/css/views/messages/_EventTileBubble.pcss", type: "css" }, + { from: "res/css/views/messages/_MessageActionBar.pcss", type: "css" }, + { from: "res/css/views/voip/LegacyCallView/_LegacyCallViewButtons.pcss", type: "css" }, + { from: "res/css/views/elements/_ToggleSwitch.pcss", type: "css" }, + { from: "res/css/views/settings/tabs/_SettingsTab.pcss", type: "css" }, + { from: "res/css/structures/_RoomView.pcss", type: "css" }, + // Compound vars + "node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-common-base.css", + "node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-common-semantic.css", + "node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-theme-light-base-mq.css", + "node_modules/@vector-im/compound-design-tokens/assets/web/css/cpd-theme-light-semantic-mq.css", + ], + }, + ], }, }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 6260a72f99..a554890dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +Changes in [1.11.86](https://github.com/element-hq/element-web/releases/tag/v1.11.86) (2024-11-19) +================================================================================================== +## ✨ Features + +* Deduplicate icons using Compound Design Tokens ([#28419](https://github.com/element-hq/element-web/pull/28419)). Contributed by @t3chguy. +* Let widget driver send error details ([#28357](https://github.com/element-hq/element-web/pull/28357)). Contributed by @AndrewFerr. +* Deduplicate icons using Compound Design Tokens ([#28381](https://github.com/element-hq/element-web/pull/28381)). Contributed by @t3chguy. +* Auto approvoce `io.element.call.reaction` capability for element call widgets ([#28401](https://github.com/element-hq/element-web/pull/28401)). Contributed by @toger5. +* Show message type prefix in thread root \& reply previews ([#28361](https://github.com/element-hq/element-web/pull/28361)). Contributed by @t3chguy. +* Support sending encrypted to device messages from widgets ([#28315](https://github.com/element-hq/element-web/pull/28315)). Contributed by @hughns. + +## 🐛 Bug Fixes + +* Feed events to widgets as they are decrypted (even if out of order) ([#28376](https://github.com/element-hq/element-web/pull/28376)). Contributed by @robintown. +* Handle authenticated media when downloading from ImageView ([#28379](https://github.com/element-hq/element-web/pull/28379)). Contributed by @t3chguy. +* Ignore `m.3pid_changes` for Identity service 3PID changes ([#28375](https://github.com/element-hq/element-web/pull/28375)). Contributed by @t3chguy. +* Fix markdown escaping wrongly passing html through ([#28363](https://github.com/element-hq/element-web/pull/28363)). Contributed by @t3chguy. +* Remove "Upgrade your encryption" flow in `CreateSecretStorageDialog` ([#28290](https://github.com/element-hq/element-web/pull/28290)). Contributed by @florianduros. + + Changes in [1.11.85](https://github.com/element-hq/element-web/releases/tag/v1.11.85) (2024-11-12) ================================================================================================== # Security diff --git a/package.json b/package.json index 0a0d0a477b..a48284bb97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.11.85", + "version": "1.11.86", "description": "A feature-rich client for Matrix.org", "author": "New Vector Ltd.", "repository": { @@ -86,7 +86,7 @@ "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^8.0.0", "@vector-im/compound-design-tokens": "^2.0.1", - "@vector-im/compound-web": "^7.3.0", + "@vector-im/compound-web": "^7.4.0", "@vector-im/matrix-wysiwyg": "2.37.13", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", @@ -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/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index 824ee3273e..0a14950b1f 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:127c68d4468019ce363c8b2fd7a42a3ef50710eb3aaf288a2295dd4623ce9f54"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); 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 7e1195c82d..1ade373ba8 100644 Binary files a/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png and b/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png differ diff --git a/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png b/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png index 1387ef062d..a8709dfd99 100644 Binary files a/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png and b/playwright/snapshots/register/email.spec.ts/registration-check-your-email-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png b/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png index 51c70fc196..5d32d5b619 100644 Binary files a/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png and b/playwright/snapshots/register/register.spec.ts/email-prompt-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/registration-linux.png b/playwright/snapshots/register/register.spec.ts/registration-linux.png index bac041646a..a4f3be59a9 100644 Binary files a/playwright/snapshots/register/register.spec.ts/registration-linux.png and b/playwright/snapshots/register/register.spec.ts/registration-linux.png differ diff --git a/playwright/snapshots/register/register.spec.ts/terms-prompt-linux.png b/playwright/snapshots/register/register.spec.ts/terms-prompt-linux.png index 913ccf9839..310dcf25d9 100644 Binary files a/playwright/snapshots/register/register.spec.ts/terms-prompt-linux.png and b/playwright/snapshots/register/register.spec.ts/terms-prompt-linux.png differ diff --git a/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-no-results-linux.png b/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-no-results-linux.png index 458c6469f1..82779d4cc9 100644 Binary files a/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-no-results-linux.png and b/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-no-results-linux.png differ 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 7bfa3229ed..666dbe689a 100644 Binary files a/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-one-result-linux.png and b/playwright/snapshots/room-directory/room-directory.spec.ts/filtered-one-result-linux.png differ diff --git a/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png b/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png index 7e04b4404d..edc0ad17ad 100644 Binary files a/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png and b/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index cd17066955..fd6697307b 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -320,6 +320,7 @@ @import "./views/rooms/_ThirdPartyMemberInfo.pcss"; @import "./views/rooms/_ThreadSummary.pcss"; @import "./views/rooms/_TopUnreadMessagesBar.pcss"; +@import "./views/rooms/_UserIdentityWarning.pcss"; @import "./views/rooms/_VoiceRecordComposerTile.pcss"; @import "./views/rooms/_WhoIsTypingTile.pcss"; @import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss"; diff --git a/res/css/components/views/elements/_AppPermission.pcss b/res/css/components/views/elements/_AppPermission.pcss index 25db241f73..0891d25221 100644 --- a/res/css/components/views/elements/_AppPermission.pcss +++ b/res/css/components/views/elements/_AppPermission.pcss @@ -11,7 +11,8 @@ Please see LICENSE files in the repository root for full details. font-size: $font-12px; width: 100%; /* make mx_AppPermission fill width of mx_AppTileBody so that scroll bar appears on the edge */ overflow-y: scroll; - .mx_AppPermission_bolder { + .mx_AppPermission_bolder, + .mx_AppPermission_content_bolder { font-weight: var(--cpd-font-weight-semibold); } .mx_AppPermission_content { @@ -21,10 +22,6 @@ Please see LICENSE files in the repository root for full details. margin-block: 12px; } - .mx_AppPermission_content_bolder { - font-weight: var(--font-semi-bold); - } - .mx_TextWithTooltip_target--helpIcon { display: inline-block; height: $font-14px; /* align with characters on the same line */ diff --git a/res/css/structures/_RightPanel.pcss b/res/css/structures/_RightPanel.pcss index 57196985a9..0f796dbc96 100644 --- a/res/css/structures/_RightPanel.pcss +++ b/res/css/structures/_RightPanel.pcss @@ -27,7 +27,7 @@ Please see LICENSE files in the repository root for full details. /** Fixme - factor this out with the main header **/ .mx_RightPanel_threadsButton::before { - mask-image: url("$(res)/img/element-icons/room/thread.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/threads-solid.svg"); } .mx_RightPanel_notifsButton::before { @@ -36,7 +36,7 @@ Please see LICENSE files in the repository root for full details. } .mx_RightPanel_roomSummaryButton::before { - mask-image: url("$(res)/img/element-icons/room/room-summary.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); mask-position: center; } diff --git a/res/css/structures/_RoomView.pcss b/res/css/structures/_RoomView.pcss index eaa02cd2d2..65ea555ce1 100644 --- a/res/css/structures/_RoomView.pcss +++ b/res/css/structures/_RoomView.pcss @@ -207,62 +207,3 @@ Please see LICENSE files in the repository root for full details. min-height: 42px; } } - -@keyframes mx_Indicator_pulse { - 0% { - transform: scale(0.95); - } - - 70% { - transform: scale(1); - } - - 100% { - transform: scale(0.95); - } -} - -@keyframes mx_Indicator_pulse_shadow { - 0% { - opacity: 0.7; - } - - 70% { - transform: scale(2.2); - opacity: 0; - } - - 100% { - opacity: 0; - } -} - -.mx_Indicator { - position: absolute; - right: -3px; - top: -3px; - width: var(--RoomHeader-indicator-dot-size); - height: var(--RoomHeader-indicator-dot-size); - border-radius: 50%; - transform: scale(1); - background: var(--RoomHeader-indicator-pulseColor); - box-shadow: 0 0 0 0 var(--RoomHeader-indicator-pulseColor); - animation: mx_Indicator_pulse 2s infinite; - animation-iteration-count: 1; - - &::after { - content: ""; - position: absolute; - width: inherit; - height: inherit; - top: 0; - left: 0; - transform: scale(1); - transform-origin: center center; - animation-name: mx_Indicator_pulse_shadow; - animation-duration: inherit; - animation-iteration-count: inherit; - border-radius: 50%; - background: inherit; - } -} diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index fdaa930686..7ea717554b 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -211,11 +211,11 @@ Please see LICENSE files in the repository root for full details. } &.mx_SpaceButton_favourites .mx_SpaceButton_icon::before { - mask-image: url("$(res)/img/element-icons/roomlist/favorite.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/favourite-solid.svg"); } &.mx_SpaceButton_people .mx_SpaceButton_icon::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } &.mx_SpaceButton_orphans .mx_SpaceButton_icon::before { @@ -426,11 +426,11 @@ Please see LICENSE files in the repository root for full details. } .mx_SpacePanel_iconLeave::before { - mask-image: url("$(res)/img/element-icons/leave.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/leave.svg"); } .mx_SpacePanel_iconMembers::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } .mx_SpacePanel_iconPlus::before { diff --git a/res/css/structures/_SpaceRoomView.pcss b/res/css/structures/_SpaceRoomView.pcss index 7e55743200..d756d51d65 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); @@ -248,7 +248,7 @@ Please see LICENSE files in the repository root for full details. } .mx_SpaceRoomView_privateScope_justMeButton::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } .mx_SpaceRoomView_privateScope_meAndMyTeammatesButton::before { diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index 7cf3845027..741a4e90dc 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -197,7 +197,7 @@ Please see LICENSE files in the repository root for full details. } .mx_UserMenu_iconSignOut::before { - mask-image: url("$(res)/img/element-icons/leave.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/leave.svg"); } .mx_UserMenu_iconQr::before { diff --git a/res/css/views/audio_messages/_PlaybackContainer.pcss b/res/css/views/audio_messages/_PlaybackContainer.pcss index e02533037b..f1dc1d1ec8 100644 --- a/res/css/views/audio_messages/_PlaybackContainer.pcss +++ b/res/css/views/audio_messages/_PlaybackContainer.pcss @@ -28,10 +28,11 @@ Please see LICENSE files in the repository root for full details. /* Waveforms are present in live recording only */ .mx_Waveform { + /* default, overridden in JS */ + --barHeight: 1; .mx_Waveform_bar { background-color: $quaternary-content; height: 100%; - /* Variable set by a JS component */ transform: scaleY(max(0.05, var(--barHeight))); &.mx_Waveform_bar_100pct { diff --git a/res/css/views/audio_messages/_SeekBar.pcss b/res/css/views/audio_messages/_SeekBar.pcss index 47cce4b47a..fb781811f1 100644 --- a/res/css/views/audio_messages/_SeekBar.pcss +++ b/res/css/views/audio_messages/_SeekBar.pcss @@ -12,6 +12,9 @@ Please see LICENSE files in the repository root for full details. /* * https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ */ .mx_SeekBar { + /* default, overridden in JS */ + --fillTo: 1; + /* Dev note: we deliberately do not have the -ms-track (and friends) selectors because we don't */ /* need to support IE. */ diff --git a/res/css/views/context_menus/_MessageContextMenu.pcss b/res/css/views/context_menus/_MessageContextMenu.pcss index e06782ebe9..a73dab9982 100644 --- a/res/css/views/context_menus/_MessageContextMenu.pcss +++ b/res/css/views/context_menus/_MessageContextMenu.pcss @@ -33,7 +33,7 @@ Please see LICENSE files in the repository root for full details. } .mx_MessageContextMenu_iconLink::before { - mask-image: url("$(res)/img/element-icons/link.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); } .mx_MessageContextMenu_iconPermalink::before { @@ -53,7 +53,7 @@ Please see LICENSE files in the repository root for full details. } .mx_MessageContextMenu_iconForward::before { - mask-image: url("$(res)/img/element-icons/message/fwd.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/forward.svg"); } .mx_MessageContextMenu_iconRedact::before { @@ -96,7 +96,7 @@ Please see LICENSE files in the repository root for full details. } .mx_MessageContextMenu_iconReplyInThread::before { - mask-image: url("$(res)/img/element-icons/message/thread.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/threads.svg"); } .mx_MessageContextMenu_iconReact::before { diff --git a/res/css/views/context_menus/_RoomGeneralContextMenu.pcss b/res/css/views/context_menus/_RoomGeneralContextMenu.pcss index 90602538f0..0eb51420bb 100644 --- a/res/css/views/context_menus/_RoomGeneralContextMenu.pcss +++ b/res/css/views/context_menus/_RoomGeneralContextMenu.pcss @@ -1,5 +1,5 @@ .mx_RoomGeneralContextMenu_iconStar::before { - mask-image: url("$(res)/img/element-icons/roomlist/favorite.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/favourite-solid.svg"); } .mx_RoomGeneralContextMenu_iconArrowDown::before { @@ -31,7 +31,7 @@ } .mx_RoomGeneralContextMenu_iconPeople::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } .mx_RoomGeneralContextMenu_iconFiles::before { @@ -43,7 +43,7 @@ } .mx_RoomGeneralContextMenu_iconWidgets::before { - mask-image: url("$(res)/img/element-icons/room/apps.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/extensions-solid.svg"); } .mx_RoomGeneralContextMenu_iconSettings::before { @@ -51,7 +51,7 @@ } .mx_RoomGeneralContextMenu_iconExport::before { - mask-image: url("$(res)/img/element-icons/export.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/export-archive.svg"); } .mx_RoomGeneralContextMenu_iconDeveloperTools::before { @@ -59,7 +59,7 @@ } .mx_RoomGeneralContextMenu_iconCopyLink::before { - mask-image: url("$(res)/img/element-icons/link.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); } .mx_RoomGeneralContextMenu_iconInvite::before { @@ -67,5 +67,5 @@ } .mx_RoomGeneralContextMenu_iconSignOut::before { - mask-image: url("$(res)/img/element-icons/leave.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/leave.svg"); } diff --git a/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.pcss b/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.pcss index 7c1828183a..3b91eddc8b 100644 --- a/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.pcss +++ b/res/css/views/dialogs/_ConfirmSpaceUserActionDialog.pcss @@ -51,7 +51,7 @@ Please see LICENSE files in the repository root for full details. background-color: $secondary-content; mask-repeat: no-repeat; mask-size: contain; - mask-image: url("$(res)/img/element-icons/room/room-summary.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); mask-position: center; } } diff --git a/res/css/views/dialogs/_LeaveSpaceDialog.pcss b/res/css/views/dialogs/_LeaveSpaceDialog.pcss index b332942f75..b3e3878276 100644 --- a/res/css/views/dialogs/_LeaveSpaceDialog.pcss +++ b/res/css/views/dialogs/_LeaveSpaceDialog.pcss @@ -45,7 +45,7 @@ Please see LICENSE files in the repository root for full details. background-color: $secondary-content; mask-repeat: no-repeat; mask-size: contain; - mask-image: url("$(res)/img/element-icons/room/room-summary.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); mask-position: center; } } diff --git a/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss index f6635b9791..a6b9fe0304 100644 --- a/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss +++ b/res/css/views/dialogs/_ManageRestrictedJoinRuleDialog.pcss @@ -108,7 +108,7 @@ Please see LICENSE files in the repository root for full details. background-color: $secondary-content; mask-repeat: no-repeat; mask-size: contain; - mask-image: url("$(res)/img/element-icons/room/room-summary.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); mask-position: center; } } diff --git a/res/css/views/dialogs/_SpotlightDialog.pcss b/res/css/views/dialogs/_SpotlightDialog.pcss index eff7bd0e12..c4f94bfde9 100644 --- a/res/css/views/dialogs/_SpotlightDialog.pcss +++ b/res/css/views/dialogs/_SpotlightDialog.pcss @@ -93,7 +93,7 @@ Please see LICENSE files in the repository root for full details. } &.mx_SpotlightDialog_filterPeople::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } &.mx_SpotlightDialog_filterPublicRooms::before { @@ -400,7 +400,7 @@ Please see LICENSE files in the repository root for full details. } .mx_SpotlightDialog_inviteLink .mx_AccessibleButton::before { - mask-image: url("$(res)/img/element-icons/link.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); } .mx_SpotlightDialog_createRoom .mx_AccessibleButton::before { @@ -432,7 +432,7 @@ Please see LICENSE files in the repository root for full details. } .mx_SpotlightDialog_startChat::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } .mx_SpotlightDialog_joinRoomAlias::before { @@ -512,11 +512,11 @@ Please see LICENSE files in the repository root for full details. } &.mx_SpotlightDialog_metaspaceResult_favourites-space { - mask-image: url("$(res)/img/element-icons/roomlist/favorite.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/favourite-solid.svg"); } &.mx_SpotlightDialog_metaspaceResult_people-space { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } &.mx_SpotlightDialog_metaspaceResult_orphans-space { diff --git a/res/css/views/elements/_InfoTooltip.pcss b/res/css/views/elements/_InfoTooltip.pcss index 0329f6a63b..dcec1410f1 100644 --- a/res/css/views/elements/_InfoTooltip.pcss +++ b/res/css/views/elements/_InfoTooltip.pcss @@ -25,7 +25,7 @@ Please see LICENSE files in the repository root for full details. } .mx_InfoTooltip_icon_info::before { - mask-image: url("$(res)/img/element-icons/info.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/info.svg"); } .mx_InfoTooltip_icon_warning::before { diff --git a/res/css/views/elements/_ProgressBar.pcss b/res/css/views/elements/_ProgressBar.pcss index 8900b7d985..062770f77f 100644 --- a/res/css/views/elements/_ProgressBar.pcss +++ b/res/css/views/elements/_ProgressBar.pcss @@ -16,16 +16,7 @@ progress.mx_ProgressBar { @mixin ProgressBarBorderRadius 6px; @mixin ProgressBarColour var(--cpd-color-icon-accent-tertiary); @mixin ProgressBarBgColour $progressbar-bg-color; - ::-webkit-progress-value { + &::-webkit-progress-value { transition: width 1s; } - ::-moz-progress-bar { - transition: padding-bottom 1s; - padding-bottom: var(--value); - transform-origin: 0 0; - transform: rotate(-90deg) translateX(-15px); - padding-left: 15px; - - height: 0; - } } diff --git a/res/css/views/messages/_MessageActionBar.pcss b/res/css/views/messages/_MessageActionBar.pcss index fd9012ed28..cdfc3693d5 100644 --- a/res/css/views/messages/_MessageActionBar.pcss +++ b/res/css/views/messages/_MessageActionBar.pcss @@ -108,6 +108,10 @@ Please see LICENSE files in the repository root for full details. color: var(--cpd-color-icon-primary); } + &.mx_MessageActionBar_threadButton { + --MessageActionBar-icon-size: 20px; + } + &.mx_MessageActionBar_retryButton { --MessageActionBar-icon-size: 16px; } diff --git a/res/css/views/right_panel/_ThreadPanel.pcss b/res/css/views/right_panel/_ThreadPanel.pcss index 93efded304..1f9d1e0562 100644 --- a/res/css/views/right_panel/_ThreadPanel.pcss +++ b/res/css/views/right_panel/_ThreadPanel.pcss @@ -165,7 +165,7 @@ Please see LICENSE files in the repository root for full details. } .mx_ThreadPanel_copyLinkToThread::before { - mask-image: url("$(res)/img/element-icons/link.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); } .mx_ContextualMenu_wrapper { diff --git a/res/css/views/rooms/_BasicMessageComposer.pcss b/res/css/views/rooms/_BasicMessageComposer.pcss index e34c991d89..499ce870ec 100644 --- a/res/css/views/rooms/_BasicMessageComposer.pcss +++ b/res/css/views/rooms/_BasicMessageComposer.pcss @@ -7,6 +7,11 @@ Please see LICENSE files in the repository root for full details. */ .mx_BasicMessageComposer { + /* These are set in Javascript */ + --avatar-letter: ""; + --avatar-background: unset; + --placeholder: ""; + position: relative; .mx_BasicMessageComposer_inputEmpty > :first-child::before { diff --git a/res/css/views/rooms/_EventBubbleTile.pcss b/res/css/views/rooms/_EventBubbleTile.pcss index 3a42cde9bb..7b1af0c771 100644 --- a/res/css/views/rooms/_EventBubbleTile.pcss +++ b/res/css/views/rooms/_EventBubbleTile.pcss @@ -334,7 +334,6 @@ Please see LICENSE files in the repository root for full details. .mx_MImageBody { width: 100%; - height: 100%; .mx_MImageBody_thumbnail.mx_MImageBody_thumbnail--blurhash { position: unset; diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 311e059166..d405381db1 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -1017,16 +1017,6 @@ $left-gutter: 64px; visibility: visible; } -/* Inverse of the above to *disable* the animation on any indicators. This approach */ -/* is less pretty, but is easier to target because otherwise we need to define the */ -/* animation for when it's shown which means duplicating the style definition in */ -/* multiple places. */ -.mx_EventTile:not(:hover):not(.mx_EventTile_actionBarFocused):not([data-whatinput="keyboard"] :focus-within) { - &:not(:focus-visible:focus-within) .mx_MessageActionBar .mx_Indicator { - animation: none; - } -} - .mx_EventTile[data-shape="ThreadsList"], .mx_EventTile[data-shape="Notification"] { --topOffset: $spacing-12; diff --git a/res/css/views/rooms/_RoomList.pcss b/res/css/views/rooms/_RoomList.pcss index 4ceba9a20a..97b1e76cef 100644 --- a/res/css/views/rooms/_RoomList.pcss +++ b/res/css/views/rooms/_RoomList.pcss @@ -29,7 +29,7 @@ Please see LICENSE files in the repository root for full details. mask-image: url("$(res)/img/element-icons/roomlist/dialpad.svg"); } .mx_RoomList_iconStartChat::before { - mask-image: url("$(res)/img/element-icons/roomlist/member-plus.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-add-solid.svg"); } .mx_RoomList_iconInvite::before { mask-image: url("$(res)/img/element-icons/room/share.svg"); diff --git a/res/css/views/rooms/_RoomListHeader.pcss b/res/css/views/rooms/_RoomListHeader.pcss index 6fbd2a38db..fa0e0b24eb 100644 --- a/res/css/views/rooms/_RoomListHeader.pcss +++ b/res/css/views/rooms/_RoomListHeader.pcss @@ -92,7 +92,7 @@ Please see LICENSE files in the repository root for full details. mask-image: url("$(res)/img/element-icons/room/invite.svg"); } .mx_RoomListHeader_iconStartChat::before { - mask-image: url("$(res)/img/element-icons/roomlist/member-plus.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-add-solid.svg"); } .mx_RoomListHeader_iconNewRoom::before { mask-image: url("$(res)/img/element-icons/roomlist/hash-plus.svg"); diff --git a/res/css/views/rooms/_RoomPreviewCard.pcss b/res/css/views/rooms/_RoomPreviewCard.pcss index 6b070de27f..f96b705cc2 100644 --- a/res/css/views/rooms/_RoomPreviewCard.pcss +++ b/res/css/views/rooms/_RoomPreviewCard.pcss @@ -34,7 +34,7 @@ Please see LICENSE files in the repository root for full details. mask-repeat: no-repeat; mask-position: center; mask-size: contain; - mask-image: url("$(res)/img/element-icons/room/room-summary.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/info-solid.svg"); background-color: $secondary-content; } } diff --git a/res/css/views/rooms/_RoomTile.pcss b/res/css/views/rooms/_RoomTile.pcss index 1550fc84fa..53f9c10f1b 100644 --- a/res/css/views/rooms/_RoomTile.pcss +++ b/res/css/views/rooms/_RoomTile.pcss @@ -182,7 +182,7 @@ Please see LICENSE files in the repository root for full details. .mx_RoomTile_contextMenu { .mx_RoomTile_iconStar::before { - mask-image: url("$(res)/img/element-icons/roomlist/favorite.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/favourite-solid.svg"); } .mx_RoomTile_iconArrowDown::before { @@ -206,7 +206,7 @@ Please see LICENSE files in the repository root for full details. } .mx_RoomTile_iconPeople::before { - mask-image: url("$(res)/img/element-icons/room/members.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/user-profile-solid.svg"); } .mx_RoomTile_iconFiles::before { @@ -218,7 +218,7 @@ Please see LICENSE files in the repository root for full details. } .mx_RoomTile_iconWidgets::before { - mask-image: url("$(res)/img/element-icons/room/apps.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/extensions-solid.svg"); } .mx_RoomTile_iconSettings::before { @@ -226,11 +226,11 @@ Please see LICENSE files in the repository root for full details. } .mx_RoomTile_iconExport::before { - mask-image: url("$(res)/img/element-icons/export.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/export-archive.svg"); } .mx_RoomTile_iconCopyLink::before { - mask-image: url("$(res)/img/element-icons/link.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); } .mx_RoomTile_iconInvite::before { @@ -238,6 +238,6 @@ Please see LICENSE files in the repository root for full details. } .mx_RoomTile_iconSignOut::before { - mask-image: url("$(res)/img/element-icons/leave.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/leave.svg"); } } diff --git a/res/css/views/rooms/_UserIdentityWarning.pcss b/res/css/views/rooms/_UserIdentityWarning.pcss new file mode 100644 index 0000000000..b294b3fc8c --- /dev/null +++ b/res/css/views/rooms/_UserIdentityWarning.pcss @@ -0,0 +1,28 @@ +/* +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. +*/ + +.mx_UserIdentityWarning { + /* 42px is the padding-left of .mx_MessageComposer_wrapper in res/css/views/rooms/_MessageComposer.pcss */ + margin-left: calc(-42px + var(--RoomView_MessageList-padding)); + + .mx_UserIdentityWarning_row { + display: flex; + align-items: center; + + .mx_BaseAvatar { + margin-left: var(--cpd-space-2x); + } + .mx_UserIdentityWarning_main { + margin-left: var(--cpd-space-6x); + flex-grow: 1; + } + } +} + +.mx_MessageComposer.mx_MessageComposer--compact > .mx_UserIdentityWarning { + margin-left: calc(-25px + var(--RoomView_MessageList-padding)); +} diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss index 5c0d5da9fc..34c2a4d626 100644 --- a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss @@ -7,6 +7,11 @@ Please see LICENSE files in the repository root for full details. */ .mx_WysiwygComposer_Editor_container { + /* These are set in Javascript */ + --avatar-letter: ""; + --avatar-background: unset; + --placeholder: ""; + @keyframes visualbell { from { background-color: $visual-bell-bg-color; diff --git a/res/css/views/spaces/_SpacePublicShare.pcss b/res/css/views/spaces/_SpacePublicShare.pcss index 58cf3659ae..ddda97b493 100644 --- a/res/css/views/spaces/_SpacePublicShare.pcss +++ b/res/css/views/spaces/_SpacePublicShare.pcss @@ -11,7 +11,7 @@ Please see LICENSE files in the repository root for full details. @mixin SpacePillButton; &.mx_SpacePublicShare_shareButton::before { - mask-image: url("$(res)/img/element-icons/link.svg"); + mask-image: url("@vector-im/compound-design-tokens/icons/link.svg"); } &.mx_SpacePublicShare_inviteButton::before { diff --git a/res/img/element-icons/export.svg b/res/img/element-icons/export.svg deleted file mode 100644 index 038866c294..0000000000 --- a/res/img/element-icons/export.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/res/img/element-icons/info.svg b/res/img/element-icons/info.svg deleted file mode 100644 index b5769074ab..0000000000 --- a/res/img/element-icons/info.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/res/img/element-icons/leave.svg b/res/img/element-icons/leave.svg deleted file mode 100644 index 773e27d4ce..0000000000 --- a/res/img/element-icons/leave.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/res/img/element-icons/link.svg b/res/img/element-icons/link.svg deleted file mode 100644 index 07dbdc0ccc..0000000000 --- a/res/img/element-icons/link.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/location.svg b/res/img/element-icons/location.svg deleted file mode 100644 index fc8337a43b..0000000000 --- a/res/img/element-icons/location.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/message/fwd.svg b/res/img/element-icons/message/fwd.svg deleted file mode 100644 index 8bcc70d092..0000000000 --- a/res/img/element-icons/message/fwd.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/message/thread.svg b/res/img/element-icons/message/thread.svg deleted file mode 100644 index dc23d8c14a..0000000000 --- a/res/img/element-icons/message/thread.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/res/img/element-icons/room/apps.svg b/res/img/element-icons/room/apps.svg deleted file mode 100644 index c90704752c..0000000000 --- a/res/img/element-icons/room/apps.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/res/img/element-icons/room/members.svg b/res/img/element-icons/room/members.svg deleted file mode 100644 index 50aa0aa466..0000000000 --- a/res/img/element-icons/room/members.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/res/img/element-icons/room/message-bar/reply.svg b/res/img/element-icons/room/message-bar/reply.svg deleted file mode 100644 index c32848a0b0..0000000000 --- a/res/img/element-icons/room/message-bar/reply.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/res/img/element-icons/room/room-summary.svg b/res/img/element-icons/room/room-summary.svg deleted file mode 100644 index b6ac258b18..0000000000 --- a/res/img/element-icons/room/room-summary.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/room/thread.svg b/res/img/element-icons/room/thread.svg deleted file mode 100644 index d1b8b35c91..0000000000 --- a/res/img/element-icons/room/thread.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/res/img/element-icons/roomlist/favorite.svg b/res/img/element-icons/roomlist/favorite.svg deleted file mode 100644 index c601b69808..0000000000 --- a/res/img/element-icons/roomlist/favorite.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/element-icons/roomlist/member-plus.svg b/res/img/element-icons/roomlist/member-plus.svg deleted file mode 100644 index 71269b54ca..0000000000 --- a/res/img/element-icons/roomlist/member-plus.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 02e26729d2..4f47cd7eac 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -46,6 +46,7 @@ import SettingsStore, { CallbackFn } from "./settings/SettingsStore"; import { UIFeature } from "./settings/UIFeature"; import { isBulkUnverifiedDeviceReminderSnoozed } from "./utils/device/snoozeBulkUnverifiedDeviceReminder"; import { getUserDeviceIds } from "./utils/crypto/deviceInfo"; +import { asyncSomeParallel } from "./utils/arrays.ts"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -240,13 +241,16 @@ export default class DeviceListener { return this.keyBackupInfo; } - private shouldShowSetupEncryptionToast(): boolean { + private async shouldShowSetupEncryptionToast(): Promise { // If we're in the middle of a secret storage operation, we're likely // modifying the state involved here, so don't add new toasts to setup. if (isSecretStorageBeingAccessed()) return false; // Show setup toasts once the user is in at least one encrypted room. const cli = this.client; - return cli?.getRooms().some((r) => cli.isRoomEncrypted(r.roomId)) ?? false; + const cryptoApi = cli?.getCrypto(); + if (!cli || !cryptoApi) return false; + + return await asyncSomeParallel(cli.getRooms(), ({ roomId }) => cryptoApi.isEncryptionEnabledInRoom(roomId)); } private recheck(): void { @@ -283,7 +287,7 @@ export default class DeviceListener { hideSetupEncryptionToast(); this.checkKeyBackupStatus(); - } else if (this.shouldShowSetupEncryptionToast()) { + } else if (await this.shouldShowSetupEncryptionToast()) { // make sure our keys are finished downloading await crypto.getUserDeviceInfo([cli.getSafeUserId()]); diff --git a/src/ScalarMessaging.ts b/src/ScalarMessaging.ts index cac5b561a0..82846b9abf 100644 --- a/src/ScalarMessaging.ts +++ b/src/ScalarMessaging.ts @@ -514,7 +514,7 @@ function getWidgets(event: MessageEvent, roomId: string | null): void { sendResponse(event, widgetStateEvents); } -function getRoomEncState(event: MessageEvent, roomId: string): void { +async function getRoomEncState(event: MessageEvent, roomId: string): Promise { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t("widget|error_need_to_be_logged_in")); @@ -525,7 +525,7 @@ function getRoomEncState(event: MessageEvent, roomId: string): void { sendError(event, _t("scalar|error_room_unknown")); return; } - const roomIsEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId); + const roomIsEncrypted = Boolean(await client.getCrypto()?.isEncryptionEnabledInRoom(roomId)); sendResponse(event, roomIsEncrypted); } diff --git a/src/Searching.ts b/src/Searching.ts index 85483eb23c..252d4378ad 100644 --- a/src/Searching.ts +++ b/src/Searching.ts @@ -596,7 +596,7 @@ async function combinedPagination( return result; } -function eventIndexSearch( +async function eventIndexSearch( client: MatrixClient, term: string, roomId?: string, @@ -605,7 +605,7 @@ function eventIndexSearch( let searchPromise: Promise; if (roomId !== undefined) { - if (client.isRoomEncrypted(roomId)) { + if (await client.getCrypto()?.isEncryptionEnabledInRoom(roomId)) { // The search is for a single encrypted room, use our local // search method. searchPromise = localSearchProcess(client, term, roomId); diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index cf8d40acc8..f97dff786f 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -186,6 +186,15 @@ export async function withSecretStorageKeyCache(func: () => Promise): Prom } } +export interface AccessSecretStorageOpts { + /** Reset secret storage even if it's already set up. */ + forceReset?: boolean; + /** Create new cross-signing keys. Only applicable if `forceReset` is `true`. */ + resetCrossSigning?: boolean; + /** The cached account password, if available. */ + accountPassword?: string; +} + /** * This helper should be used whenever you need to access secret storage. It * ensures that secret storage (and also cross-signing since they each depend on @@ -205,14 +214,17 @@ export async function withSecretStorageKeyCache(func: () => Promise): Prom * * @param {Function} [func] An operation to perform once secret storage has been * bootstrapped. Optional. - * @param {bool} [forceReset] Reset secret storage even if it's already set up + * @param [opts] The options to use when accessing secret storage. */ -export async function accessSecretStorage(func = async (): Promise => {}, forceReset = false): Promise { - await withSecretStorageKeyCache(() => doAccessSecretStorage(func, forceReset)); +export async function accessSecretStorage( + func = async (): Promise => {}, + opts: AccessSecretStorageOpts = {}, +): Promise { + await withSecretStorageKeyCache(() => doAccessSecretStorage(func, opts)); } /** Helper for {@link #accessSecretStorage} */ -async function doAccessSecretStorage(func: () => Promise, forceReset: boolean): Promise { +async function doAccessSecretStorage(func: () => Promise, opts: AccessSecretStorageOpts): Promise { try { const cli = MatrixClientPeg.safeGet(); const crypto = cli.getCrypto(); @@ -221,7 +233,7 @@ async function doAccessSecretStorage(func: () => Promise, forceReset: bool } let createNew = false; - if (forceReset) { + if (opts.forceReset) { logger.debug("accessSecretStorage: resetting 4S"); createNew = true; } else if (!(await cli.secretStorage.hasKey())) { @@ -234,9 +246,7 @@ async function doAccessSecretStorage(func: () => Promise, forceReset: bool // passphrase creation. const { finished } = Modal.createDialog( lazy(() => import("./async-components/views/dialogs/security/CreateSecretStorageDialog")), - { - forceReset, - }, + opts, undefined, /* priority = */ false, /* static = */ true, diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts index e3ca77f988..11872d059e 100644 --- a/src/SlidingSyncManager.ts +++ b/src/SlidingSyncManager.ts @@ -229,7 +229,7 @@ export class SlidingSyncManager { subscriptions.delete(roomId); } const room = this.client?.getRoom(roomId); - let shouldLazyLoad = !this.client?.isRoomEncrypted(roomId); + let shouldLazyLoad = !(await this.client?.getCrypto()?.isEncryptionEnabledInRoom(roomId)); if (!room) { // default to safety: request all state if we can't work it out. This can happen if you // refresh the app whilst viewing a room: we call setRoomVisible before we know anything diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 1e87b5b826..1258bde2ca 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -58,6 +58,7 @@ interface IProps { hasCancel?: boolean; accountPassword?: string; forceReset?: boolean; + resetCrossSigning?: boolean; onFinished(ok?: boolean): void; } @@ -91,6 +92,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent = { hasCancel: true, forceReset: false, + resetCrossSigning: false, }; private recoveryKey?: GeneratedSecretStorageKey; private recoveryKeyNode = createRef(); @@ -270,7 +272,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent => { const cli = MatrixClientPeg.safeGet(); const crypto = cli.getCrypto()!; - const { forceReset } = this.props; + const { forceReset, resetCrossSigning } = this.props; let backupInfo; // First, unless we know we want to do a reset, we see if there is an existing key backup @@ -292,12 +294,28 @@ export default class CreateSecretStorageDialog extends React.PureComponent this.recoveryKey!, - setupNewKeyBackup: true, setupNewSecretStorage: true, }); + if (resetCrossSigning) { + logger.log("Resetting cross signing"); + await crypto.bootstrapCrossSigning({ + authUploadDeviceSigningKeys: this.doBootstrapUIAuth, + setupNewCrossSigning: true, + }); + } + logger.log("Resetting key backup"); + await crypto.resetKeyBackup(); } else { // For password authentication users after 2020-09, this cross-signing // step will be a no-op since it is now setup during registration or login diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index afd444c952..e51dd96647 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -427,7 +427,7 @@ export default class MatrixChat extends React.PureComponent { } } else if ( (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) && - !shouldSkipSetupEncryption(cli) + !(await shouldSkipSetupEncryption(cli)) ) { // if cross-signing is not yet set up, do so now if possible. this.setStateForNewView({ view: Views.E2E_SETUP }); diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index e0a9318e9a..0cc6fbd04d 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 { } } - private async checkServerLiveliness(serverConfig: ValidatedServerConfig): Promise { + public componentWillUnmount(): void { + this.unmounted = true; + } + + private async checkServerLiveliness(serverConfig: ValidatedServerConfig): Promise { try { await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(serverConfig.hsUrl, serverConfig.isUrl); + if (this.unmounted) return false; this.setState({ serverIsAlive: true, }); } catch (e: any) { + if (this.unmounted) return false; const { serverIsAlive, serverDeadError } = AutoDiscoveryUtils.authComponentStateForError( e, "forgot_password", @@ -124,7 +131,9 @@ export default class ForgotPassword extends React.Component { serverIsAlive, errorText: serverDeadError, }); + return serverIsAlive; } + return true; } private async onPhaseEmailInputSubmit(): Promise { @@ -292,10 +301,10 @@ export default class ForgotPassword extends React.Component { }); // Refresh the server errors. Just in case the server came back online of went offline. - await this.checkServerLiveliness(this.props.serverConfig); + const serverIsAlive = await this.checkServerLiveliness(this.props.serverConfig); // Server error - if (!this.state.serverIsAlive) return; + if (!serverIsAlive) return; switch (this.state.phase) { case Phase.EnterEmail: diff --git a/src/components/views/auth/AuthFooter.tsx b/src/components/views/auth/AuthFooter.tsx index 8d27a04c83..a792896a88 100644 --- a/src/components/views/auth/AuthFooter.tsx +++ b/src/components/views/auth/AuthFooter.tsx @@ -16,7 +16,7 @@ const AuthFooter = (): ReactElement => { const brandingConfig = SdkConfig.getObject("branding"); const links = brandingConfig?.get("auth_footer_links") ?? [ { text: "Blog", url: "https://element.io/blog" }, - { text: "Twitter", url: "https://twitter.com/element_hq" }, + { text: "Mastodon", url: "https://mastodon.matrix.org/@Element" }, { text: "GitHub", url: "https://github.com/element-hq/element-web" }, ]; diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index 990efdda71..4d36d01623 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -256,7 +256,7 @@ export default class CreateRoomDialog extends React.Component { }); public render(): React.ReactNode { - const isVideoRoom = this.props.type === RoomType.ElementVideo; + const isVideoRoom = this.props.type === RoomType.ElementVideo || this.props.type === RoomType.UnstableCall; let aliasField: JSX.Element | undefined; if (this.state.joinRule === JoinRule.Public) { diff --git a/src/components/views/dialogs/ReportEventDialog.tsx b/src/components/views/dialogs/ReportEventDialog.tsx index 75b9977dc4..3234c2be35 100644 --- a/src/components/views/dialogs/ReportEventDialog.tsx +++ b/src/components/views/dialogs/ReportEventDialog.tsx @@ -43,6 +43,10 @@ interface IState { // If we know it, the nature of the abuse, as specified by MSC3215. nature?: ExtendedNature; ignoreUserToo: boolean; // if true, user will be ignored/blocked on submit + /* + * Whether the room is encrypted. + */ + isRoomEncrypted: boolean; } const MODERATED_BY_STATE_EVENT_TYPE = [ @@ -188,9 +192,20 @@ export default class ReportEventDialog extends React.Component { // If specified, the nature of the abuse, as specified by MSC3215. nature: undefined, ignoreUserToo: false, // default false, for now. Could easily be argued as default true + isRoomEncrypted: false, // async, will be set later }; } + public componentDidMount = async (): Promise => { + const crypto = MatrixClientPeg.safeGet().getCrypto(); + const roomId = this.props.mxEvent.getRoomId(); + if (!crypto || !roomId) return; + + this.setState({ + isRoomEncrypted: await crypto.isEncryptionEnabledInRoom(roomId), + }); + }; + private onIgnoreUserTooChanged = (newVal: boolean): void => { this.setState({ ignoreUserToo: newVal }); }; @@ -319,7 +334,6 @@ export default class ReportEventDialog extends React.Component { if (this.moderation) { // Display report-to-moderator dialog. // We let the user pick a nature. - const client = MatrixClientPeg.safeGet(); const homeServerName = SdkConfig.get("validated_server_config")!.hsName; let subtitle: string; switch (this.state.nature) { @@ -336,7 +350,7 @@ export default class ReportEventDialog extends React.Component { subtitle = _t("report_content|nature_spam"); break; case NonStandardValue.Admin: - if (client.isRoomEncrypted(this.props.mxEvent.getRoomId()!)) { + if (this.state.isRoomEncrypted) { subtitle = _t("report_content|nature_nonstandard_admin_encrypted", { homeserver: homeServerName, }); diff --git a/src/components/views/dialogs/devtools/RoomNotifications.tsx b/src/components/views/dialogs/devtools/RoomNotifications.tsx index c54e695006..1bcff78487 100644 --- a/src/components/views/dialogs/devtools/RoomNotifications.tsx +++ b/src/components/views/dialogs/devtools/RoomNotifications.tsx @@ -17,6 +17,7 @@ import { determineUnreadState } from "../../../../RoomNotifs"; import { humanReadableNotificationLevel } from "../../../../stores/notifications/NotificationLevel"; import { doesRoomOrThreadHaveUnreadMessages } from "../../../../Unread"; import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool"; +import { useIsEncrypted } from "../../../../hooks/useIsEncrypted.ts"; function UserReadUpTo({ target }: { target: ReadReceipt }): JSX.Element { const cli = useContext(MatrixClientContext); @@ -59,6 +60,7 @@ function UserReadUpTo({ target }: { target: ReadReceipt }): JSX.Elemen export default function RoomNotifications({ onBack }: IDevtoolsProps): JSX.Element { const { room } = useContext(DevtoolsContext); const cli = useContext(MatrixClientContext); + const isRoomEncrypted = useIsEncrypted(cli, room); const { level, count } = determineUnreadState(room, undefined, false); const [notificationState] = useNotificationState(room); @@ -93,9 +95,7 @@ export default function RoomNotifications({ onBack }: IDevtoolsProps): JSX.Eleme
  • {_t( - cli.isRoomEncrypted(room.roomId!) - ? _td("devtools|room_encrypted") - : _td("devtools|room_not_encrypted"), + isRoomEncrypted ? _td("devtools|room_encrypted") : _td("devtools|room_not_encrypted"), {}, { strong: (sub) => {sub}, diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index 7361e3982d..d9c97261dd 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -19,7 +19,6 @@ import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton"; import { _t } from "../../../../languageHandler"; import { accessSecretStorage } from "../../../../SecurityManager"; import Modal from "../../../../Modal"; -import InteractiveAuthDialog from "../InteractiveAuthDialog"; import DialogButtons from "../../elements/DialogButtons"; import BaseDialog from "../BaseDialog"; import { chromeFileInputFix } from "../../../../utils/BrowserWorkarounds"; @@ -226,28 +225,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent => { - // Now reset cross-signing so everything Just Works™ again. - const cli = MatrixClientPeg.safeGet(); - await cli.getCrypto()?.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async (makeRequest): Promise => { - const { finished } = Modal.createDialog(InteractiveAuthDialog, { - title: _t("encryption|bootstrap_title"), - matrixClient: cli, - makeRequest, - }); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Cross-signing key upload auth canceled"); - } - }, - setupNewCrossSigning: true, - }); - - // Now we can indicate that the user is done pressing buttons, finally. - // Upstream flows will detect the new secret storage, key backup, etc and use it. - this.props.onFinished({}); - }, true); + await accessSecretStorage( + async (): Promise => { + // Now we can indicate that the user is done pressing buttons, finally. + // Upstream flows will detect the new secret storage, key backup, etc and use it. + this.props.onFinished({}); + }, + { forceReset: true, resetCrossSigning: true }, + ); } catch (e) { logger.error(e); this.props.onFinished(false); diff --git a/src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx b/src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx index af84feb848..ec85e72ac9 100644 --- a/src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx +++ b/src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx @@ -109,7 +109,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { this.props.onFinished(false); - accessSecretStorage(async (): Promise => {}, /* forceReset = */ true); + accessSecretStorage(async (): Promise => {}, { forceReset: true }); }; /** diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index 8bbca5b309..cf5a239814 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -17,6 +17,7 @@ import { useTimeout } from "../../../hooks/useTimeout"; import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import AccessibleButton from "./AccessibleButton"; import Spinner from "./Spinner"; +import { getFileChanged } from "../settings/AvatarSetting.tsx"; export const AVATAR_SIZE = "52px"; @@ -72,11 +73,12 @@ const MiniAvatarUploader: React.FC = ({ onClick?.(ev); }} onChange={async (ev): Promise => { - if (!ev.target.files?.length) return; setBusy(true); - const file = ev.target.files[0]; - const { content_uri: uri } = await cli.uploadContent(file); - await setAvatarUrl(uri); + const file = getFileChanged(ev); + if (file) { + const { content_uri: uri } = await cli.uploadContent(file); + await setAvatarUrl(uri); + } setBusy(false); }} accept="image/*" diff --git a/src/components/views/location/MapFallback.tsx b/src/components/views/location/MapFallback.tsx index cb1a579764..101a5d8066 100644 --- a/src/components/views/location/MapFallback.tsx +++ b/src/components/views/location/MapFallback.tsx @@ -8,8 +8,8 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import classNames from "classnames"; +import LocationMarkerIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid"; -import { Icon as LocationMarkerIcon } from "../../../../res/img/element-icons/location.svg"; import { Icon as MapFallbackImage } from "../../../../res/img/location/map.svg"; import Spinner from "../elements/Spinner"; diff --git a/src/components/views/location/Marker.tsx b/src/components/views/location/Marker.tsx index 93a5c28831..58e1ce30fb 100644 --- a/src/components/views/location/Marker.tsx +++ b/src/components/views/location/Marker.tsx @@ -9,8 +9,8 @@ Please see LICENSE files in the repository root for full details. import React, { ReactNode, useState } from "react"; import classNames from "classnames"; import { RoomMember } from "matrix-js-sdk/src/matrix"; +import LocationIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid"; -import { Icon as LocationIcon } from "../../../../res/img/element-icons/location.svg"; import { getUserNameColorClass } from "../../../utils/FormattingUtils"; import MemberAvatar from "../avatars/MemberAvatar"; diff --git a/src/components/views/location/ShareType.tsx b/src/components/views/location/ShareType.tsx index f580d4638d..0aa31b7bd4 100644 --- a/src/components/views/location/ShareType.tsx +++ b/src/components/views/location/ShareType.tsx @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { HTMLAttributes, useContext } from "react"; +import LocationIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { _t } from "../../../languageHandler"; @@ -14,7 +15,6 @@ import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import BaseAvatar from "../avatars/BaseAvatar"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import Heading from "../typography/Heading"; -import { Icon as LocationIcon } from "../../../../res/img/element-icons/location.svg"; import { LocationShareType } from "./shareLocation"; import StyledLiveBeaconIcon from "../beacon/StyledLiveBeaconIcon"; diff --git a/src/components/views/messages/EncryptionEvent.tsx b/src/components/views/messages/EncryptionEvent.tsx index e721662cb5..bc6680d300 100644 --- a/src/components/views/messages/EncryptionEvent.tsx +++ b/src/components/views/messages/EncryptionEvent.tsx @@ -6,18 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { forwardRef, useContext } from "react"; +import React, { forwardRef } from "react"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types"; import { _t } from "../../../languageHandler"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import EventTileBubble from "./EventTileBubble"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import DMRoomMap from "../../../utils/DMRoomMap"; import { objectHasDiff } from "../../../utils/objects"; import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../utils/crypto"; +import { useIsEncrypted } from "../../../hooks/useIsEncrypted.ts"; interface IProps { mxEvent: MatrixEvent; @@ -25,9 +25,9 @@ interface IProps { } const EncryptionEvent = forwardRef(({ mxEvent, timestamp }, ref) => { - const cli = useContext(MatrixClientContext); + const cli = useMatrixClientContext(); const roomId = mxEvent.getRoomId()!; - const isRoomEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId); + const isRoomEncrypted = useIsEncrypted(cli, cli.getRoom(roomId) || undefined); const prevContent = mxEvent.getPrevContent() as RoomEncryptionEventContent; const content = mxEvent.getContent(); diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index 39e09f4e29..59392c46ce 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -396,7 +396,10 @@ function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote { export function allVotes(voteRelations: Relations): Array { if (voteRelations) { - return voteRelations.getRelations().map(userResponseFromPollResponseEvent); + return voteRelations + .getRelations() + .filter((e) => !e.isRedacted()) + .map(userResponseFromPollResponseEvent); } else { return []; } diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 0776c51437..fdd0200429 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -28,11 +28,11 @@ import { ReplyIcon, DeleteIcon, RestartIcon, + ThreadsIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { Icon as EditIcon } from "../../../../res/img/element-icons/room/message-bar/edit.svg"; import { Icon as EmojiIcon } from "../../../../res/img/element-icons/room/message-bar/emoji.svg"; -import { Icon as ThreadIcon } from "../../../../res/img/element-icons/message/thread.svg"; import { Icon as ExpandMessageIcon } from "../../../../res/img/element-icons/expand-message.svg"; import { Icon as CollapseMessageIcon } from "../../../../res/img/element-icons/collapse-message.svg"; import type { Relations } from "matrix-js-sdk/src/matrix"; @@ -243,7 +243,7 @@ const ReplyInThreadButton: React.FC = ({ mxEvent }) => { onContextMenu={onClick} placement="left" > - + ); }; diff --git a/src/components/views/room_settings/UrlPreviewSettings.tsx b/src/components/views/room_settings/UrlPreviewSettings.tsx index babe7cf140..4ca63fd4a0 100644 --- a/src/components/views/room_settings/UrlPreviewSettings.tsx +++ b/src/components/views/room_settings/UrlPreviewSettings.tsx @@ -9,107 +9,144 @@ 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, { ReactNode } from "react"; +import React, { ReactNode, JSX } from "react"; import { Room } from "matrix-js-sdk/src/matrix"; +import { InlineSpinner } from "@vector-im/compound-web"; -import { _t, _td } from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; import dis from "../../../dispatcher/dispatcher"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { Action } from "../../../dispatcher/actions"; import { SettingLevel } from "../../../settings/SettingLevel"; import SettingsFlag from "../elements/SettingsFlag"; import SettingsFieldset from "../settings/SettingsFieldset"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import { useIsEncrypted } from "../../../hooks/useIsEncrypted.ts"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx"; +import { useSettingValueAt } from "../../../hooks/useSettings.ts"; -interface IProps { +/** + * The URL preview settings for a room + */ +interface UrlPreviewSettingsProps { + /** + * The room. + */ room: Room; } -export default class UrlPreviewSettings extends React.Component { - private onClickUserSettings = (e: ButtonEvent): void => { - e.preventDefault(); - e.stopPropagation(); - dis.fire(Action.ViewUserSettings); - }; +export function UrlPreviewSettings({ room }: UrlPreviewSettingsProps): JSX.Element { + const { roomId } = room; + const matrixClient = useMatrixClientContext(); + const isEncrypted = useIsEncrypted(matrixClient, room); + const isLoading = isEncrypted === null; - public render(): ReactNode { - const roomId = this.props.room.roomId; - const isEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId); - - let previewsForAccount: ReactNode | undefined; - let previewsForRoom: ReactNode | undefined; - - if (!isEncrypted) { - // Only show account setting state and room state setting state in non-e2ee rooms where they apply - const accountEnabled = SettingsStore.getValueAt(SettingLevel.ACCOUNT, "urlPreviewsEnabled"); - if (accountEnabled) { - previewsForAccount = _t( - "room_settings|general|user_url_previews_default_on", - {}, - { - a: (sub) => ( - - {sub} - - ), - }, - ); - } else { - previewsForAccount = _t( - "room_settings|general|user_url_previews_default_off", - {}, - { - a: (sub) => ( - - {sub} - - ), - }, - ); - } - - if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, SettingLevel.ROOM)) { - previewsForRoom = ( + return ( + } + > + {isLoading ? ( + + ) : ( + <> + - ); - } else { - let str = _td("room_settings|general|default_url_previews_on"); - if (!SettingsStore.getValueAt(SettingLevel.ROOM, "urlPreviewsEnabled", roomId, /*explicit=*/ true)) { - str = _td("room_settings|general|default_url_previews_off"); - } - previewsForRoom =
    {_t(str)}
    ; - } - } else { - previewsForAccount = _t("room_settings|general|url_preview_encryption_warning"); - } + + )} +
    + ); +} - const previewsForRoomAccount = // in an e2ee room we use a special key to enforce per-room opt-in - ( - - ); +/** + * Click handler for the user settings link + * @param e + */ +function onClickUserSettings(e: ButtonEvent): void { + e.preventDefault(); + e.stopPropagation(); + dis.fire(Action.ViewUserSettings); +} - const description = ( - <> -

    {_t("room_settings|general|url_preview_explainer")}

    -

    {previewsForAccount}

    - +/** + * The description for the URL preview settings + */ +interface DescriptionProps { + /** + * Whether the room is encrypted + */ + isEncrypted: boolean; +} + +function Description({ isEncrypted }: DescriptionProps): JSX.Element { + const urlPreviewsEnabled = useSettingValueAt(SettingLevel.ACCOUNT, "urlPreviewsEnabled"); + + let previewsForAccount: ReactNode | undefined; + if (isEncrypted) { + previewsForAccount = _t("room_settings|general|url_preview_encryption_warning"); + } else { + const button = { + a: (sub: string) => ( + + {sub} + + ), + }; + + previewsForAccount = urlPreviewsEnabled + ? _t("room_settings|general|user_url_previews_default_on", {}, button) + : _t("room_settings|general|user_url_previews_default_off", {}, button); + } + + return ( + <> +

    {_t("room_settings|general|url_preview_explainer")}

    +

    {previewsForAccount}

    + + ); +} + +/** + * The description for the URL preview settings + */ +interface PreviewsForRoomProps { + /** + * Whether the room is encrypted + */ + isEncrypted: boolean; + /** + * The room ID + */ + roomId: string; +} + +function PreviewsForRoom({ isEncrypted, roomId }: PreviewsForRoomProps): JSX.Element | null { + const urlPreviewsEnabled = useSettingValueAt( + SettingLevel.ACCOUNT, + "urlPreviewsEnabled", + roomId, + /*explicit=*/ true, + ); + if (isEncrypted) return null; + + let previewsForRoom: ReactNode; + if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, SettingLevel.ROOM)) { + previewsForRoom = ( + ); - - return ( - - {previewsForRoom} - {previewsForRoomAccount} - + } else { + previewsForRoom = ( +
    + {urlPreviewsEnabled + ? _t("room_settings|general|default_url_previews_on") + : _t("room_settings|general|default_url_previews_off")} +
    ); } + + return previewsForRoom; } diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index af33fb2d9e..423b5c6272 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -10,6 +10,7 @@ import React, { createRef, KeyboardEvent, RefObject } from "react"; import classNames from "classnames"; import { flatMap } from "lodash"; import { Room } from "matrix-js-sdk/src/matrix"; +import { defer } from "matrix-js-sdk/src/utils"; import Autocompleter, { ICompletion, ISelectionRange, IProviderCompletions } from "../../../autocomplete/Autocompleter"; import SettingsStore from "../../../settings/SettingsStore"; @@ -127,18 +128,21 @@ export default class Autocomplete extends React.PureComponent { } private async processQuery(query: string, selection: ISelectionRange): Promise { - return this.autocompleter - ?.getCompletions(query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES) - .then((completions) => { - // Only ever process the completions for the most recent query being processed - if (query !== this.queryRequested) { - return; - } - this.processCompletions(completions); - }); + if (!this.autocompleter) return; + const completions = await this.autocompleter.getCompletions( + query, + selection, + this.state.forceComplete, + MAX_PROVIDER_MATCHES, + ); + // Only ever process the completions for the most recent query being processed + if (query !== this.queryRequested) { + return; + } + await this.processCompletions(completions); } - private processCompletions(completions: IProviderCompletions[]): void { + private async processCompletions(completions: IProviderCompletions[]): Promise { const completionList = flatMap(completions, (provider) => provider.completions); // Reset selection when completion list becomes empty. @@ -169,14 +173,19 @@ export default class Autocomplete extends React.PureComponent { } } - this.setState({ - completions, - completionList, - selectionOffset, - hide, - // Force complete is turned off each time since we can't edit the query in that case - forceComplete: false, - }); + const deferred = defer(); + this.setState( + { + completions, + completionList, + selectionOffset, + hide, + // Force complete is turned off each time since we can't edit the query in that case + forceComplete: false, + }, + deferred.resolve, + ); + await deferred.promise; } public hasSelection(): boolean { diff --git a/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx b/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx index 603bc9953e..ad1a6ce9a6 100644 --- a/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx +++ b/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx @@ -7,11 +7,11 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; +import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link"; import { RovingAccessibleButton } from "../../../../accessibility/RovingTabIndex"; import Toolbar from "../../../../accessibility/Toolbar"; import { _t } from "../../../../languageHandler"; -import { Icon as LinkIcon } from "../../../../../res/img/element-icons/link.svg"; import { Icon as ViewInRoomIcon } from "../../../../../res/img/element-icons/view-in-room.svg"; import { ButtonEvent } from "../../elements/AccessibleButton"; diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 62029f46c3..27189000d1 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -30,6 +30,7 @@ import E2EIcon from "./E2EIcon"; import SettingsStore from "../../../settings/SettingsStore"; import { aboveLeftOf, MenuProps } from "../../structures/ContextMenu"; import ReplyPreview from "./ReplyPreview"; +import { UserIdentityWarning } from "./UserIdentityWarning"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore"; @@ -669,6 +670,7 @@ export class MessageComposer extends React.Component {
    + { + const verificationStatus = await crypto.getUserVerificationStatus(userId); + return verificationStatus.needsUserApproval; +} + +/** + * Whether the component is uninitialised, is in the process of initialising, or + * has completed initialising. + */ +enum InitialisationStatus { + Uninitialised, + Initialising, + Completed, +} + +/** + * Displays a banner warning when there is an issue with a user's identity. + * + * Warns when an unverified user's identity has changed, and gives the user a + * button to acknowledge the change. + */ +export const UserIdentityWarning: React.FC = ({ room }) => { + const cli = useMatrixClientContext(); + const crypto = cli.getCrypto(); + + // The current room member that we are prompting the user to approve. + // `undefined` means we are not currently showing a prompt. + const [currentPrompt, setCurrentPrompt] = useState(undefined); + + // Whether or not we've already initialised the component by loading the + // room membership. + const initialisedRef = useRef(InitialisationStatus.Uninitialised); + // Which room members need their identity approved. + const membersNeedingApprovalRef = useRef>(new Map()); + // For each user, we assign a sequence number to each verification status + // that we get, or fetch. + // + // Since fetching a verification status is asynchronous, we could get an + // update in the middle of fetching the verification status, which could + // mean that the status that we fetched is out of date. So if the current + // sequence number is not the same as the sequence number when we started + // the fetch, then we drop our fetched result, under the assumption that the + // update that we received is the most up-to-date version. If it is in fact + // not the most up-to-date version, then we should be receiving a new update + // soon with the newer value, so it will fix itself in the end. + // + // We also assign a sequence number when the user leaves the room, in order + // to prevent prompting about a user who leaves while we are fetching their + // verification status. + const verificationStatusSequencesRef = useRef>(new Map()); + const incrementVerificationStatusSequence = (userId: string): number => { + const verificationStatusSequences = verificationStatusSequencesRef.current; + const value = verificationStatusSequences.get(userId); + const newValue = value === undefined ? 1 : value + 1; + verificationStatusSequences.set(userId, newValue); + return newValue; + }; + + // Update the current prompt. Select a new user if needed, or hide the + // warning if we don't have anyone to warn about. + const updateCurrentPrompt = useCallback((): undefined => { + const membersNeedingApproval = membersNeedingApprovalRef.current; + // We have to do this in a callback to `setCurrentPrompt` + // because this function could have been called after an + // `await`, and the `currentPrompt` that this function would + // have may be outdated. + setCurrentPrompt((currentPrompt) => { + // If we're already displaying a warning, and that user still needs + // approval, continue showing that user. + if (currentPrompt && membersNeedingApproval.has(currentPrompt.userId)) return currentPrompt; + + if (membersNeedingApproval.size === 0) { + return undefined; + } + + // We pick the user with the smallest user ID. + const keys = Array.from(membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b)); + const selection = membersNeedingApproval.get(keys[0]!); + return selection; + }); + }, []); + + // Add a user to the membersNeedingApproval map, and update the current + // prompt if necessary. The user will only be added if they are actually a + // member of the room. If they are not a member, this function will do + // nothing. + const addMemberNeedingApproval = useCallback( + (userId: string, member?: RoomMember): void => { + if (userId === cli.getUserId()) { + // We always skip our own user, because we can't pin our own identity. + return; + } + member = member ?? room.getMember(userId) ?? undefined; + if (!member) return; + + membersNeedingApprovalRef.current.set(userId, member); + // We only select the prompt if we are done initialising, + // because we will select the prompt after we're done + // initialising, and we want to start by displaying a warning + // for the user with the smallest ID. + if (initialisedRef.current === InitialisationStatus.Completed) { + updateCurrentPrompt(); + } + }, + [cli, room, updateCurrentPrompt], + ); + + // For each user in the list check if their identity needs approval, and if + // so, add them to the membersNeedingApproval map and update the prompt if + // needed. + const addMembersWhoNeedApproval = useCallback( + async (members: RoomMember[]): Promise => { + const verificationStatusSequences = verificationStatusSequencesRef.current; + + const promises: Promise[] = []; + + for (const member of members) { + const userId = member.userId; + const sequenceNum = incrementVerificationStatusSequence(userId); + promises.push( + userNeedsApproval(crypto!, userId).then((needsApproval) => { + if (needsApproval) { + // Only actually update the list if we have the most + // recent value. + if (verificationStatusSequences.get(userId) === sequenceNum) { + addMemberNeedingApproval(userId, member); + } + } + }), + ); + } + + await Promise.all(promises); + }, + [crypto, addMemberNeedingApproval], + ); + + // Remove a user from the membersNeedingApproval map, and update the current + // prompt if necessary. + const removeMemberNeedingApproval = useCallback( + (userId: string): void => { + membersNeedingApprovalRef.current.delete(userId); + updateCurrentPrompt(); + }, + [updateCurrentPrompt], + ); + + // Initialise the component. Get the room members, check which ones need + // their identity approved, and pick one to display. + const loadMembers = useCallback(async (): Promise => { + if (!crypto || initialisedRef.current !== InitialisationStatus.Uninitialised) { + return; + } + // If encryption is not enabled in the room, we don't need to do + // anything. If encryption gets enabled later, we will retry, via + // onRoomStateEvent. + if (!(await crypto.isEncryptionEnabledInRoom(room.roomId))) { + return; + } + initialisedRef.current = InitialisationStatus.Initialising; + + const members = await room.getEncryptionTargetMembers(); + await addMembersWhoNeedApproval(members); + + updateCurrentPrompt(); + initialisedRef.current = InitialisationStatus.Completed; + }, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]); + + loadMembers().catch((e) => { + logger.error("Error initialising UserIdentityWarning:", e); + }); + + // When a user's verification status changes, we check if they need to be + // added/removed from the set of members needing approval. + const onUserVerificationStatusChanged = useCallback( + (userId: string, verificationStatus: UserVerificationStatus): void => { + // If we haven't started initialising, that means that we're in a + // room where we don't need to display any warnings. + if (initialisedRef.current === InitialisationStatus.Uninitialised) { + return; + } + + incrementVerificationStatusSequence(userId); + + if (verificationStatus.needsUserApproval) { + addMemberNeedingApproval(userId); + } else { + removeMemberNeedingApproval(userId); + } + }, + [addMemberNeedingApproval, removeMemberNeedingApproval], + ); + useTypedEventEmitter(cli, CryptoEvent.UserTrustStatusChanged, onUserVerificationStatusChanged); + + // We watch for encryption events (since we only display warnings in + // encrypted rooms), and for membership changes (since we only display + // warnings for users in the room). + const onRoomStateEvent = useCallback( + async (event: MatrixEvent): Promise => { + if (!crypto || event.getRoomId() !== room.roomId) { + return; + } + + const eventType = event.getType(); + if (eventType === EventType.RoomEncryption && event.getStateKey() === "") { + // Room is now encrypted, so we can initialise the component. + return loadMembers().catch((e) => { + logger.error("Error initialising UserIdentityWarning:", e); + }); + } else if (eventType !== EventType.RoomMember) { + return; + } + + // We're processing an m.room.member event + + if (initialisedRef.current === InitialisationStatus.Uninitialised) { + return; + } + + const userId = event.getStateKey(); + + if (!userId) return; + + if ( + event.getContent().membership === KnownMembership.Join || + (event.getContent().membership === KnownMembership.Invite && room.shouldEncryptForInvitedMembers()) + ) { + // Someone's membership changed and we will now encrypt to them. If + // their identity needs approval, show a warning. + const member = room.getMember(userId); + if (member) { + await addMembersWhoNeedApproval([member]).catch((e) => { + logger.error("Error adding member in UserIdentityWarning:", e); + }); + } + } else { + // Someone's membership changed and we no longer encrypt to them. + // If we're showing a warning about them, we don't need to any more. + removeMemberNeedingApproval(userId); + incrementVerificationStatusSequence(userId); + } + }, + [crypto, room, addMembersWhoNeedApproval, removeMemberNeedingApproval, loadMembers], + ); + useTypedEventEmitter(cli, RoomStateEvent.Events, onRoomStateEvent); + + if (!crypto || !currentPrompt) return null; + + const confirmIdentity = async (): Promise => { + await crypto.pinCurrentUserIdentity(currentPrompt.userId); + }; + + return ( +
    + +
    + + + {currentPrompt.rawDisplayName === currentPrompt.userId + ? _t( + "encryption|pinned_identity_changed_no_displayname", + { userId: currentPrompt.userId }, + { + a: substituteATag, + b: substituteBTag, + }, + ) + : _t( + "encryption|pinned_identity_changed", + { displayName: currentPrompt.rawDisplayName, userId: currentPrompt.userId }, + { + a: substituteATag, + b: substituteBTag, + }, + )} + + +
    +
    + ); +}; + +function substituteATag(sub: string): React.ReactNode { + return ( + + {sub} + + ); +} + +function substituteBTag(sub: string): React.ReactNode { + return {sub}; +} diff --git a/src/components/views/settings/AvatarSetting.tsx b/src/components/views/settings/AvatarSetting.tsx index eaeabc641b..b6ce541590 100644 --- a/src/components/views/settings/AvatarSetting.tsx +++ b/src/components/views/settings/AvatarSetting.tsx @@ -19,6 +19,8 @@ import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import { useId } from "../../../utils/useId"; import AccessibleButton from "../elements/AccessibleButton"; import BaseAvatar from "../avatars/BaseAvatar"; +import Modal from "../../../Modal.tsx"; +import ErrorDialog from "../dialogs/ErrorDialog.tsx"; interface MenuProps { trigger: ReactNode; @@ -103,6 +105,18 @@ interface IProps { placeholderName: string; } +export function getFileChanged(e: React.ChangeEvent): File | null { + if (!e.target.files?.length) return null; + const file = e.target.files[0]; + if (file.type.startsWith("image/")) return file; + + Modal.createDialog(ErrorDialog, { + title: _t("upload_failed_title"), + description: _t("upload_file|not_image"), + }); + return null; +} + /** * Component for setting or removing an avatar on something (eg. a user or a room) */ @@ -139,7 +153,10 @@ const AvatarSetting: React.FC = ({ const onFileChanged = useCallback( (e: React.ChangeEvent) => { - if (e.target.files) onChange?.(e.target.files[0]); + const file = getFileChanged(e); + if (file) { + onChange?.(file); + } }, [onChange], ); diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index db165eb115..06c67c7d0b 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -209,7 +209,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { private resetSecretStorage = async (): Promise => { this.setState({ error: false }); try { - await accessSecretStorage(async (): Promise => {}, /* forceReset = */ true); + await accessSecretStorage(async (): Promise => {}, { forceReset: true }); } catch (e) { logger.error("Error resetting secret storage", e); if (this.unmounted) return; diff --git a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx index 066dc45366..048fe5df9d 100644 --- a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx @@ -16,12 +16,12 @@ import dis from "../../../../../dispatcher/dispatcher"; import MatrixClientContext from "../../../../../contexts/MatrixClientContext"; import SettingsStore from "../../../../../settings/SettingsStore"; import { UIFeature } from "../../../../../settings/UIFeature"; -import UrlPreviewSettings from "../../../room_settings/UrlPreviewSettings"; import AliasSettings from "../../../room_settings/AliasSettings"; import PosthogTrackers from "../../../../../PosthogTrackers"; import { SettingsSubsection } from "../../shared/SettingsSubsection"; import SettingsTab from "../SettingsTab"; import { SettingsSection } from "../../shared/SettingsSection"; +import { UrlPreviewSettings } from "../../../room_settings/UrlPreviewSettings"; interface IProps { room: Room; diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index ec8a2b8718..8261bfd3eb 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -127,12 +127,30 @@ interface IProps { room: Room; } -export default class RolesRoomSettingsTab extends React.Component { +interface RolesRoomSettingsTabState { + isRoomEncrypted: boolean; + isReady: boolean; +} + +export default class RolesRoomSettingsTab extends React.Component { public static contextType = MatrixClientContext; public declare context: React.ContextType; - public componentDidMount(): void { + public constructor(props: IProps) { + super(props); + this.state = { + isReady: false, + isRoomEncrypted: false, + }; + } + + public async componentDidMount(): Promise { this.context.on(RoomStateEvent.Update, this.onRoomStateUpdate); + this.setState({ + isRoomEncrypted: + (await this.context.getCrypto()?.isEncryptionEnabledInRoom(this.props.room.roomId)) || false, + isReady: true, + }); } public componentWillUnmount(): void { @@ -416,7 +434,7 @@ export default class RolesRoomSettingsTab extends React.Component { .filter(Boolean); // hide the power level selector for enabling E2EE if it the room is already encrypted - if (client.isRoomEncrypted(this.props.room.roomId)) { + if (this.state.isRoomEncrypted) { delete eventsLevels[EventType.RoomEncryption]; } @@ -458,17 +476,19 @@ export default class RolesRoomSettingsTab extends React.Component { {canChangeLevels && } {mutedUsersSection} {bannedUsersSection} - - {powerSelectors} - {eventPowerSelectors} - + {this.state.isReady && ( + + {powerSelectors} + {eventPowerSelectors} + + )} ); diff --git a/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx index 41ebbaf669..0971ece699 100644 --- a/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SidebarUserSettingsTab.tsx @@ -7,10 +7,13 @@ Please see LICENSE files in the repository root for full details. */ import React, { ChangeEvent, useMemo } from "react"; -import { VideoCallSolidIcon, HomeSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { + VideoCallSolidIcon, + HomeSolidIcon, + UserProfileSolidIcon, + FavouriteSolidIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; -import { Icon as FavoriteIcon } from "../../../../../../res/img/element-icons/roomlist/favorite.svg"; -import { Icon as MembersIcon } from "../../../../../../res/img/element-icons/room/members.svg"; import { Icon as HashCircleIcon } from "../../../../../../res/img/element-icons/roomlist/hash-circle.svg"; import { _t } from "../../../../../languageHandler"; import SettingsStore from "../../../../../settings/SettingsStore"; @@ -112,7 +115,7 @@ const SidebarUserSettingsTab: React.FC = () => { className="mx_SidebarUserSettingsTab_checkbox" > - + {_t("common|favourites")} @@ -126,7 +129,7 @@ const SidebarUserSettingsTab: React.FC = () => { className="mx_SidebarUserSettingsTab_checkbox" > - + {_t("common|people")} diff --git a/src/components/views/spaces/QuickSettingsButton.tsx b/src/components/views/spaces/QuickSettingsButton.tsx index c21e0a71e2..161290fca8 100644 --- a/src/components/views/spaces/QuickSettingsButton.tsx +++ b/src/components/views/spaces/QuickSettingsButton.tsx @@ -8,7 +8,11 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import classNames from "classnames"; -import EllipsisIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; +import { + OverflowHorizontalIcon, + UserProfileSolidIcon, + FavouriteSolidIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../../../languageHandler"; import ContextMenu, { alwaysAboveRightOf, ChevronFace, useContextMenu } from "../../structures/ContextMenu"; @@ -22,8 +26,6 @@ import { Action } from "../../../dispatcher/actions"; import { UserTab } from "../dialogs/UserTab"; import QuickThemeSwitcher from "./QuickThemeSwitcher"; import { Icon as PinUprightIcon } from "../../../../res/img/element-icons/room/pin-upright.svg"; -import { Icon as MembersIcon } from "../../../../res/img/element-icons/room/members.svg"; -import { Icon as FavoriteIcon } from "../../../../res/img/element-icons/roomlist/favorite.svg"; import Modal from "../../../Modal"; import DevtoolsDialog from "../dialogs/DevtoolsDialog"; import { SdkContextClass } from "../../../contexts/SDKContext"; @@ -89,7 +91,7 @@ const QuickSettingsButton: React.FC<{ checked={!!favouritesEnabled} onChange={onMetaSpaceChangeFactory(MetaSpace.Favourites, "WebQuickSettingsPinToSidebarCheckbox")} > - + {_t("common|favourites")} - + {_t("common|people")} - + {_t("quick_settings|sidebar_settings")} diff --git a/src/hooks/useEncryptionStatus.ts b/src/hooks/useEncryptionStatus.ts index 30417f7821..686f68f25e 100644 --- a/src/hooks/useEncryptionStatus.ts +++ b/src/hooks/useEncryptionStatus.ts @@ -6,21 +6,40 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; -import { useEffect, useState } from "react"; +import { CryptoEvent, MatrixClient, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { useEffect, useMemo, useState } from "react"; +import { throttle } from "lodash"; import { E2EStatus, shieldStatusForRoom } from "../utils/ShieldUtils"; +import { useTypedEventEmitter } from "./useEventEmitter"; export function useEncryptionStatus(client: MatrixClient, room: Room): E2EStatus | null { const [e2eStatus, setE2eStatus] = useState(null); - useEffect(() => { - if (client.getCrypto()) { - shieldStatusForRoom(client, room).then((e2eStatus) => { - setE2eStatus(e2eStatus); - }); - } - }, [client, room]); + const updateEncryptionStatus = useMemo( + () => + throttle( + () => { + if (client.getCrypto()) { + shieldStatusForRoom(client, room).then((e2eStatus) => { + setE2eStatus(e2eStatus); + }); + } + }, + 250, + { leading: true, trailing: true }, + ), + [client, room], + ); + + useEffect(updateEncryptionStatus, [updateEncryptionStatus]); + + // shieldStatusForRoom depends on the room membership, each member's trust + // status for each member, and each member's devices, so we update the + // status whenever any of those changes. + useTypedEventEmitter(room, RoomStateEvent.Members, updateEncryptionStatus); + useTypedEventEmitter(client, CryptoEvent.UserTrustStatusChanged, updateEncryptionStatus); + useTypedEventEmitter(client, CryptoEvent.DevicesUpdated, updateEncryptionStatus); return e2eStatus; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4444152ab7..a9abfb51ee 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -905,6 +905,8 @@ "warning": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings." }, "not_supported": "", + "pinned_identity_changed": "%(displayName)s's (%(userId)s) identity appears to have changed. Learn more", + "pinned_identity_changed_no_displayname": "%(userId)s's identity appears to have changed. Learn more", "recovery_method_removed": { "description_1": "This session has detected that your Security Phrase and key for Secure Messages have been removed.", "description_2": "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.", @@ -3748,6 +3750,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/src/indexing/EventIndex.ts b/src/indexing/EventIndex.ts index ec3935cd68..fc1be4eba5 100644 --- a/src/indexing/EventIndex.ts +++ b/src/indexing/EventIndex.ts @@ -39,6 +39,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg"; import SettingsStore from "../settings/SettingsStore"; import { SettingLevel } from "../settings/SettingLevel"; import { ICrawlerCheckpoint, IEventAndProfile, IIndexStats, ILoadArgs, ISearchArgs } from "./BaseEventIndexManager"; +import { asyncFilter } from "../utils/arrays.ts"; // The time in ms that the crawler will wait loop iterations if there // have not been any checkpoints to consume in the last iteration. @@ -103,13 +104,11 @@ export default class EventIndex extends EventEmitter { const client = MatrixClientPeg.safeGet(); const rooms = client.getRooms(); - const isRoomEncrypted = (room: Room): boolean => { - return client.isRoomEncrypted(room.roomId); - }; - // We only care to crawl the encrypted rooms, non-encrypted // rooms can use the search provided by the homeserver. - const encryptedRooms = rooms.filter(isRoomEncrypted); + const encryptedRooms = await asyncFilter(rooms, async (room) => + Boolean(await client.getCrypto()?.isEncryptionEnabledInRoom(room.roomId)), + ); logger.log("EventIndex: Adding initial crawler checkpoints"); diff --git a/src/stores/MemberListStore.ts b/src/stores/MemberListStore.ts index 1455a4526f..e500dec84c 100644 --- a/src/stores/MemberListStore.ts +++ b/src/stores/MemberListStore.ts @@ -70,7 +70,7 @@ export class MemberListStore { return []; } - if (!this.isLazyLoadingEnabled(roomId) || this.loadedRooms.has(roomId)) { + if (this.loadedRooms.has(roomId) || !(await this.isLazyLoadingEnabled(roomId))) { // nice and easy, we must already have all the members so just return them. return this.loadMembersInRoom(room); } @@ -121,10 +121,10 @@ export class MemberListStore { * @param roomId The room to check if lazy loading is enabled * @returns True if enabled */ - private isLazyLoadingEnabled(roomId: string): boolean { + private async isLazyLoadingEnabled(roomId: string): Promise { if (SettingsStore.getValue("feature_sliding_sync")) { // only unencrypted rooms use lazy loading - return !this.stores.client!.isRoomEncrypted(roomId); + return !(await this.stores.client?.getCrypto()?.isEncryptionEnabledInRoom(roomId)); } return this.stores.client!.hasLazyLoadMembersEnabled(); } diff --git a/src/stores/SetupEncryptionStore.ts b/src/stores/SetupEncryptionStore.ts index 5422f68d7b..2fb9c6a9ca 100644 --- a/src/stores/SetupEncryptionStore.ts +++ b/src/stores/SetupEncryptionStore.ts @@ -19,9 +19,6 @@ import { Device, SecretStorage } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { AccessCancelledError, accessSecretStorage } from "../SecurityManager"; -import Modal from "../Modal"; -import InteractiveAuthDialog from "../components/views/dialogs/InteractiveAuthDialog"; -import { _t } from "../languageHandler"; import { SdkContextClass } from "../contexts/SDKContext"; import { asyncSome } from "../utils/arrays"; import { initialiseDehydration } from "../utils/device/dehydration"; @@ -230,42 +227,16 @@ export class SetupEncryptionStore extends EventEmitter { // secret storage key if they had one. Start by resetting // secret storage and setting up a new recovery key, then // create new cross-signing keys once that succeeds. - await accessSecretStorage(async (): Promise => { - const cli = MatrixClientPeg.safeGet(); - await cli.getCrypto()?.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async (makeRequest): Promise => { - const cachedPassword = SdkContextClass.instance.accountPasswordStore.getPassword(); - - if (cachedPassword) { - await makeRequest({ - type: "m.login.password", - identifier: { - type: "m.id.user", - user: cli.getSafeUserId(), - }, - user: cli.getSafeUserId(), - password: cachedPassword, - }); - return; - } - - const { finished } = Modal.createDialog(InteractiveAuthDialog, { - title: _t("encryption|bootstrap_title"), - matrixClient: cli, - makeRequest, - }); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Cross-signing key upload auth canceled"); - } - }, - setupNewCrossSigning: true, - }); - - await initialiseDehydration(true); - - this.phase = Phase.Finished; - }, true); + await accessSecretStorage( + async (): Promise => { + this.phase = Phase.Finished; + }, + { + forceReset: true, + resetCrossSigning: true, + accountPassword: SdkContextClass.instance.accountPasswordStore.getPassword(), + }, + ); } catch (e) { logger.error("Error resetting cross-signing", e); this.phase = Phase.Intro; diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 99c69b9891..da8157adce 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -328,6 +328,39 @@ 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; + } +} + +/** + * Async version of Array.filter. + * If one of the promises rejects, the whole operation will reject. + * @param values + * @param predicate + */ +export async function asyncFilter(values: Array, predicate: (value: T) => Promise): Promise> { + const results = await Promise.all(values.map(predicate)); + return values.filter((_, i) => results[i]); +} + export function filterBoolean(values: Array): T[] { return values.filter(Boolean) as T[]; } diff --git a/src/utils/crypto/shouldSkipSetupEncryption.ts b/src/utils/crypto/shouldSkipSetupEncryption.ts index 51d7a9303c..d4dbb27d1b 100644 --- a/src/utils/crypto/shouldSkipSetupEncryption.ts +++ b/src/utils/crypto/shouldSkipSetupEncryption.ts @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { shouldForceDisableEncryption } from "./shouldForceDisableEncryption"; +import { asyncSomeParallel } from "../arrays.ts"; /** * If encryption is force disabled AND the user is not in any encrypted rooms @@ -16,7 +17,13 @@ import { shouldForceDisableEncryption } from "./shouldForceDisableEncryption"; * @param client * @returns {boolean} true when we can skip settings up encryption */ -export const shouldSkipSetupEncryption = (client: MatrixClient): boolean => { +export const shouldSkipSetupEncryption = async (client: MatrixClient): Promise => { const isEncryptionForceDisabled = shouldForceDisableEncryption(client); - return isEncryptionForceDisabled && !client.getRooms().some((r) => client.isRoomEncrypted(r.roomId)); + const crypto = client.getCrypto(); + if (!crypto) return true; + + return ( + isEncryptionForceDisabled && + !(await asyncSomeParallel(client.getRooms(), ({ roomId }) => crypto.isEncryptionEnabledInRoom(roomId))) + ); }; diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index a131c3e55b..30d2948380 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -151,10 +151,7 @@ export async function setMarkedUnreadState(room: Room, client: MatrixClient, unr const currentState = getMarkedUnreadState(room); if (Boolean(currentState) !== unread) { - // Assuming MSC2867 passes FCP with no changes, we should update to start writing - // the flag to the stable prefix (or both) and then ultimately use only the - // stable prefix. - await client.setRoomAccountData(room.roomId, MARKED_UNREAD_TYPE_UNSTABLE, { unread }); + await client.setRoomAccountData(room.roomId, MARKED_UNREAD_TYPE_STABLE, { unread }); } } diff --git a/src/vector/init.tsx b/src/vector/init.tsx index 97b203cd5b..a3d5624cb4 100644 --- a/src/vector/init.tsx +++ b/src/vector/init.tsx @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import * as ReactDOM from "react-dom"; +import { createRoot } from "react-dom/client"; import React, { StrictMode } from "react"; import { logger } from "matrix-js-sdk/src/logger"; @@ -93,7 +93,9 @@ export async function loadApp(fragParams: {}): Promise { function setWindowMatrixChat(matrixChat: MatrixChat): void { window.matrixChat = matrixChat; } - ReactDOM.render(await module.loadApp(fragParams, setWindowMatrixChat), document.getElementById("matrixchat")); + const app = await module.loadApp(fragParams, setWindowMatrixChat); + const root = createRoot(document.getElementById("matrixchat")!); + root.render(app); } export async function showError(title: string, messages?: string[]): Promise { @@ -101,11 +103,11 @@ export async function showError(title: string, messages?: string[]): Promise , - document.getElementById("matrixchat"), ); } @@ -114,11 +116,11 @@ export async function showIncompatibleBrowser(onAccept: () => void): Promise , - document.getElementById("matrixchat"), ); } diff --git a/src/vector/platform/ElectronPlatform.tsx b/src/vector/platform/ElectronPlatform.tsx index d7ebd94bb2..ac6e7a7feb 100644 --- a/src/vector/platform/ElectronPlatform.tsx +++ b/src/vector/platform/ElectronPlatform.tsx @@ -474,10 +474,8 @@ export default class ElectronPlatform extends BasePlatform { const url = super.getOidcCallbackUrl(); url.protocol = "io.element.desktop"; // Trim the double slash into a single slash to comply with https://datatracker.ietf.org/doc/html/rfc8252#section-7.1 - // Chrome seems to have a strange issue where non-standard protocols prevent URL object mutations on pathname - // field, so we cannot mutate `pathname` reliably and instead have to rewrite the href manually. - if (url.pathname.startsWith("//")) { - url.href = url.href.replace(url.pathname, url.pathname.slice(1)); + if (url.href.startsWith(`${url.protocol}://`)) { + url.href = url.href.replace("://", ":/"); } return url; } diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index 0a5798d8a1..7842afbfe5 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -162,6 +162,7 @@ export const mockClientMethodsCrypto = (): Partial< getVersion: jest.fn().mockReturnValue("Version 0"), getOwnDeviceKeys: jest.fn().mockReturnValue(new Promise(() => {})), getCrossSigningKeyId: jest.fn(), + isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false), }), }); diff --git a/test/test-utils/jest-matrix-react.tsx b/test/test-utils/jest-matrix-react.tsx index 4fbb0dc77d..2aad5d45ff 100644 --- a/test/test-utils/jest-matrix-react.tsx +++ b/test/test-utils/jest-matrix-react.tsx @@ -27,7 +27,6 @@ const wrapWithTooltipProvider = (Wrapper: RenderOptions["wrapper"]) => { const customRender = (ui: ReactElement, options: RenderOptions = {}) => { return render(ui, { - legacyRoot: true, ...options, wrapper: wrapWithTooltipProvider(options?.wrapper) as RenderOptions["wrapper"], }) as ReturnType; diff --git a/test/test-utils/utilities.ts b/test/test-utils/utilities.ts index 29b25fda21..5285a840b2 100644 --- a/test/test-utils/utilities.ts +++ b/test/test-utils/utilities.ts @@ -197,7 +197,7 @@ export const clearAllModals = async (): Promise => { // Prevent modals from leaking and polluting other tests let keepClosingModals = true; while (keepClosingModals) { - keepClosingModals = Modal.closeCurrentModal(); + keepClosingModals = await act(() => Modal.closeCurrentModal()); // Then wait for the screen to update (probably React rerender and async/await). // Important for tests using Jest fake timers to not get into an infinite loop diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index 0862c6b385..906826e456 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -95,6 +95,7 @@ describe("DeviceListener", () => { }, }), getSessionBackupPrivateKey: jest.fn(), + isEncryptionEnabledInRoom: jest.fn(), } as unknown as Mocked; mockClient = getMockClientWithEventEmitter({ isGuest: jest.fn(), @@ -105,7 +106,6 @@ describe("DeviceListener", () => { isVersionSupported: jest.fn().mockResolvedValue(true), isInitialSyncComplete: jest.fn().mockReturnValue(true), waitForClientWellKnown: jest.fn(), - isRoomEncrypted: jest.fn(), getClientWellKnown: jest.fn(), getDeviceId: jest.fn().mockReturnValue(deviceId), setAccountData: jest.fn(), @@ -292,7 +292,7 @@ describe("DeviceListener", () => { mockCrypto!.isCrossSigningReady.mockResolvedValue(false); mockCrypto!.isSecretStorageReady.mockResolvedValue(false); mockClient!.getRooms.mockReturnValue(rooms); - mockClient!.isRoomEncrypted.mockReturnValue(true); + jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); }); it("hides setup encryption toast when cross signing and secret storage are ready", async () => { @@ -317,7 +317,7 @@ describe("DeviceListener", () => { }); it("does not show any toasts when no rooms are encrypted", async () => { - mockClient!.isRoomEncrypted.mockReturnValue(false); + jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); await createAndStart(); expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled(); diff --git a/test/unit-tests/SecurityManager-test.ts b/test/unit-tests/SecurityManager-test.ts index 63143d4644..574549d8b2 100644 --- a/test/unit-tests/SecurityManager-test.ts +++ b/test/unit-tests/SecurityManager-test.ts @@ -68,7 +68,7 @@ describe("SecurityManager", () => { stubClient(); const func = jest.fn(); - accessSecretStorage(func, true); + accessSecretStorage(func, { forceReset: true }); expect(spy).toHaveBeenCalledTimes(1); await expect(spy.mock.lastCall![0]).resolves.toEqual(expect.objectContaining({ __test: true })); diff --git a/test/unit-tests/accessibility/RovingTabIndex-test.tsx b/test/unit-tests/accessibility/RovingTabIndex-test.tsx index c814502732..520103bca1 100644 --- a/test/unit-tests/accessibility/RovingTabIndex-test.tsx +++ b/test/unit-tests/accessibility/RovingTabIndex-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { HTMLAttributes } from "react"; -import { render } from "jest-matrix-react"; +import { act, render } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { @@ -79,15 +79,15 @@ describe("RovingTabIndex", () => { checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); // focus on 2nd button and test it is the only active one - container.querySelectorAll("button")[2].focus(); + act(() => container.querySelectorAll("button")[2].focus()); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); // focus on 1st button and test it is the only active one - container.querySelectorAll("button")[1].focus(); + act(() => container.querySelectorAll("button")[1].focus()); checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); // check that the active button does not change even on an explicit blur event - container.querySelectorAll("button")[1].blur(); + act(() => container.querySelectorAll("button")[1].blur()); checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]); // update the children, it should remain on the same button @@ -162,7 +162,7 @@ describe("RovingTabIndex", () => { checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); // focus on 2nd button and test it is the only active one - container.querySelectorAll("button")[2].focus(); + act(() => container.querySelectorAll("button")[2].focus()); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); }); @@ -390,7 +390,7 @@ describe("RovingTabIndex", () => { , ); - container.querySelectorAll("button")[0].focus(); + act(() => container.querySelectorAll("button")[0].focus()); checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); await userEvent.keyboard("[ArrowDown]"); @@ -423,7 +423,7 @@ describe("RovingTabIndex", () => { , ); - container.querySelectorAll("button")[0].focus(); + act(() => container.querySelectorAll("button")[0].focus()); checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]); const button = container.querySelectorAll("button")[1]; diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index 16106ee0d2..5cee50ef29 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"; @@ -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), @@ -163,8 +162,11 @@ describe("", () => { }; let initPromise: Promise | undefined; let defaultProps: ComponentProps; - const getComponent = (props: Partial> = {}) => - render(); + const getComponent = (props: Partial> = {}) => { + // MatrixChat does many questionable things which bomb tests in modern React mode, + // we'll want to refactor and break up MatrixChat before turning off legacyRoot mode + return render(, { legacyRoot: true }); + }; // make test results readable filterConsole( @@ -202,7 +204,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 @@ -264,7 +266,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(); }); @@ -329,7 +331,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(() => { @@ -957,9 +959,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(); @@ -1011,6 +1015,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 +1063,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 () => { @@ -1126,7 +1133,9 @@ describe("", () => { 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(); }); }); }); @@ -1395,7 +1404,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/MessagePanel-test.tsx b/test/unit-tests/components/structures/MessagePanel-test.tsx index 037a57bb06..cf44716ba9 100644 --- a/test/unit-tests/components/structures/MessagePanel-test.tsx +++ b/test/unit-tests/components/structures/MessagePanel-test.tsx @@ -23,6 +23,7 @@ import { createTestClient, getMockClientWithEventEmitter, makeBeaconInfoEvent, + mockClientMethodsCrypto, mockClientMethodsEvents, mockClientMethodsUser, } from "../../../test-utils"; @@ -42,6 +43,7 @@ describe("MessagePanel", function () { const client = getMockClientWithEventEmitter({ ...mockClientMethodsUser(userId), ...mockClientMethodsEvents(), + ...mockClientMethodsCrypto(), getAccountData: jest.fn(), isUserIgnored: jest.fn().mockReturnValue(false), isRoomEncrypted: jest.fn().mockReturnValue(false), diff --git a/test/unit-tests/components/structures/PipContainer-test.tsx b/test/unit-tests/components/structures/PipContainer-test.tsx index d55905d4b4..446727c74e 100644 --- a/test/unit-tests/components/structures/PipContainer-test.tsx +++ b/test/unit-tests/components/structures/PipContainer-test.tsx @@ -81,9 +81,7 @@ describe("PipContainer", () => { let voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore; const actFlushPromises = async () => { - await act(async () => { - await flushPromises(); - }); + await flushPromises(); }; beforeEach(async () => { @@ -165,12 +163,12 @@ describe("PipContainer", () => { if (!(call instanceof MockedCall)) throw new Error("Failed to create call"); const widget = new Widget(call.widget); - WidgetStore.instance.addVirtualWidget(call.widget, room.roomId); - WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { - stop: () => {}, - } as unknown as ClientWidgetApi); - await act(async () => { + WidgetStore.instance.addVirtualWidget(call.widget, room.roomId); + WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { + stop: () => {}, + } as unknown as ClientWidgetApi); + await call.start(); ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true); }); @@ -178,9 +176,11 @@ describe("PipContainer", () => { await fn(call); cleanup(); - call.destroy(); - ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId); - WidgetStore.instance.removeVirtualWidget(widget.id, room.roomId); + act(() => { + call.destroy(); + ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId); + WidgetStore.instance.removeVirtualWidget(widget.id, room.roomId); + }); }; const withWidget = async (fn: () => Promise): Promise => { diff --git a/test/unit-tests/components/structures/RoomView-test.tsx b/test/unit-tests/components/structures/RoomView-test.tsx index 02bed8cf4f..b6fbd2e850 100644 --- a/test/unit-tests/components/structures/RoomView-test.tsx +++ b/test/unit-tests/components/structures/RoomView-test.tsx @@ -21,15 +21,24 @@ 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 { + 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, @@ -44,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"; @@ -63,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; @@ -72,6 +80,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,15 +106,17 @@ describe("RoomView", () => { stores.rightPanelStore.useUnitTestClient(cli); jest.spyOn(VoipUserMapper.sharedInstance(), "getVirtualRoomForRoom").mockResolvedValue(undefined); + crypto = cli.getCrypto()!; jest.spyOn(cli, "getCrypto").mockReturnValue(undefined); }); 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 = () => { @@ -117,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; @@ -164,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!; }; @@ -190,7 +207,7 @@ describe("RoomView", () => { }); describe("when there is an old room", () => { - let instance: _RoomView; + let instance: RoomView; let oldRoom: Room; beforeEach(async () => { @@ -214,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 () => { @@ -249,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); @@ -267,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); }); @@ -284,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); @@ -341,7 +360,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 +385,7 @@ describe("RoomView", () => { it("should match the snapshot", async () => { const { container } = await renderRoomView(); - expect(container).toMatchSnapshot(); + await waitFor(() => expect(container).toMatchSnapshot()); }); }); }); @@ -420,6 +445,194 @@ describe("RoomView", () => { }); }); + it("should show error view if failed to look up room alias", async () => { + const { asFragment, findByText } = await renderRoomView(false); + + act(() => + defaultDispatcher.dispatch({ + action: Action.ViewRoomError, + room_alias: "#addy:server", + room_id: null, + err: new MatrixError({ errcode: "M_NOT_FOUND" }), + }), + ); + await emitPromise(stores.roomViewStore, UPDATE_EVENT); + + await findByText("Are you sure you're at the right place?"); + expect(asFragment()).toMatchSnapshot(); + }); + + describe("knock rooms", () => { + const client = createTestClient(); + + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join"); + jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock); + jest.spyOn(defaultDispatcher, "dispatch"); + }); + + it("allows to request to join", async () => { + jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); + jest.spyOn(client, "knockRoom").mockResolvedValue({ room_id: room.roomId }); + + await mountRoomView(); + fireEvent.click(screen.getByRole("button", { name: "Request access" })); + await untilDispatch(Action.SubmitAskToJoin, defaultDispatcher); + + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: "submit_ask_to_join", + roomId: room.roomId, + opts: { reason: undefined }, + }); + }); + + it("allows to cancel a join request", async () => { + jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); + jest.spyOn(client, "leave").mockResolvedValue({}); + jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Knock); + + await mountRoomView(); + fireEvent.click(screen.getByRole("button", { name: "Cancel request" })); + await untilDispatch(Action.CancelAskToJoin, defaultDispatcher); + + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: "cancel_ask_to_join", + roomId: room.roomId, + }); + }); + }); + + it("should close search results when edit is clicked", async () => { + room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); + + const eventMapper = (obj: Partial) => new MatrixEvent(obj); + + const roomViewRef = createRef(); + const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef); + await waitFor(() => expect(roomViewRef.current).toBeTruthy()); + // @ts-ignore - triggering a search organically is a lot of work + act(() => + roomViewRef.current!.setState({ + search: { + searchId: 1, + roomId: room.roomId, + term: "search term", + scope: SearchScope.Room, + promise: Promise.resolve({ + results: [ + SearchResult.fromJson( + { + rank: 1, + result: { + content: { + body: "search term", + msgtype: "m.text", + }, + type: "m.room.message", + event_id: "$eventId", + sender: cli.getSafeUserId(), + origin_server_ts: 123456789, + room_id: room.roomId, + }, + context: { + events_before: [], + events_after: [], + profile_info: {}, + }, + }, + eventMapper, + ), + ], + highlights: [], + count: 1, + }), + inProgress: false, + count: 1, + }, + }), + ); + + await waitFor(() => { + expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); + }); + const prom = waitForElementToBeRemoved(() => container.querySelector(".mx_RoomView_searchResultsPanel")); + + await userEvent.hover(getByText("search term")); + await userEvent.click(await findByLabelText("Edit")); + + await prom; + }); + + it("should switch rooms when edit is clicked on a search result for a different room", async () => { + const room2 = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); + rooms.set(room2.roomId, room2); + + room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); + + const eventMapper = (obj: Partial) => new MatrixEvent(obj); + + const roomViewRef = createRef(); + const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef); + await waitFor(() => expect(roomViewRef.current).toBeTruthy()); + // @ts-ignore - triggering a search organically is a lot of work + act(() => + roomViewRef.current!.setState({ + search: { + searchId: 1, + roomId: room.roomId, + term: "search term", + scope: SearchScope.All, + promise: Promise.resolve({ + results: [ + SearchResult.fromJson( + { + rank: 1, + result: { + content: { + body: "search term", + msgtype: "m.text", + }, + type: "m.room.message", + event_id: "$eventId", + sender: cli.getSafeUserId(), + origin_server_ts: 123456789, + room_id: room2.roomId, + }, + context: { + events_before: [], + events_after: [], + profile_info: {}, + }, + }, + eventMapper, + ), + ], + highlights: [], + count: 1, + }), + inProgress: false, + count: 1, + }, + }), + ); + + await waitFor(() => { + expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); + }); + const prom = untilDispatch(Action.ViewRoom, defaultDispatcher); + + await userEvent.hover(getByText("search term")); + await userEvent.click(await findByLabelText("Edit")); + + await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId })); + }); + + it("fires Action.RoomLoaded", async () => { + jest.spyOn(defaultDispatcher, "dispatch"); + await mountRoomView(); + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.RoomLoaded }); + }); + describe("when there is a RoomView", () => { const widget1Id = "widget1"; const widget2Id = "widget2"; @@ -505,184 +718,4 @@ describe("RoomView", () => { }); }); }); - - it("should show error view if failed to look up room alias", async () => { - const { asFragment, findByText } = await renderRoomView(false); - - defaultDispatcher.dispatch({ - action: Action.ViewRoomError, - room_alias: "#addy:server", - room_id: null, - err: new MatrixError({ errcode: "M_NOT_FOUND" }), - }); - await emitPromise(stores.roomViewStore, UPDATE_EVENT); - - await findByText("Are you sure you're at the right place?"); - expect(asFragment()).toMatchSnapshot(); - }); - - describe("knock rooms", () => { - const client = createTestClient(); - - beforeEach(() => { - jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join"); - jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock); - jest.spyOn(defaultDispatcher, "dispatch"); - }); - - it("allows to request to join", async () => { - jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); - jest.spyOn(client, "knockRoom").mockResolvedValue({ room_id: room.roomId }); - - await mountRoomView(); - fireEvent.click(screen.getByRole("button", { name: "Request access" })); - await untilDispatch(Action.SubmitAskToJoin, defaultDispatcher); - - expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ - action: "submit_ask_to_join", - roomId: room.roomId, - opts: { reason: undefined }, - }); - }); - - it("allows to cancel a join request", async () => { - jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); - jest.spyOn(client, "leave").mockResolvedValue({}); - jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Knock); - - await mountRoomView(); - fireEvent.click(screen.getByRole("button", { name: "Cancel request" })); - await untilDispatch(Action.CancelAskToJoin, defaultDispatcher); - - expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ - action: "cancel_ask_to_join", - roomId: room.roomId, - }); - }); - }); - - it("should close search results when edit is clicked", async () => { - room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); - - const eventMapper = (obj: Partial) => new MatrixEvent(obj); - - const roomViewRef = createRef<_RoomView>(); - const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef); - // @ts-ignore - triggering a search organically is a lot of work - roomViewRef.current!.setState({ - search: { - searchId: 1, - roomId: room.roomId, - term: "search term", - scope: SearchScope.Room, - promise: Promise.resolve({ - results: [ - SearchResult.fromJson( - { - rank: 1, - result: { - content: { - body: "search term", - msgtype: "m.text", - }, - type: "m.room.message", - event_id: "$eventId", - sender: cli.getSafeUserId(), - origin_server_ts: 123456789, - room_id: room.roomId, - }, - context: { - events_before: [], - events_after: [], - profile_info: {}, - }, - }, - eventMapper, - ), - ], - highlights: [], - count: 1, - }), - inProgress: false, - count: 1, - }, - }); - - await waitFor(() => { - expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); - }); - const prom = waitForElementToBeRemoved(() => container.querySelector(".mx_RoomView_searchResultsPanel")); - - await userEvent.hover(getByText("search term")); - await userEvent.click(await findByLabelText("Edit")); - - await prom; - }); - - it("should switch rooms when edit is clicked on a search result for a different room", async () => { - const room2 = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); - rooms.set(room2.roomId, room2); - - room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); - - const eventMapper = (obj: Partial) => new MatrixEvent(obj); - - const roomViewRef = createRef<_RoomView>(); - const { container, getByText, findByLabelText } = await mountRoomView(roomViewRef); - // @ts-ignore - triggering a search organically is a lot of work - roomViewRef.current!.setState({ - search: { - searchId: 1, - roomId: room.roomId, - term: "search term", - scope: SearchScope.All, - promise: Promise.resolve({ - results: [ - SearchResult.fromJson( - { - rank: 1, - result: { - content: { - body: "search term", - msgtype: "m.text", - }, - type: "m.room.message", - event_id: "$eventId", - sender: cli.getSafeUserId(), - origin_server_ts: 123456789, - room_id: room2.roomId, - }, - context: { - events_before: [], - events_after: [], - profile_info: {}, - }, - }, - eventMapper, - ), - ], - highlights: [], - count: 1, - }), - inProgress: false, - count: 1, - }, - }); - - await waitFor(() => { - expect(container.querySelector(".mx_RoomView_searchResultsPanel")).toBeVisible(); - }); - const prom = untilDispatch(Action.ViewRoom, defaultDispatcher); - - await userEvent.hover(getByText("search term")); - await userEvent.click(await findByLabelText("Edit")); - - await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId })); - }); - - it("fires Action.RoomLoaded", async () => { - jest.spyOn(defaultDispatcher, "dispatch"); - await mountRoomView(); - expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.RoomLoaded }); - }); }); diff --git a/test/unit-tests/components/structures/ThreadPanel-test.tsx b/test/unit-tests/components/structures/ThreadPanel-test.tsx index 1b4d59d9af..c19127de25 100644 --- a/test/unit-tests/components/structures/ThreadPanel-test.tsx +++ b/test/unit-tests/components/structures/ThreadPanel-test.tsx @@ -215,34 +215,33 @@ describe("ThreadPanel", () => { myThreads!.addLiveEvent(mixedThread.rootEvent); myThreads!.addLiveEvent(ownThread.rootEvent); - let events: EventData[] = []; const renderResult = render(); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); await waitFor(() => { - events = findEvents(renderResult.container); - expect(findEvents(renderResult.container)).toHaveLength(3); + const events = findEvents(renderResult.container); + expect(events).toHaveLength(3); + expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); + expect(events[1]).toEqual(toEventData(mixedThread.rootEvent)); + expect(events[2]).toEqual(toEventData(ownThread.rootEvent)); }); - expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); - expect(events[1]).toEqual(toEventData(mixedThread.rootEvent)); - expect(events[2]).toEqual(toEventData(ownThread.rootEvent)); await waitFor(() => expect(renderResult.container.querySelector(".mx_ThreadPanel_dropdown")).toBeTruthy()); toggleThreadFilter(renderResult.container, ThreadFilterType.My); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); await waitFor(() => { - events = findEvents(renderResult.container); - expect(findEvents(renderResult.container)).toHaveLength(2); + const events = findEvents(renderResult.container); + expect(events).toHaveLength(2); + expect(events[0]).toEqual(toEventData(mixedThread.rootEvent)); + expect(events[1]).toEqual(toEventData(ownThread.rootEvent)); }); - expect(events[0]).toEqual(toEventData(mixedThread.rootEvent)); - expect(events[1]).toEqual(toEventData(ownThread.rootEvent)); toggleThreadFilter(renderResult.container, ThreadFilterType.All); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); await waitFor(() => { - events = findEvents(renderResult.container); - expect(findEvents(renderResult.container)).toHaveLength(3); + const events = findEvents(renderResult.container); + expect(events).toHaveLength(3); + expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); + expect(events[1]).toEqual(toEventData(mixedThread.rootEvent)); + expect(events[2]).toEqual(toEventData(ownThread.rootEvent)); }); - expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); - expect(events[1]).toEqual(toEventData(mixedThread.rootEvent)); - expect(events[2]).toEqual(toEventData(ownThread.rootEvent)); }); it("correctly filters Thread List with a single, unparticipated thread", async () => { @@ -261,28 +260,27 @@ describe("ThreadPanel", () => { const [allThreads] = room.threadsTimelineSets; allThreads!.addLiveEvent(otherThread.rootEvent); - let events: EventData[] = []; const renderResult = render(); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); await waitFor(() => { - events = findEvents(renderResult.container); - expect(findEvents(renderResult.container)).toHaveLength(1); + const events = findEvents(renderResult.container); + expect(events).toHaveLength(1); + expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); }); - expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); await waitFor(() => expect(renderResult.container.querySelector(".mx_ThreadPanel_dropdown")).toBeTruthy()); toggleThreadFilter(renderResult.container, ThreadFilterType.My); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); await waitFor(() => { - events = findEvents(renderResult.container); - expect(findEvents(renderResult.container)).toHaveLength(0); + const events = findEvents(renderResult.container); + expect(events).toHaveLength(0); }); toggleThreadFilter(renderResult.container, ThreadFilterType.All); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); await waitFor(() => { - events = findEvents(renderResult.container); - expect(findEvents(renderResult.container)).toHaveLength(1); + const events = findEvents(renderResult.container); + expect(events).toHaveLength(1); + expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); }); - expect(events[0]).toEqual(toEventData(otherThread.rootEvent)); }); }); }); diff --git a/test/unit-tests/components/structures/TimelinePanel-test.tsx b/test/unit-tests/components/structures/TimelinePanel-test.tsx index 4a66351779..2f85843000 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, cleanup } from "jest-matrix-react"; import { ReceiptType, EventTimelineSet, @@ -28,7 +28,7 @@ import { ThreadFilterType, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; -import React, { createRef } from "react"; +import React from "react"; import { Mocked, mocked } from "jest-mock"; import { forEachRight } from "lodash"; @@ -178,7 +178,7 @@ describe("TimelinePanel", () => { const roomId = "#room:example.com"; let room: Room; let timelineSet: EventTimelineSet; - let timelinePanel: TimelinePanel; + let timelinePanel: TimelinePanel | null = null; const ev1 = new MatrixEvent({ event_id: "ev1", @@ -197,17 +197,16 @@ describe("TimelinePanel", () => { }); const renderTimelinePanel = async (): Promise => { - const ref = createRef(); render( (timelinePanel = ref)} />, ); await flushPromises(); - timelinePanel = ref.current!; + await waitFor(() => expect(timelinePanel).toBeTruthy()); }; const setUpTimelineSet = (threadRoot?: MatrixEvent) => { @@ -232,8 +231,9 @@ describe("TimelinePanel", () => { room = new Room(roomId, client, userId, { pendingEventOrdering: PendingEventOrdering.Detached }); }); - afterEach(() => { + afterEach(async () => { TimelinePanel.roomReadMarkerTsMap = {}; + cleanup(); }); it("when there is no event, it should not send any receipt", async () => { @@ -258,7 +258,6 @@ describe("TimelinePanel", () => { await renderTimelinePanel(); timelineSet.addLiveEvent(ev1, {}); await flushPromises(); - // @ts-ignore await timelinePanel.sendReadReceipts(); // @ts-ignore Simulate user activity by calling updateReadMarker on the TimelinePanel. @@ -276,7 +275,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", () => { @@ -291,7 +290,7 @@ describe("TimelinePanel", () => { // setup, timelineSet is not actually the timelineSet of the room. await room.addLiveEvents([ev2], {}); room.addEphemeralEvents([newReceipt(ev2.getId()!, userId, 222, 200)]); - await timelinePanel.forgetReadMarker(); + await timelinePanel!.forgetReadMarker(); expect(client.setRoomReadMarkers).toHaveBeenCalledWith(roomId, ev2.getId()); }); }); @@ -315,7 +314,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 +325,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 +334,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 +361,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 +1021,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/__snapshots__/MatrixChat-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap index e074958144..94c2678388 100644 --- a/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap @@ -180,11 +180,11 @@ exports[` Multi-tab lockout waits for other tab to stop during sta Blog - Twitter + Mastodon with a soft-logged-out session should show the soft-logo Blog - Twitter + Mastodon ({ @@ -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 => { @@ -78,14 +68,7 @@ describe("", () => { afterEach(async () => { // clean up modals await clearAllModals(); - }); - - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); + cleanup(); }); describe("when starting a password reset flow", () => { @@ -128,13 +111,16 @@ 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(); }); }); describe("and submitting an unknown email", () => { beforeEach(async () => { + mocked(AutoDiscoveryUtils.validateServerConfigWithStaticUrls).mockResolvedValue(serverConfig); await typeIntoField("Email address", testEmail); mocked(client).requestPasswordEmailToken.mockRejectedValue({ errcode: "M_THREEPID_NOT_FOUND", @@ -142,8 +128,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 +142,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 +163,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 +200,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 +229,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 +269,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 +282,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 +348,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 +371,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/auth/__snapshots__/AuthFooter-test.tsx.snap b/test/unit-tests/components/views/auth/__snapshots__/AuthFooter-test.tsx.snap index f1321ece2a..e206df6e6d 100644 --- a/test/unit-tests/components/views/auth/__snapshots__/AuthFooter-test.tsx.snap +++ b/test/unit-tests/components/views/auth/__snapshots__/AuthFooter-test.tsx.snap @@ -14,11 +14,11 @@ exports[` should match snapshot 1`] = ` Blog - Twitter + Mastodon should match snapshot 1`] = ` Blog - Twitter + Mastodon 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/context_menus/RoomGeneralContextMenu-test.tsx b/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx index 10de3996e6..9fc32dda29 100644 --- a/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx +++ b/test/unit-tests/components/views/context_menus/RoomGeneralContextMenu-test.tsx @@ -150,7 +150,7 @@ describe("RoomGeneralContextMenu", () => { await sleep(0); - expect(mockClient.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", { + expect(mockClient.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "m.marked_unread", { unread: true, }); expect(onFinished).toHaveBeenCalled(); diff --git a/test/unit-tests/components/views/dialogs/AccessSecretStorageDialog-test.tsx b/test/unit-tests/components/views/dialogs/AccessSecretStorageDialog-test.tsx index 30e1151d53..f5b0b1e074 100644 --- a/test/unit-tests/components/views/dialogs/AccessSecretStorageDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/AccessSecretStorageDialog-test.tsx @@ -122,4 +122,34 @@ describe("AccessSecretStorageDialog", () => { expect(screen.getByPlaceholderText("Security Phrase")).toHaveFocus(); }); + + it("Can reset secret storage", async () => { + jest.spyOn(mockClient.secretStorage, "checkKey").mockResolvedValue(true); + + const onFinished = jest.fn(); + const checkPrivateKey = jest.fn().mockResolvedValue(true); + renderComponent({ onFinished, checkPrivateKey }); + + await userEvent.click(screen.getByText("Reset all"), { delay: null }); + + // It will prompt the user to confirm resetting + expect(screen.getByText("Reset everything")).toBeInTheDocument(); + await userEvent.click(screen.getByText("Reset"), { delay: null }); + + // Then it will prompt the user to create a key/passphrase + await screen.findByText("Set up Secure Backup"); + document.execCommand = jest.fn().mockReturnValue(true); + jest.spyOn(mockClient.getCrypto()!, "createRecoveryKeyFromPassphrase").mockResolvedValue({ + privateKey: new Uint8Array(), + encodedPrivateKey: securityKey, + }); + screen.getByRole("button", { name: "Continue" }).click(); + + await screen.findByText(/Save your Security Key/); + screen.getByRole("button", { name: "Copy" }).click(); + await screen.findByText("Copied!"); + screen.getByRole("button", { name: "Continue" }).click(); + + await screen.findByText("Secure Backup successful"); + }); }); diff --git a/test/unit-tests/components/views/dialogs/SpotlightDialog-test.tsx b/test/unit-tests/components/views/dialogs/SpotlightDialog-test.tsx index 5cc95b96ee..54d21e147b 100644 --- a/test/unit-tests/components/views/dialogs/SpotlightDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/SpotlightDialog-test.tsx @@ -239,7 +239,7 @@ describe("Spotlight Dialog", () => { }); it("should call getVisibleRooms with MSC3946 dynamic room predecessors", async () => { - render( null} />, { legacyRoot: false }); + render( null} />); jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); expect(mockedClient.getVisibleRooms).toHaveBeenCalledWith(true); diff --git a/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx b/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx index b9d0514148..9e792a48f3 100644 --- a/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx @@ -13,7 +13,7 @@ import { mocked, MockedObject } from "jest-mock"; import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { sleep } from "matrix-js-sdk/src/utils"; -import { filterConsole, stubClient } from "../../../../../test-utils"; +import { filterConsole, flushPromises, stubClient } from "../../../../../test-utils"; import CreateSecretStorageDialog from "../../../../../../src/async-components/views/dialogs/security/CreateSecretStorageDialog"; describe("CreateSecretStorageDialog", () => { @@ -97,4 +97,39 @@ 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"); + }); + + await flushPromises(); + 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/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx b/test/unit-tests/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx index b0ee3531e2..6e8837c50d 100644 --- a/test/unit-tests/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { screen, fireEvent, render, waitFor } from "jest-matrix-react"; +import { screen, fireEvent, render, waitFor, act } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { Crypto, IMegolmSessionData } from "matrix-js-sdk/src/matrix"; @@ -23,12 +23,12 @@ describe("ExportE2eKeysDialog", () => { expect(asFragment()).toMatchSnapshot(); }); - it("should have disabled submit button initially", () => { + it("should have disabled submit button initially", async () => { const cli = createTestClient(); const onFinished = jest.fn(); const { container } = render(); - fireEvent.click(container.querySelector("[type=submit]")!); - expect(screen.getByText("Enter passphrase")).toBeInTheDocument(); + await act(() => fireEvent.click(container.querySelector("[type=submit]")!)); + expect(screen.getByLabelText("Enter passphrase")).toBeInTheDocument(); }); it("should complain about weak passphrases", async () => { @@ -38,7 +38,7 @@ describe("ExportE2eKeysDialog", () => { const { container } = render(); const input = screen.getByLabelText("Enter passphrase"); await userEvent.type(input, "password"); - fireEvent.click(container.querySelector("[type=submit]")!); + await act(() => fireEvent.click(container.querySelector("[type=submit]")!)); await expect(screen.findByText("This is a top-10 common password")).resolves.toBeInTheDocument(); }); @@ -49,7 +49,7 @@ describe("ExportE2eKeysDialog", () => { const { container } = render(); await userEvent.type(screen.getByLabelText("Enter passphrase"), "ThisIsAMoreSecurePW123$$"); await userEvent.type(screen.getByLabelText("Confirm passphrase"), "ThisIsAMoreSecurePW124$$"); - fireEvent.click(container.querySelector("[type=submit]")!); + await act(() => fireEvent.click(container.querySelector("[type=submit]")!)); await expect(screen.findByText("Passphrases must match")).resolves.toBeInTheDocument(); }); @@ -74,7 +74,7 @@ describe("ExportE2eKeysDialog", () => { const { container } = render(); await userEvent.type(screen.getByLabelText("Enter passphrase"), passphrase); await userEvent.type(screen.getByLabelText("Confirm passphrase"), passphrase); - fireEvent.click(container.querySelector("[type=submit]")!); + await act(() => fireEvent.click(container.querySelector("[type=submit]")!)); // Then it exports keys and encrypts them await waitFor(() => expect(exportRoomKeysAsJson).toHaveBeenCalled()); diff --git a/test/unit-tests/components/views/elements/AppTile-test.tsx b/test/unit-tests/components/views/elements/AppTile-test.tsx index 95ce95d3f4..12363f56f0 100644 --- a/test/unit-tests/components/views/elements/AppTile-test.tsx +++ b/test/unit-tests/components/views/elements/AppTile-test.tsx @@ -10,7 +10,7 @@ import React from "react"; import { Room, MatrixClient } from "matrix-js-sdk/src/matrix"; import { ClientWidgetApi, IWidget, MatrixWidgetType } from "matrix-widget-api"; import { Optional } from "matrix-events-sdk"; -import { act, render, RenderResult } from "jest-matrix-react"; +import { act, render, RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { ApprovalOpts, @@ -29,7 +29,6 @@ import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext import SettingsStore from "../../../../../src/settings/SettingsStore"; import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases"; import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore"; -import { UPDATE_EVENT } from "../../../../../src/stores/AsyncStore"; import WidgetStore, { IApp } from "../../../../../src/stores/WidgetStore"; import ActiveWidgetStore from "../../../../../src/stores/ActiveWidgetStore"; import AppTile from "../../../../../src/components/views/elements/AppTile"; @@ -59,16 +58,6 @@ describe("AppTile", () => { let app1: IApp; let app2: IApp; - const waitForRps = (roomId: string) => - new Promise((resolve) => { - const update = () => { - if (RightPanelStore.instance.currentCardForRoom(roomId).phase !== RightPanelPhases.Widget) return; - RightPanelStore.instance.off(UPDATE_EVENT, update); - resolve(); - }; - RightPanelStore.instance.on(UPDATE_EVENT, update); - }); - beforeAll(async () => { stubClient(); cli = MatrixClientPeg.safeGet(); @@ -160,29 +149,28 @@ describe("AppTile", () => { /> , ); - // Wait for RPS room 1 updates to fire - const rpsUpdated = waitForRps("r1"); - dis.dispatch({ - action: Action.ViewRoom, - room_id: "r1", - }); - await rpsUpdated; + act(() => + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r1", + }), + ); - expect(renderResult.getByText("Example 1")).toBeInTheDocument(); + await expect(renderResult.findByText("Example 1")).resolves.toBeInTheDocument(); expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(true); - const { container, asFragment } = renderResult; - expect(container.getElementsByClassName("mx_Spinner").length).toBeTruthy(); + const { asFragment } = renderResult; expect(asFragment()).toMatchSnapshot(); - // We want to verify that as we change to room 2, we should close the // right panel and destroy the widget. // Switch to room 2 - dis.dispatch({ - action: Action.ViewRoom, - room_id: "r2", - }); + act(() => + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r2", + }), + ); renderResult.rerender( @@ -233,16 +221,17 @@ describe("AppTile", () => { /> , ); - // Wait for RPS room 1 updates to fire - const rpsUpdated1 = waitForRps("r1"); - dis.dispatch({ - action: Action.ViewRoom, - room_id: "r1", - }); - await rpsUpdated1; + act(() => + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r1", + }), + ); - expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(true); - expect(ActiveWidgetStore.instance.isLive("1", "r2")).toBe(false); + await waitFor(() => { + expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(true); + expect(ActiveWidgetStore.instance.isLive("1", "r2")).toBe(false); + }); jest.spyOn(SettingsStore, "getValue").mockImplementation((name, roomId) => { if (name === "RightPanel.phases") { @@ -263,13 +252,13 @@ describe("AppTile", () => { } return realGetValue(name, roomId); }); - // Wait for RPS room 2 updates to fire - const rpsUpdated2 = waitForRps("r2"); // Switch to room 2 - dis.dispatch({ - action: Action.ViewRoom, - room_id: "r2", - }); + act(() => + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r2", + }), + ); renderResult.rerender( { /> , ); - await rpsUpdated2; - expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(false); - expect(ActiveWidgetStore.instance.isLive("1", "r2")).toBe(true); + await waitFor(() => { + expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(false); + expect(ActiveWidgetStore.instance.isLive("1", "r2")).toBe(true); + }); }); it("preserves non-persisted widget on container move", async () => { @@ -345,7 +335,7 @@ describe("AppTile", () => { let renderResult: RenderResult; let moveToContainerSpy: jest.SpyInstance; - beforeEach(() => { + beforeEach(async () => { renderResult = render( @@ -353,12 +343,12 @@ describe("AppTile", () => { ); moveToContainerSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer"); + await waitForElementToBeRemoved(() => renderResult.queryByRole("progressbar")); }); it("should render", () => { - const { container, asFragment } = renderResult; + const { asFragment } = renderResult; - expect(container.querySelector(".mx_Spinner")).toBeFalsy(); // Assert that the spinner is gone expect(asFragment()).toMatchSnapshot(); // Take a snapshot of the pinned widget }); @@ -459,18 +449,19 @@ describe("AppTile", () => { describe("for a persistent app", () => { let renderResult: RenderResult; - beforeEach(() => { + beforeEach(async () => { renderResult = render( , ); + + await waitForElementToBeRemoved(() => renderResult.queryByRole("progressbar")); }); - it("should render", () => { - const { container, asFragment } = renderResult; + it("should render", async () => { + const { asFragment } = renderResult; - expect(container.querySelector(".mx_Spinner")).toBeFalsy(); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/test/unit-tests/components/views/elements/MiniAvatarUploader-test.tsx b/test/unit-tests/components/views/elements/MiniAvatarUploader-test.tsx new file mode 100644 index 0000000000..cf6ed6ae62 --- /dev/null +++ b/test/unit-tests/components/views/elements/MiniAvatarUploader-test.tsx @@ -0,0 +1,40 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { render } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; +import { mocked } from "jest-mock"; + +import MiniAvatarUploader from "../../../../../src/components/views/elements/MiniAvatarUploader.tsx"; +import { stubClient, withClientContextRenderOptions } from "../../../../test-utils"; + +const BASE64_GIF = "R0lGODlhAQABAAAAACw="; +const AVATAR_FILE = new File([Uint8Array.from(atob(BASE64_GIF), (c) => c.charCodeAt(0))], "avatar.gif", { + type: "image/gif", +}); + +describe("", () => { + it("calls setAvatarUrl when a file is uploaded", async () => { + const cli = stubClient(); + mocked(cli.uploadContent).mockResolvedValue({ content_uri: "mxc://example.com/1234" }); + + const setAvatarUrl = jest.fn(); + const user = userEvent.setup(); + + const { container, findByText } = render( + , + withClientContextRenderOptions(cli), + ); + + await findByText("Upload"); + await user.upload(container.querySelector("input")!, AVATAR_FILE); + + expect(cli.uploadContent).toHaveBeenCalledWith(AVATAR_FILE); + expect(setAvatarUrl).toHaveBeenCalledWith("mxc://example.com/1234"); + }); +}); diff --git a/test/unit-tests/components/views/elements/Pill-test.tsx b/test/unit-tests/components/views/elements/Pill-test.tsx index 24fb2ca5dd..716b4513ce 100644 --- a/test/unit-tests/components/views/elements/Pill-test.tsx +++ b/test/unit-tests/components/views/elements/Pill-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { act, render, RenderResult, screen } from "jest-matrix-react"; +import { render, RenderResult, screen } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { mocked, Mocked } from "jest-mock"; import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; @@ -214,9 +214,7 @@ describe("", () => { }); // wait for profile query via API - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(renderResult.asFragment()).toMatchSnapshot(); }); @@ -228,9 +226,7 @@ describe("", () => { }); // wait for profile query via API - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(renderResult.asFragment()).toMatchSnapshot(); }); diff --git a/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap b/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap index b3b5fc3b89..f039d94514 100644 --- a/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap +++ b/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap @@ -60,29 +60,9 @@ exports[`AppTile destroys non-persisted right panel widget on room change 1`] = id="1" >
    -
    -
    -
    - Loading… -
    -   -
    -
    -
    +
    diff --git a/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx b/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx index d069d663b8..e67334ca61 100644 --- a/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx +++ b/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { createRef } from "react"; -import { render, waitFor } from "jest-matrix-react"; +import { render, waitFor, act } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import EmojiPicker from "../../../../../src/components/views/emojipicker/EmojiPicker"; @@ -27,12 +27,12 @@ describe("EmojiPicker", function () { // Apply a filter and assert that the HTML has changed //@ts-ignore private access - ref.current!.onChangeFilter("test"); + act(() => ref.current!.onChangeFilter("test")); expect(beforeHtml).not.toEqual(container.innerHTML); // Clear the filter and assert that the HTML matches what it was before filtering //@ts-ignore private access - ref.current!.onChangeFilter(""); + act(() => ref.current!.onChangeFilter("")); await waitFor(() => expect(beforeHtml).toEqual(container.innerHTML)); }); @@ -40,7 +40,7 @@ describe("EmojiPicker", function () { const ep = new EmojiPicker({ onChoose: (str: string) => false, onFinished: jest.fn() }); //@ts-ignore private access - ep.onChangeFilter("heart"); + act(() => ep.onChangeFilter("heart")); //@ts-ignore private access expect(ep.memoizedDataByCategory["people"][0].shortcodes[0]).toEqual("heart"); diff --git a/test/unit-tests/components/views/location/LocationShareMenu-test.tsx b/test/unit-tests/components/views/location/LocationShareMenu-test.tsx index 672580e952..84c5e91ea0 100644 --- a/test/unit-tests/components/views/location/LocationShareMenu-test.tsx +++ b/test/unit-tests/components/views/location/LocationShareMenu-test.tsx @@ -139,7 +139,7 @@ describe("", () => { const [, onGeolocateCallback] = mocked(mockGeolocate.on).mock.calls.find(([event]) => event === "geolocate")!; // set the location - onGeolocateCallback(position); + act(() => onGeolocateCallback(position)); }; const setLocationClick = () => { @@ -151,7 +151,7 @@ describe("", () => { lngLat: { lng: position.coords.longitude, lat: position.coords.latitude }, } as unknown as maplibregl.MapMouseEvent; // set the location - onMapClickCallback(event); + act(() => onMapClickCallback(event)); }; const shareTypeLabels: Record = { diff --git a/test/unit-tests/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap b/test/unit-tests/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap index edd05cc260..36152bc0f4 100644 --- a/test/unit-tests/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap +++ b/test/unit-tests/components/views/location/__snapshots__/LocationViewDialog-test.tsx.snap @@ -13,9 +13,18 @@ exports[` renders map correctly 1`] = `
    -
    + fill="currentColor" + height="1em" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + +
    diff --git a/test/unit-tests/components/views/location/__snapshots__/Marker-test.tsx.snap b/test/unit-tests/components/views/location/__snapshots__/Marker-test.tsx.snap index e7fce5e5a2..635119d55c 100644 --- a/test/unit-tests/components/views/location/__snapshots__/Marker-test.tsx.snap +++ b/test/unit-tests/components/views/location/__snapshots__/Marker-test.tsx.snap @@ -9,9 +9,18 @@ exports[` renders with location icon when no room member 1`] = `
    -
    + fill="currentColor" + height="1em" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + +
    diff --git a/test/unit-tests/components/views/location/__snapshots__/SmartMarker-test.tsx.snap b/test/unit-tests/components/views/location/__snapshots__/SmartMarker-test.tsx.snap index 1e043c9db8..f2b3e4cc8b 100644 --- a/test/unit-tests/components/views/location/__snapshots__/SmartMarker-test.tsx.snap +++ b/test/unit-tests/components/views/location/__snapshots__/SmartMarker-test.tsx.snap @@ -9,9 +9,18 @@ exports[` creates a marker on mount 1`] = `
    -
    + fill="currentColor" + height="1em" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + +
    @@ -27,9 +36,18 @@ exports[` removes marker on unmount 1`] = `
    -
    + fill="currentColor" + height="1em" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + +
    diff --git a/test/unit-tests/components/views/messages/DateSeparator-test.tsx b/test/unit-tests/components/views/messages/DateSeparator-test.tsx index 0c953a1738..9b5b32cfca 100644 --- a/test/unit-tests/components/views/messages/DateSeparator-test.tsx +++ b/test/unit-tests/components/views/messages/DateSeparator-test.tsx @@ -264,10 +264,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 +282,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 3a78ef55e8..5788daebc0 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"; @@ -26,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", () => { @@ -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,37 +90,39 @@ 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); - checkTexts("Encryption enabled", "Messages in this chat will be end-to-end encrypted."); + it("should show the expected texts", async () => { + expect(client.getCrypto()!.isEncryptionEnabledInRoom).toHaveBeenCalledWith(roomId); + 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 2e64de7422..1a6cdee557 100644 --- a/test/unit-tests/components/views/messages/MPollBody-test.tsx +++ b/test/unit-tests/components/views/messages/MPollBody-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 { fireEvent, render, RenderResult, waitFor } from "jest-matrix-react"; +import { act, fireEvent, render, RenderResult, waitFor, waitForElementToBeRemoved } from "jest-matrix-react"; import { M_POLL_KIND_DISCLOSED, M_POLL_KIND_UNDISCLOSED, @@ -238,7 +238,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"); @@ -269,7 +269,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 - @@ -632,13 +632,15 @@ 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. Click here to see full results", - ); + 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. Click here to see full results", + ); + }); }); it("ignores votes that arrived after the first end poll event", async () => { @@ -945,12 +947,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 1de9361de9..70778178c1 100644 --- a/test/unit-tests/components/views/messages/MPollEndBody-test.tsx +++ b/test/unit-tests/components/views/messages/MPollEndBody-test.tsx @@ -121,12 +121,13 @@ describe("", () => { describe("when poll start event does not exist in current timeline", () => { it("fetches the related poll start event and displays a poll tile", async () => { await setupRoomWithEventsTimeline(pollEndEvent); - const { container, getByTestId, getByRole } = getComponent(); + const { container, getByTestId, getByRole, queryByRole } = getComponent(); // while fetching event, only icon is shown expect(container).toMatchSnapshot(); await waitFor(() => expect(getByRole("progressbar")).toBeInTheDocument()); + await waitFor(() => expect(queryByRole("progressbar")).not.toBeInTheDocument()); expect(mockClient.fetchRoomEvent).toHaveBeenCalledWith(roomId, pollStartEvent.getId()); diff --git a/test/unit-tests/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap b/test/unit-tests/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap index 5a61ada30f..7b919b5326 100644 --- a/test/unit-tests/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap +++ b/test/unit-tests/components/views/messages/__snapshots__/MLocationBody-test.tsx.snap @@ -49,9 +49,18 @@ exports[`MLocationBody without error renders map correctly 1`] =
    -
    + fill="currentColor" + height="1em" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + +
    diff --git a/test/unit-tests/components/views/polls/pollHistory/PollHistory-test.tsx b/test/unit-tests/components/views/polls/pollHistory/PollHistory-test.tsx index 96aeffb03c..1e0f0a658c 100644 --- a/test/unit-tests/components/views/polls/pollHistory/PollHistory-test.tsx +++ b/test/unit-tests/components/views/polls/pollHistory/PollHistory-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { act, fireEvent, render } from "jest-matrix-react"; +import { fireEvent, render } from "jest-matrix-react"; import { Filter, EventTimeline, Room, MatrixEvent, M_POLL_START } from "matrix-js-sdk/src/matrix"; import { PollHistory } from "../../../../../../src/components/views/polls/pollHistory/PollHistory"; @@ -110,7 +110,7 @@ describe("", () => { expect(getByText("Loading polls")).toBeInTheDocument(); // flush filter creation request - await act(flushPromises); + await flushPromises(); expect(liveTimeline.getPaginationToken).toHaveBeenCalledWith(EventTimeline.BACKWARDS); expect(mockClient.paginateEventTimeline).toHaveBeenCalledWith(liveTimeline, { backwards: true }); @@ -140,7 +140,7 @@ describe("", () => { ); // flush filter creation request - await act(flushPromises); + await flushPromises(); // once per page expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(3); @@ -175,7 +175,7 @@ describe("", () => { it("renders a no polls message when there are no active polls in the room", async () => { const { getByText } = getComponent(); - await act(flushPromises); + await flushPromises(); expect(getByText("There are no active polls in this room")).toBeTruthy(); }); @@ -199,7 +199,7 @@ describe("", () => { .mockReturnValueOnce("test-pagination-token-3"); const { getByText } = getComponent(); - await act(flushPromises); + await flushPromises(); expect(mockClient.paginateEventTimeline).toHaveBeenCalledTimes(1); @@ -212,7 +212,7 @@ describe("", () => { // load more polls button still in UI, with loader expect(getByText("Load more polls")).toMatchSnapshot(); - await act(flushPromises); + await flushPromises(); // no more spinner expect(getByText("Load more polls")).toMatchSnapshot(); diff --git a/test/unit-tests/components/views/polls/pollHistory/__snapshots__/PollHistory-test.tsx.snap b/test/unit-tests/components/views/polls/pollHistory/__snapshots__/PollHistory-test.tsx.snap index b6bd7b72d8..360eeda061 100644 --- a/test/unit-tests/components/views/polls/pollHistory/__snapshots__/PollHistory-test.tsx.snap +++ b/test/unit-tests/components/views/polls/pollHistory/__snapshots__/PollHistory-test.tsx.snap @@ -91,7 +91,7 @@ exports[` renders a list of active polls when there are polls in tabindex="0" >
    @@ -116,7 +116,7 @@ exports[` renders a list of active polls when there are polls in tabindex="0" >
    diff --git a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx index dbf5645ca8..7e23679fc2 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, waitForElementToBeRemoved } 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"; @@ -439,7 +439,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 +459,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 +490,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 +538,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 +583,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 +618,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 +653,10 @@ describe("", () => { room: mockRoom, }); - await waitFor(() => expect(screen.getByRole("button", { name: "Deactivate user" })).toBeInTheDocument()); + await expect(screen.findByRole("button", { name: "Deactivate user" })).resolves.toBeInTheDocument(); + if (screen.queryAllByRole("progressbar").length) { + await waitForElementToBeRemoved(() => screen.queryAllByRole("progressbar")); + } expect(container).toMatchSnapshot(); }); }); @@ -666,7 +669,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 +680,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 +771,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 +779,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 +788,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 +801,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 +817,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 +827,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 +842,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 +854,7 @@ describe("", () => { it("with display name", async () => { const { container } = renderComponent(); - await act(flushPromises); + await flushPromises(); expect(container).toMatchSnapshot(); }); @@ -859,7 +862,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 +870,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 +1036,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 +1049,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/room_settings/UrlPreviewSettings-test.tsx b/test/unit-tests/components/views/room_settings/UrlPreviewSettings-test.tsx new file mode 100644 index 0000000000..cbd5410599 --- /dev/null +++ b/test/unit-tests/components/views/room_settings/UrlPreviewSettings-test.tsx @@ -0,0 +1,90 @@ +/* + * 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 { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { render, screen } from "jest-matrix-react"; +import { waitFor } from "@testing-library/dom"; + +import { createTestClient, mkStubRoom, withClientContextRenderOptions } from "../../../../test-utils"; +import { UrlPreviewSettings } from "../../../../../src/components/views/room_settings/UrlPreviewSettings.tsx"; +import SettingsStore from "../../../../../src/settings/SettingsStore.ts"; +import dis from "../../../../../src/dispatcher/dispatcher.ts"; +import { Action } from "../../../../../src/dispatcher/actions.ts"; + +describe("UrlPreviewSettings", () => { + let client: MatrixClient; + let room: Room; + + beforeEach(() => { + client = createTestClient(); + room = mkStubRoom("roomId", "room", client); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function renderComponent() { + return render(, withClientContextRenderOptions(client)); + } + + it("should display the correct preview when the setting is in a loading state", () => { + jest.spyOn(client, "getCrypto").mockReturnValue(undefined); + const { asFragment } = renderComponent(); + expect(screen.getByText("URL Previews")).toBeInTheDocument(); + + expect(asFragment()).toMatchSnapshot(); + }); + + it("should display the correct preview when the room is encrypted and the url preview is enabled", async () => { + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); + jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(true); + + const { asFragment } = renderComponent(); + await waitFor(() => { + expect( + screen.getByText( + "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.", + ), + ).toBeInTheDocument(); + }); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should display the correct preview when the room is unencrypted and the url preview is enabled", async () => { + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); + jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(true); + jest.spyOn(dis, "fire").mockReturnValue(undefined); + + const { asFragment } = renderComponent(); + await waitFor(() => { + expect(screen.getByRole("button", { name: "enabled" })).toBeInTheDocument(); + expect( + screen.getByText("URL previews are enabled by default for participants in this room."), + ).toBeInTheDocument(); + }); + expect(asFragment()).toMatchSnapshot(); + + screen.getByRole("button", { name: "enabled" }).click(); + expect(dis.fire).toHaveBeenCalledWith(Action.ViewUserSettings); + }); + + it("should display the correct preview when the room is unencrypted and the url preview is disabled", async () => { + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); + jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(false); + + const { asFragment } = renderComponent(); + await waitFor(() => { + expect(screen.getByRole("button", { name: "disabled" })).toBeInTheDocument(); + expect( + screen.getByText("URL previews are disabled by default for participants in this room."), + ).toBeInTheDocument(); + }); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/unit-tests/components/views/room_settings/__snapshots__/UrlPreviewSettings-test.tsx.snap b/test/unit-tests/components/views/room_settings/__snapshots__/UrlPreviewSettings-test.tsx.snap new file mode 100644 index 0000000000..d3a7eb4ad4 --- /dev/null +++ b/test/unit-tests/components/views/room_settings/__snapshots__/UrlPreviewSettings-test.tsx.snap @@ -0,0 +1,236 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UrlPreviewSettings should display the correct preview when the room is encrypted and the url preview is enabled 1`] = ` + +
    + + URL Previews + +
    +
    +

    + When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website. +

    +

    + In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room. +

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`UrlPreviewSettings should display the correct preview when the room is unencrypted and the url preview is disabled 1`] = ` + +
    + + URL Previews + +
    +
    +

    + When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website. +

    +

    + + You have + +

    + + URL previews by default. +

    +

    +
    +
    +
    + URL previews are disabled by default for participants in this room. +
    +
    + +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`UrlPreviewSettings should display the correct preview when the room is unencrypted and the url preview is enabled 1`] = ` + +
    + + URL Previews + +
    +
    +

    + When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website. +

    +

    + + You have + +

    + + URL previews by default. +

    +

    +
    +
    +
    + URL previews are enabled by default for participants in this room. +
    +
    + +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`UrlPreviewSettings should display the correct preview when the setting is in a loading state 1`] = ` + +
    + + URL Previews + +
    + + + +
    +
    +
    +`; diff --git a/test/unit-tests/components/views/rooms/EventTile-test.tsx b/test/unit-tests/components/views/rooms/EventTile-test.tsx index b2835d15c0..4cb2296760 100644 --- a/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -260,7 +260,7 @@ describe("EventTile", () => { } as EventEncryptionInfo); const { container } = getComponent(); - await act(flushPromises); + await flushPromises(); const eventTiles = container.getElementsByClassName("mx_EventTile"); expect(eventTiles).toHaveLength(1); @@ -285,7 +285,7 @@ describe("EventTile", () => { } as EventEncryptionInfo); const { container } = getComponent(); - await act(flushPromises); + await flushPromises(); const eventTiles = container.getElementsByClassName("mx_EventTile"); expect(eventTiles).toHaveLength(1); @@ -314,7 +314,7 @@ describe("EventTile", () => { } as EventEncryptionInfo); const { container } = getComponent(); - await act(flushPromises); + await flushPromises(); const e2eIcons = container.getElementsByClassName("mx_EventTile_e2eIcon"); expect(e2eIcons).toHaveLength(1); @@ -346,7 +346,7 @@ describe("EventTile", () => { await mxEvent.attemptDecryption(mockCrypto); const { container } = getComponent(); - await act(flushPromises); + await flushPromises(); const eventTiles = container.getElementsByClassName("mx_EventTile"); expect(eventTiles).toHaveLength(1); @@ -400,7 +400,7 @@ describe("EventTile", () => { const roomContext = getRoomContext(room, {}); const { container, rerender } = render(); - await act(flushPromises); + await flushPromises(); const eventTiles = container.getElementsByClassName("mx_EventTile"); expect(eventTiles).toHaveLength(1); @@ -451,7 +451,7 @@ describe("EventTile", () => { const roomContext = getRoomContext(room, {}); const { container, rerender } = render(); - await act(flushPromises); + await flushPromises(); const eventTiles = container.getElementsByClassName("mx_EventTile"); expect(eventTiles).toHaveLength(1); diff --git a/test/unit-tests/components/views/rooms/EventTile/__snapshots__/EventTileThreadToolbar-test.tsx.snap b/test/unit-tests/components/views/rooms/EventTile/__snapshots__/EventTileThreadToolbar-test.tsx.snap index 133531b447..4597bd83bc 100644 --- a/test/unit-tests/components/views/rooms/EventTile/__snapshots__/EventTileThreadToolbar-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/EventTile/__snapshots__/EventTileThreadToolbar-test.tsx.snap @@ -22,7 +22,17 @@ exports[`EventTileThreadToolbar renders 1`] = ` role="button" tabindex="-1" > -
    + + +
    diff --git a/test/unit-tests/components/views/rooms/MemberList-test.tsx b/test/unit-tests/components/views/rooms/MemberList-test.tsx index 3e17f7ce86..34c37d2ba5 100644 --- a/test/unit-tests/components/views/rooms/MemberList-test.tsx +++ b/test/unit-tests/components/views/rooms/MemberList-test.tsx @@ -8,7 +8,16 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { act, fireEvent, render, RenderResult, screen, waitFor, waitForElementToBeRemoved } from "jest-matrix-react"; +import { + act, + fireEvent, + render, + RenderResult, + screen, + waitFor, + waitForElementToBeRemoved, + cleanup, +} from "jest-matrix-react"; import { Room, MatrixClient, RoomState, RoomMember, User, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { mocked, MockedObject } from "jest-mock"; @@ -361,6 +370,7 @@ describe("MemberList", () => { afterEach(() => { jest.restoreAllMocks(); + cleanup(); }); const renderComponent = () => { @@ -397,21 +407,22 @@ describe("MemberList", () => { jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join); jest.spyOn(room, "canInvite").mockReturnValue(false); - renderComponent(); - await flushPromises(); + const { findByLabelText } = renderComponent(); // button rendered but disabled - expect(screen.getByText("Invite to this room")).toHaveAttribute("aria-disabled", "true"); + await expect(findByLabelText("You do not have permission to invite users")).resolves.toHaveAttribute( + "aria-disabled", + "true", + ); }); it("renders enabled invite button when current user is a member and has rights to invite", async () => { jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join); jest.spyOn(room, "canInvite").mockReturnValue(true); - renderComponent(); - await flushPromises(); + const { findByText } = renderComponent(); - expect(screen.getByText("Invite to this room")).not.toBeDisabled(); + await expect(findByText("Invite to this room")).resolves.not.toBeDisabled(); }); it("opens room inviter on button click", async () => { diff --git a/test/unit-tests/components/views/rooms/MessageComposer-test.tsx b/test/unit-tests/components/views/rooms/MessageComposer-test.tsx index c2e0c4848e..3bd9a6cf62 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( diff --git a/test/unit-tests/components/views/rooms/RoomHeader-test.tsx b/test/unit-tests/components/views/rooms/RoomHeader-test.tsx index a7e556e452..1be9c77713 100644 --- a/test/unit-tests/components/views/rooms/RoomHeader-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomHeader-test.tsx @@ -8,9 +8,19 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; -import { EventType, JoinRule, MatrixEvent, PendingEventOrdering, Room, RoomMember } from "matrix-js-sdk/src/matrix"; -import { KnownMembership } from "matrix-js-sdk/src/types"; import { + EventType, + JoinRule, + MatrixEvent, + PendingEventOrdering, + Room, + RoomStateEvent, + RoomMember, +} from "matrix-js-sdk/src/matrix"; +import { KnownMembership } from "matrix-js-sdk/src/types"; +import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; +import { + act, createEvent, fireEvent, getAllByLabelText, @@ -632,6 +642,52 @@ describe("RoomHeader", () => { expect(asFragment()).toMatchSnapshot(); }); + + it("updates the icon when the encryption status changes", async () => { + // The room starts verified + jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Verified); + render(, getWrapper()); + await waitFor(() => expect(getByLabelText(document.body, "Verified")).toBeInTheDocument()); + + // A new member joins, and the room becomes unverified + jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Warning); + act(() => { + room.emit( + RoomStateEvent.Members, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "join", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + room.currentState, + new RoomMember(room.roomId, "@alice:example.org"), + ); + }); + await waitFor(() => expect(getByLabelText(document.body, "Untrusted")).toBeInTheDocument()); + + // The user becomes verified + jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Verified); + act(() => { + MatrixClientPeg.get()!.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(true, true, true, false), + ); + }); + await waitFor(() => expect(getByLabelText(document.body, "Verified")).toBeInTheDocument()); + + // An unverified device is added + jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(ShieldUtils.E2EStatus.Warning); + act(() => { + MatrixClientPeg.get()!.emit(CryptoEvent.DevicesUpdated, ["@alice:example.org"], false); + }); + await waitFor(() => expect(getByLabelText(document.body, "Untrusted")).toBeInTheDocument()); + }); }); it("renders additionalButtons", async () => { diff --git a/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx b/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx index e423d03ea9..f3a0168833 100644 --- a/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx @@ -385,7 +385,7 @@ describe("", () => { it("correctly persists state to and from localStorage", () => { const props = { replyToEvent: mockEvent }; - const { container, unmount, rerender } = getComponent(props); + let { container, unmount } = getComponent(props); addTextToComposer(container, "Test Text"); @@ -402,7 +402,7 @@ describe("", () => { }); // ensure the correct model is re-loaded - rerender(getRawComponent(props)); + ({ container, unmount } = getComponent(props)); expect(container.textContent).toBe("Test Text"); expect(spyDispatcher).toHaveBeenCalledWith({ action: "reply_to_event", @@ -413,7 +413,7 @@ describe("", () => { // now try with localStorage wiped out unmount(); localStorage.removeItem(key); - rerender(getRawComponent(props)); + ({ container } = getComponent(props)); expect(container.textContent).toBe(""); }); diff --git a/test/unit-tests/components/views/rooms/UserIdentityWarning-test.tsx b/test/unit-tests/components/views/rooms/UserIdentityWarning-test.tsx new file mode 100644 index 0000000000..9a70a88768 --- /dev/null +++ b/test/unit-tests/components/views/rooms/UserIdentityWarning-test.tsx @@ -0,0 +1,534 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { sleep } from "matrix-js-sdk/src/utils"; +import { + EventType, + MatrixClient, + MatrixEvent, + Room, + RoomState, + RoomStateEvent, + RoomMember, +} from "matrix-js-sdk/src/matrix"; +import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; +import { act, render, screen, waitFor } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { stubClient } from "../../../../test-utils"; +import { UserIdentityWarning } from "../../../../../src/components/views/rooms/UserIdentityWarning"; +import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; + +const ROOM_ID = "!room:id"; + +function mockRoom(): Room { + const room = { + getEncryptionTargetMembers: jest.fn(async () => []), + getMember: jest.fn((userId) => {}), + roomId: ROOM_ID, + shouldEncryptForInvitedMembers: jest.fn(() => true), + } as unknown as Room; + + return room; +} + +function mockRoomMember(userId: string, name?: string): RoomMember { + return { + userId, + name: name ?? userId, + rawDisplayName: name ?? userId, + roomId: ROOM_ID, + getMxcAvatarUrl: jest.fn(), + } as unknown as RoomMember; +} + +function dummyRoomState(): RoomState { + return new RoomState(ROOM_ID); +} + +/** + * Get the warning element, given the warning text (excluding the "Learn more" + * link). This is needed because the warning text contains a `` tag, so the + * normal `getByText` doesn't work. + */ +function getWarningByText(text: string): Element { + return screen.getByText((content?: string, element?: Element | null): boolean => { + return ( + !!element && + element.classList.contains("mx_UserIdentityWarning_main") && + element.textContent === text + " Learn more" + ); + }); +} + +function renderComponent(client: MatrixClient, room: Room) { + return render(, { + wrapper: ({ ...rest }) => , + }); +} + +describe("UserIdentityWarning", () => { + let client: MatrixClient; + let room: Room; + + beforeEach(async () => { + client = stubClient(); + room = mockRoom(); + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // This tests the basic functionality of the component. If we have a room + // member whose identity needs accepting, we should display a warning. When + // the "OK" button gets pressed, it should call `pinCurrentUserIdentity`. + it("displays a warning when a user's identity needs approval", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + crypto.pinCurrentUserIdentity = jest.fn(); + renderComponent(client, room); + + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + await userEvent.click(screen.getByRole("button")!); + await waitFor(() => expect(crypto.pinCurrentUserIdentity).toHaveBeenCalledWith("@alice:example.org")); + }); + + // We don't display warnings in non-encrypted rooms, but if encryption is + // enabled, then we should display a warning if there are any users whose + // identity need accepting. + it("displays pending warnings when encryption is enabled", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + // Start the room off unencrypted. We shouldn't display anything. + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(false); + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(); + + // Encryption gets enabled in the room. We should now warn that Alice's + // identity changed. + jest.spyOn(crypto, "isEncryptionEnabledInRoom").mockResolvedValue(true); + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomEncryption, + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + }); + + // When a user's identity needs approval, or has been approved, the display + // should update appropriately. + it("updates the display when identity changes", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, false), + ); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(); + + // The user changes their identity, so we should show the warning. + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, true), + ); + }); + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + + // Simulate the user's new identity having been approved, so we no + // longer show the warning. + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, false), + ); + }); + await waitFor(() => + expect(() => getWarningByText("Alice's (@alice:example.org) identity appears to have changed.")).toThrow(), + ); + }); + + // We only display warnings about users in the room. When someone + // joins/leaves, we should update the warning appropriately. + describe("updates the display when a member joins/leaves", () => { + it("when invited users can see encrypted messages", async () => { + // Nobody in the room yet + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); + jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(true); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + + // Alice joins. Her identity needs approval, so we should show a warning. + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "join", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + await waitFor(() => + expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + + // Bob is invited. His identity needs approval, so we should show a + // warning for him after Alice's warning is resolved by her leaving. + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@bob:example.org", + content: { + membership: "invite", + }, + room_id: ROOM_ID, + sender: "@carol:example.org", + }), + dummyRoomState(), + null, + ); + + // Alice leaves, so we no longer show her warning, but we will show + // a warning for Bob. + act(() => { + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "leave", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + }); + await waitFor(() => + expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(), + ); + await waitFor(() => + expect(getWarningByText("@bob:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + }); + + it("when invited users cannot see encrypted messages", async () => { + // Nobody in the room yet + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); + jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + + // Alice joins. Her identity needs approval, so we should show a warning. + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "join", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + await waitFor(() => + expect(getWarningByText("@alice:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + + // Bob is invited. His identity needs approval, but we don't encrypt + // to him, so we won't show a warning. (When Alice leaves, the + // display won't be updated to show a warningfor Bob.) + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@bob:example.org", + content: { + membership: "invite", + }, + room_id: ROOM_ID, + sender: "@carol:example.org", + }), + dummyRoomState(), + null, + ); + + // Alice leaves, so we no longer show her warning, and we don't show + // a warning for Bob. + act(() => { + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "leave", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + }); + await waitFor(() => + expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(), + ); + await waitFor(() => + expect(() => getWarningByText("@bob:example.org's identity appears to have changed.")).toThrow(), + ); + }); + + it("when member leaves immediately after component is loaded", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockImplementation(async () => { + setTimeout(() => { + // Alice immediately leaves after we get the room + // membership, so we shouldn't show the warning any more + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "leave", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + }); + return [mockRoomMember("@alice:example.org")]; + }); + jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + renderComponent(client, room); + + await sleep(10); + expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(); + }); + + it("when member leaves immediately after joining", async () => { + // Nobody in the room yet + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([]); + jest.spyOn(room, "getMember").mockImplementation((userId) => mockRoomMember(userId)); + jest.spyOn(room, "shouldEncryptForInvitedMembers").mockReturnValue(false); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + + // Alice joins. Her identity needs approval, so we should show a warning. + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "join", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + // ... but she immediately leaves, so we shouldn't show the warning any more + client.emit( + RoomStateEvent.Events, + new MatrixEvent({ + event_id: "$event_id", + type: EventType.RoomMember, + state_key: "@alice:example.org", + content: { + membership: "leave", + }, + room_id: ROOM_ID, + sender: "@alice:example.org", + }), + dummyRoomState(), + null, + ); + await sleep(10); // give it some time to finish + expect(() => getWarningByText("@alice:example.org's identity appears to have changed.")).toThrow(); + }); + }); + + // When we have multiple users whose identity needs approval, one user's + // identity no longer needs approval (e.g. their identity was approved), + // then we show the next one. + it("displays the next user when the current user's identity is approved", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + mockRoomMember("@bob:example.org"), + ]); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false, true), + ); + + renderComponent(client, room); + // We should warn about Alice's identity first. + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + + // Simulate Alice's new identity having been approved, so now we warn + // about Bob's identity. + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, false), + ); + }); + await waitFor(() => + expect(getWarningByText("@bob:example.org's identity appears to have changed.")).toBeInTheDocument(), + ); + }); + + // If we get an update for a user's verification status while we're fetching + // that user's verification status, we should display based on the updated + // value. + describe("handles races between fetching verification status and receiving updates", () => { + // First case: check that if the update says that the user identity + // needs approval, but the fetch says it doesn't, we show the warning. + it("update says identity needs approval", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => { + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, true), + ); + }); + return Promise.resolve(new UserVerificationStatus(false, false, false, false)); + }); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + await waitFor(() => + expect( + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toBeInTheDocument(), + ); + }); + + // Second case: check that if the update says that the user identity + // doesn't needs approval, but the fetch says it does, we don't show the + // warning. + it("update says identity doesn't need approval", async () => { + jest.spyOn(room, "getEncryptionTargetMembers").mockResolvedValue([ + mockRoomMember("@alice:example.org", "Alice"), + ]); + jest.spyOn(room, "getMember").mockReturnValue(mockRoomMember("@alice:example.org", "Alice")); + const crypto = client.getCrypto()!; + jest.spyOn(crypto, "getUserVerificationStatus").mockImplementation(async () => { + act(() => { + client.emit( + CryptoEvent.UserTrustStatusChanged, + "@alice:example.org", + new UserVerificationStatus(false, false, false, false), + ); + }); + return Promise.resolve(new UserVerificationStatus(false, false, false, true)); + }); + renderComponent(client, room); + await sleep(10); // give it some time to finish initialising + await waitFor(() => + expect(() => + getWarningByText("Alice's (@alice:example.org) identity appears to have changed."), + ).toThrow(), + ); + }); + }); +}); diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index 23384d8a43..5d3c455288 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import "@testing-library/jest-dom"; import React from "react"; -import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react"; +import { fireEvent, render, screen, waitFor } from "jest-matrix-react"; import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; import RoomContext from "../../../../../../src/contexts/RoomContext"; @@ -253,9 +253,7 @@ describe("EditWysiwygComposer", () => { }); // Wait for event dispatch to happen - await act(async () => { - await flushPromises(); - }); + await flushPromises(); // Then we don't get it because we are disabled expect(screen.getByRole("textbox")).not.toHaveFocus(); diff --git a/test/unit-tests/components/views/settings/AddRemoveThreepids-test.tsx b/test/unit-tests/components/views/settings/AddRemoveThreepids-test.tsx index 7fa6619a99..a285a98f3b 100644 --- a/test/unit-tests/components/views/settings/AddRemoveThreepids-test.tsx +++ b/test/unit-tests/components/views/settings/AddRemoveThreepids-test.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import { render, screen, waitFor } from "jest-matrix-react"; +import { render, screen, waitFor, cleanup } from "jest-matrix-react"; import { MatrixClient, MatrixError, ThreepidMedium } from "matrix-js-sdk/src/matrix"; import React from "react"; import userEvent from "@testing-library/user-event"; @@ -48,54 +48,13 @@ describe("AddRemoveThreepids", () => { afterEach(() => { jest.restoreAllMocks(); clearAllModals(); + cleanup(); }); const clientProviderWrapper: React.FC = ({ children }: React.PropsWithChildren) => ( {children} ); - it("should render a loader while loading", async () => { - render( - {}} - />, - ); - - expect(screen.getByLabelText("Loading…")).toBeInTheDocument(); - }); - - it("should render email addresses", async () => { - const { container } = render( - {}} - />, - ); - - expect(container).toMatchSnapshot(); - }); - - it("should render phone numbers", async () => { - const { container } = render( - {}} - />, - ); - - expect(container).toMatchSnapshot(); - }); - it("should handle no email addresses", async () => { const { container } = render( { />, ); + await expect(screen.findByText("Email Address")).resolves.toBeVisible(); expect(container).toMatchSnapshot(); }); @@ -127,7 +87,7 @@ describe("AddRemoveThreepids", () => { }, ); - const input = screen.getByRole("textbox", { name: "Email Address" }); + const input = await screen.findByRole("textbox", { name: "Email Address" }); await userEvent.type(input, EMAIL1.address); const addButton = screen.getByRole("button", { name: "Add" }); await userEvent.click(addButton); @@ -166,7 +126,7 @@ describe("AddRemoveThreepids", () => { }, ); - const input = screen.getByRole("textbox", { name: "Email Address" }); + const input = await screen.findByRole("textbox", { name: "Email Address" }); await userEvent.type(input, EMAIL1.address); const addButton = screen.getByRole("button", { name: "Add" }); await userEvent.click(addButton); @@ -210,7 +170,7 @@ describe("AddRemoveThreepids", () => { }, ); - const countryDropdown = screen.getByRole("button", { name: /Country Dropdown/ }); + const countryDropdown = await screen.findByRole("button", { name: /Country Dropdown/ }); await userEvent.click(countryDropdown); const gbOption = screen.getByRole("option", { name: "🇬🇧 United Kingdom (+44)" }); await userEvent.click(gbOption); @@ -270,7 +230,7 @@ describe("AddRemoveThreepids", () => { }, ); - const removeButton = screen.getByRole("button", { name: /Remove/ }); + const removeButton = await screen.findByRole("button", { name: /Remove/ }); await userEvent.click(removeButton); expect(screen.getByText(`Remove ${EMAIL1.address}?`)).toBeVisible(); @@ -297,7 +257,7 @@ describe("AddRemoveThreepids", () => { }, ); - const removeButton = screen.getByRole("button", { name: /Remove/ }); + const removeButton = await screen.findByRole("button", { name: /Remove/ }); await userEvent.click(removeButton); expect(screen.getByText(`Remove ${EMAIL1.address}?`)).toBeVisible(); @@ -326,7 +286,7 @@ describe("AddRemoveThreepids", () => { }, ); - const removeButton = screen.getByRole("button", { name: /Remove/ }); + const removeButton = await screen.findByRole("button", { name: /Remove/ }); await userEvent.click(removeButton); expect(screen.getByText(`Remove ${PHONE1.address}?`)).toBeVisible(); @@ -357,7 +317,7 @@ describe("AddRemoveThreepids", () => { }, ); - expect(screen.getByText(EMAIL1.address)).toBeVisible(); + await expect(screen.findByText(EMAIL1.address)).resolves.toBeVisible(); const shareButton = screen.getByRole("button", { name: /Share/ }); await userEvent.click(shareButton); @@ -408,7 +368,7 @@ describe("AddRemoveThreepids", () => { }, ); - expect(screen.getByText(PHONE1.address)).toBeVisible(); + await expect(screen.findByText(PHONE1.address)).resolves.toBeVisible(); const shareButton = screen.getByRole("button", { name: /Share/ }); await userEvent.click(shareButton); @@ -452,7 +412,7 @@ describe("AddRemoveThreepids", () => { }, ); - expect(screen.getByText(EMAIL1.address)).toBeVisible(); + await expect(screen.findByText(EMAIL1.address)).resolves.toBeVisible(); const revokeButton = screen.getByRole("button", { name: /Revoke/ }); await userEvent.click(revokeButton); @@ -475,7 +435,7 @@ describe("AddRemoveThreepids", () => { }, ); - expect(screen.getByText(PHONE1.address)).toBeVisible(); + await expect(screen.findByText(PHONE1.address)).resolves.toBeVisible(); const revokeButton = screen.getByRole("button", { name: /Revoke/ }); await userEvent.click(revokeButton); @@ -596,4 +556,48 @@ describe("AddRemoveThreepids", () => { }), ); }); + + it("should render a loader while loading", async () => { + render( + {}} + />, + ); + + expect(screen.getByLabelText("Loading…")).toBeInTheDocument(); + }); + + it("should render email addresses", async () => { + const { container } = render( + {}} + />, + ); + + await expect(screen.findByText(EMAIL1.address)).resolves.toBeVisible(); + expect(container).toMatchSnapshot(); + }); + + it("should render phone numbers", async () => { + const { container } = render( + {}} + />, + ); + + await expect(screen.findByText(PHONE1.address)).resolves.toBeVisible(); + expect(container).toMatchSnapshot(); + }); }); diff --git a/test/unit-tests/components/views/settings/AvatarSetting-test.tsx b/test/unit-tests/components/views/settings/AvatarSetting-test.tsx index e3e2b1cf96..1b88c416bc 100644 --- a/test/unit-tests/components/views/settings/AvatarSetting-test.tsx +++ b/test/unit-tests/components/views/settings/AvatarSetting-test.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { render, screen } from "jest-matrix-react"; +import { render, screen, fireEvent } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import AvatarSetting from "../../../../../src/components/views/settings/AvatarSetting"; @@ -16,6 +16,9 @@ const BASE64_GIF = "R0lGODlhAQABAAAAACw="; const AVATAR_FILE = new File([Uint8Array.from(atob(BASE64_GIF), (c) => c.charCodeAt(0))], "avatar.gif", { type: "image/gif", }); +const GENERIC_FILE = new File([Uint8Array.from(atob(BASE64_GIF), (c) => c.charCodeAt(0))], "not-avatar.doc", { + type: "application/msword", +}); describe("", () => { beforeEach(() => { @@ -70,4 +73,45 @@ describe("", () => { expect(onChange).toHaveBeenCalledWith(AVATAR_FILE); }); + + it("should noop when selecting no file", async () => { + const onChange = jest.fn(); + + render( + , + ); + + const fileInput = screen.getByAltText("Upload"); + // Can't use userEvent.upload here as it doesn't support uploading invalid files + fireEvent.change(fileInput, { target: { files: [] } }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("should show error if user tries to use non-image file", async () => { + const onChange = jest.fn(); + + render( + , + ); + + const fileInput = screen.getByAltText("Upload"); + // Can't use userEvent.upload here as it doesn't support uploading invalid files + fireEvent.change(fileInput, { target: { files: [GENERIC_FILE] } }); + + expect(onChange).not.toHaveBeenCalled(); + await expect(screen.findByRole("heading", { name: "Upload Failed" })).resolves.toBeInTheDocument(); + }); }); diff --git a/test/unit-tests/components/views/settings/JoinRuleSettings-test.tsx b/test/unit-tests/components/views/settings/JoinRuleSettings-test.tsx index c14f018df0..36dd664ac6 100644 --- a/test/unit-tests/components/views/settings/JoinRuleSettings-test.tsx +++ b/test/unit-tests/components/views/settings/JoinRuleSettings-test.tsx @@ -59,7 +59,7 @@ describe("", () => { onError: jest.fn(), }; const getComponent = (props: Partial = {}) => - render(, { legacyRoot: false }); + render(); const setRoomStateEvents = ( room: Room, diff --git a/test/unit-tests/components/views/settings/__snapshots__/AddRemoveThreepids-test.tsx.snap b/test/unit-tests/components/views/settings/__snapshots__/AddRemoveThreepids-test.tsx.snap index 52e754d691..0258ce7092 100644 --- a/test/unit-tests/components/views/settings/__snapshots__/AddRemoveThreepids-test.tsx.snap +++ b/test/unit-tests/components/views/settings/__snapshots__/AddRemoveThreepids-test.tsx.snap @@ -11,14 +11,14 @@ exports[`AddRemoveThreepids should handle no email addresses 1`] = ` > @@ -61,14 +61,14 @@ exports[`AddRemoveThreepids should render email addresses 1`] = ` > @@ -148,14 +148,14 @@ exports[`AddRemoveThreepids should render phone numbers 1`] = `
    diff --git a/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx b/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx index 218e43ac1f..98a0657eae 100644 --- a/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx +++ b/test/unit-tests/components/views/settings/devices/LoginWithQR-test.tsx @@ -79,9 +79,7 @@ describe("", () => { describe("MSC4108", () => { const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => ( - - - + ); test("render QR then back", async () => { diff --git a/test/unit-tests/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx b/test/unit-tests/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx index 45855a0e25..000c38c771 100644 --- a/test/unit-tests/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx +++ b/test/unit-tests/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx @@ -27,16 +27,19 @@ describe("RolesRoomSettingsTab", () => { let cli: MatrixClient; let room: Room; - const renderTab = (propRoom: Room = room): RenderResult => { - return render(, withClientContextRenderOptions(cli)); + const renderTab = async (propRoom: Room = room): Promise => { + const renderResult = render(, withClientContextRenderOptions(cli)); + // Wait for the tab to be ready + await waitFor(() => expect(screen.getByText("Permissions")).toBeInTheDocument()); + return renderResult; }; - const getVoiceBroadcastsSelect = (): HTMLElement => { - return renderTab().container.querySelector("select[label='Voice broadcasts']")!; + const getVoiceBroadcastsSelect = async (): Promise => { + return (await renderTab()).container.querySelector("select[label='Voice broadcasts']")!; }; - const getVoiceBroadcastsSelectedOption = (): HTMLElement => { - return renderTab().container.querySelector("select[label='Voice broadcasts'] option:checked")!; + const getVoiceBroadcastsSelectedOption = async (): Promise => { + return (await renderTab()).container.querySelector("select[label='Voice broadcasts'] option:checked")!; }; beforeEach(() => { @@ -45,7 +48,7 @@ describe("RolesRoomSettingsTab", () => { room = mkStubRoom(roomId, "test room", cli); }); - it("should allow an Admin to demote themselves but not others", () => { + it("should allow an Admin to demote themselves but not others", async () => { mocked(cli.getRoom).mockReturnValue(room); // @ts-ignore - mocked doesn't support overloads properly mocked(room.currentState.getStateEvents).mockImplementation((type, key) => { @@ -67,19 +70,19 @@ describe("RolesRoomSettingsTab", () => { return null; }); mocked(room.currentState.mayClientSendStateEvent).mockReturnValue(true); - const { container } = renderTab(); + const { container } = await renderTab(); expect(container.querySelector(`[placeholder="${cli.getUserId()}"]`)).not.toBeDisabled(); expect(container.querySelector(`[placeholder="@admin:server"]`)).toBeDisabled(); }); - it("should initially show »Moderator« permission for »Voice broadcasts«", () => { - expect(getVoiceBroadcastsSelectedOption().textContent).toBe("Moderator"); + it("should initially show »Moderator« permission for »Voice broadcasts«", async () => { + expect((await getVoiceBroadcastsSelectedOption()).textContent).toBe("Moderator"); }); describe("when setting »Default« permission for »Voice broadcasts«", () => { - beforeEach(() => { - fireEvent.change(getVoiceBroadcastsSelect(), { + beforeEach(async () => { + fireEvent.change(await getVoiceBroadcastsSelect(), { target: { value: 0 }, }); }); @@ -122,12 +125,12 @@ describe("RolesRoomSettingsTab", () => { }); describe("Join Element calls", () => { - it("defaults to moderator for joining calls", () => { - expect(getJoinCallSelectedOption(renderTab())?.textContent).toBe("Moderator"); + it("defaults to moderator for joining calls", async () => { + expect(getJoinCallSelectedOption(await renderTab())?.textContent).toBe("Moderator"); }); - it("can change joining calls power level", () => { - const tab = renderTab(); + it("can change joining calls power level", async () => { + const tab = await renderTab(); fireEvent.change(getJoinCallSelect(tab), { target: { value: 0 }, @@ -143,12 +146,12 @@ describe("RolesRoomSettingsTab", () => { }); describe("Start Element calls", () => { - it("defaults to moderator for starting calls", () => { - expect(getStartCallSelectedOption(renderTab())?.textContent).toBe("Moderator"); + it("defaults to moderator for starting calls", async () => { + expect(getStartCallSelectedOption(await renderTab())?.textContent).toBe("Moderator"); }); - it("can change starting calls power level", () => { - const tab = renderTab(); + it("can change starting calls power level", async () => { + const tab = await renderTab(); fireEvent.change(getStartCallSelect(tab), { target: { value: 0 }, @@ -164,10 +167,10 @@ describe("RolesRoomSettingsTab", () => { }); }); - it("hides when group calls disabled", () => { + it("hides when group calls disabled", async () => { setGroupCallsEnabled(false); - const tab = renderTab(); + const tab = await renderTab(); expect(getStartCallSelect(tab)).toBeFalsy(); expect(getStartCallSelectedOption(tab)).toBeFalsy(); @@ -250,7 +253,7 @@ describe("RolesRoomSettingsTab", () => { return null; }); mocked(room.currentState.mayClientSendStateEvent).mockReturnValue(true); - const { container } = renderTab(); + const { container } = await renderTab(); const selector = container.querySelector(`[placeholder="${cli.getUserId()}"]`)!; fireEvent.change(selector, { target: { value: "50" } }); diff --git a/test/unit-tests/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/unit-tests/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 52c9d3aaa9..87411e18a1 100644 --- a/test/unit-tests/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/unit-tests/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -277,9 +277,7 @@ describe("", () => { mockClient.getDevices.mockRejectedValue({ httpStatus: 404 }); const { container } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(container.getElementsByClassName("mx_Spinner").length).toBeFalsy(); }); @@ -302,9 +300,7 @@ describe("", () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(mockCrypto.getDeviceVerificationStatus).toHaveBeenCalledTimes(3); expect( @@ -337,9 +333,7 @@ describe("", () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); // twice for each device expect(mockClient.getAccountData).toHaveBeenCalledTimes(4); @@ -356,9 +350,7 @@ describe("", () => { const { getByTestId, queryByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); toggleDeviceDetails(getByTestId, alicesDevice.device_id); // application metadata section not rendered @@ -369,9 +361,7 @@ describe("", () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice] }); const { queryByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(queryByTestId("other-sessions-section")).toBeFalsy(); }); @@ -382,9 +372,7 @@ describe("", () => { }); const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(getByTestId("other-sessions-section")).toBeTruthy(); }); @@ -395,9 +383,7 @@ describe("", () => { }); const { getByTestId, container } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); fireEvent.click(getByTestId("unverified-devices-cta")); @@ -908,7 +894,8 @@ describe("", () => { }); it("deletes a device when interactive auth is not required", async () => { - mockClient.deleteMultipleDevices.mockResolvedValue({}); + const deferredDeleteMultipleDevices = defer<{}>(); + mockClient.deleteMultipleDevices.mockReturnValue(deferredDeleteMultipleDevices.promise); mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }); @@ -933,6 +920,7 @@ describe("", () => { fireEvent.click(signOutButton); await confirmSignout(getByTestId); await prom; + deferredDeleteMultipleDevices.resolve({}); // delete called expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( @@ -991,7 +979,7 @@ describe("", () => { const { getByTestId, getByLabelText } = render(getComponent()); - await act(flushPromises); + await flushPromises(); // reset mock count after initial load mockClient.getDevices.mockClear(); @@ -1025,7 +1013,7 @@ describe("", () => { fireEvent.submit(getByLabelText("Password")); }); - await act(flushPromises); + await flushPromises(); // called again with auth expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([alicesMobileDevice.device_id], { @@ -1551,7 +1539,7 @@ describe("", () => { }); const { getByTestId, container } = render(getComponent()); - await act(flushPromises); + await flushPromises(); // filter for inactive sessions await setFilter(container, DeviceSecurityVariation.Inactive); @@ -1577,9 +1565,7 @@ describe("", () => { it("lets you change the pusher state", async () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); @@ -1598,9 +1584,7 @@ describe("", () => { it("lets you change the local notification settings state", async () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); toggleDeviceDetails(getByTestId, alicesDevice.device_id); @@ -1621,9 +1605,7 @@ describe("", () => { it("updates the UI when another session changes the local notifications", async () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); toggleDeviceDetails(getByTestId, alicesDevice.device_id); diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap index 6c51cc41ab..5c6a8ac8ee 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap @@ -42,14 +42,14 @@ exports[` 3pids should display 3pid email addresses an > @@ -145,14 +145,14 @@ exports[` 3pids should display 3pid email addresses an diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap index 38f9e483c8..72f94d29c6 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap @@ -388,7 +388,7 @@ exports[` goes to filtered list from security recommendatio > 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
    { 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/stores/MemberListStore-test.ts b/test/unit-tests/stores/MemberListStore-test.ts index 889a9d3505..815dea8758 100644 --- a/test/unit-tests/stores/MemberListStore-test.ts +++ b/test/unit-tests/stores/MemberListStore-test.ts @@ -189,8 +189,7 @@ describe("MemberListStore", () => { }); it("does not use lazy loading on encrypted rooms", async () => { - client.isRoomEncrypted = jest.fn(); - mocked(client.isRoomEncrypted).mockReturnValue(true); + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); const { joined } = await store.loadMemberList(roomId); expect(joined).toEqual([room.getMember(alice)]); diff --git a/test/unit-tests/stores/RoomViewStore-test.ts b/test/unit-tests/stores/RoomViewStore-test.ts index 7d397397dc..c9b80553e5 100644 --- a/test/unit-tests/stores/RoomViewStore-test.ts +++ b/test/unit-tests/stores/RoomViewStore-test.ts @@ -338,7 +338,7 @@ describe("RoomViewStore", function () { }); dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); await untilDispatch(Action.ActiveRoomChanged, dis); - expect(mockClient.setRoomAccountData).toHaveBeenCalledWith(roomId, "com.famedly.marked_unread", { + expect(mockClient.setRoomAccountData).toHaveBeenCalledWith(roomId, "m.marked_unread", { unread: false, }); }); diff --git a/test/unit-tests/stores/SetupEncryptionStore-test.ts b/test/unit-tests/stores/SetupEncryptionStore-test.ts index 388f1965d7..b9ab29b94b 100644 --- a/test/unit-tests/stores/SetupEncryptionStore-test.ts +++ b/test/unit-tests/stores/SetupEncryptionStore-test.ts @@ -170,15 +170,10 @@ describe("SetupEncryptionStore", () => { await setupEncryptionStore.resetConfirm(); - expect(mocked(accessSecretStorage)).toHaveBeenCalledWith(expect.any(Function), true); - expect(makeRequest).toHaveBeenCalledWith({ - identifier: { - type: "m.id.user", - user: "@userId:matrix.org", - }, - password: cachedPassword, - type: "m.login.password", - user: "@userId:matrix.org", + expect(mocked(accessSecretStorage)).toHaveBeenCalledWith(expect.any(Function), { + accountPassword: cachedPassword, + forceReset: true, + resetCrossSigning: true, }); }); }); diff --git a/test/unit-tests/stores/notifications/RoomNotificationState-test.ts b/test/unit-tests/stores/notifications/RoomNotificationState-test.ts index 5ebbe3f1ad..396bb06ec6 100644 --- a/test/unit-tests/stores/notifications/RoomNotificationState-test.ts +++ b/test/unit-tests/stores/notifications/RoomNotificationState-test.ts @@ -91,7 +91,7 @@ describe("RoomNotificationState", () => { const listener = jest.fn(); roomNotifState.addListener(NotificationStateEvents.Update, listener); const accountDataEvent = { - getType: () => "com.famedly.marked_unread", + getType: () => "m.marked_unread", getContent: () => { return { unread: true }; }, diff --git a/test/unit-tests/toasts/UnverifiedSessionToast-test.tsx b/test/unit-tests/toasts/UnverifiedSessionToast-test.tsx index c7df2a0e6e..8b68b3e378 100644 --- a/test/unit-tests/toasts/UnverifiedSessionToast-test.tsx +++ b/test/unit-tests/toasts/UnverifiedSessionToast-test.tsx @@ -65,7 +65,8 @@ describe("UnverifiedSessionToast", () => { }); }; - it("should render as expected", () => { + it("should render as expected", async () => { + await expect(screen.findByText("New login. Was this you?")).resolves.toBeInTheDocument(); expect(renderResult.baseElement).toMatchSnapshot(); }); diff --git a/test/unit-tests/utils/arrays-test.ts b/test/unit-tests/utils/arrays-test.ts index 53baed8be3..7e440d3ee5 100644 --- a/test/unit-tests/utils/arrays-test.ts +++ b/test/unit-tests/utils/arrays-test.ts @@ -23,6 +23,8 @@ import { concat, asyncEvery, asyncSome, + asyncSomeParallel, + asyncFilter, } from "../../../src/utils/arrays"; type TestParams = { input: number[]; output: number[] }; @@ -460,4 +462,34 @@ 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); + }); + }); + + describe("asyncFilter", () => { + it("when called with an empty array, it should return an empty array", async () => { + expect(await asyncFilter([], jest.fn().mockResolvedValue(true))).toEqual([]); + }); + + it("should filter the content", async () => { + const predicate = jest.fn().mockImplementation((value) => Promise.resolve(value === 2)); + expect(await asyncFilter([1, 2, 3], predicate)).toEqual([2]); + }); + }); }); diff --git a/test/unit-tests/utils/media/requestMediaPermissions-test.tsx b/test/unit-tests/utils/media/requestMediaPermissions-test.tsx index 14dfa15505..0683ad1b67 100644 --- a/test/unit-tests/utils/media/requestMediaPermissions-test.tsx +++ b/test/unit-tests/utils/media/requestMediaPermissions-test.tsx @@ -21,7 +21,7 @@ describe("requestMediaPermissions", () => { const itShouldLogTheErrorAndShowTheNoMediaPermissionsModal = () => { it("should log the error and show the »No media permissions« modal", async () => { expect(logger.log).toHaveBeenCalledWith("Failed to list userMedia devices", error); - await screen.findByText("No media permissions"); + await expect(screen.findByText("No media permissions")).resolves.toBeInTheDocument(); }); }; diff --git a/test/unit-tests/utils/notifications-test.ts b/test/unit-tests/utils/notifications-test.ts index 67948ed217..8e33575fec 100644 --- a/test/unit-tests/utils/notifications-test.ts +++ b/test/unit-tests/utils/notifications-test.ts @@ -270,7 +270,7 @@ describe("notifications", () => { // set true, no existing event it("sets unread flag if event doesn't exist", async () => { await setMarkedUnreadState(room, client, true); - expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", { + expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "m.marked_unread", { unread: true, }); }); @@ -287,7 +287,7 @@ describe("notifications", () => { .fn() .mockReturnValue({ getContent: jest.fn().mockReturnValue({ unread: false }) }); await setMarkedUnreadState(room, client, true); - expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", { + expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "m.marked_unread", { unread: true, }); }); @@ -316,7 +316,7 @@ describe("notifications", () => { .fn() .mockReturnValue({ getContent: jest.fn().mockReturnValue({ unread: true }) }); await setMarkedUnreadState(room, client, false); - expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", { + expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "m.marked_unread", { unread: false, }); }); diff --git a/test/unit-tests/vector/__snapshots__/init-test.ts.snap b/test/unit-tests/vector/__snapshots__/init-test.ts.snap index eeb5e5967c..4fd8e03459 100644 --- a/test/unit-tests/vector/__snapshots__/init-test.ts.snap +++ b/test/unit-tests/vector/__snapshots__/init-test.ts.snap @@ -103,6 +103,7 @@ exports[`showIncompatibleBrowser should match snapshot 1`] = `