diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 16036541f8..ebde627fde 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,7 +2,7 @@ ## Checklist -- [ ] Tests written for new code (and old code if feasible). -- [ ] New or updated `public`/`exported` symbols have accurate [TSDoc](https://tsdoc.org/) documentation. -- [ ] Linter and other CI checks pass. -- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-web) +- [ ] Tests written for new code (and old code if feasible). +- [ ] New or updated `public`/`exported` symbols have accurate [TSDoc](https://tsdoc.org/) documentation. +- [ ] Linter and other CI checks pass. +- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-web) diff --git a/.github/actions/download-verify-element-tarball/action.yml b/.github/actions/download-verify-element-tarball/action.yml new file mode 100644 index 0000000000..978b27bae4 --- /dev/null +++ b/.github/actions/download-verify-element-tarball/action.yml @@ -0,0 +1,33 @@ +name: Upload release assets +description: Uploads assets to an existing release and optionally signs them +inputs: + tag: + description: GitHub release tag to fetch assets from. + required: true + out-file-path: + description: Path to where the webapp should be extracted to. + required: true +runs: + using: composite + steps: + - name: Download current version for its old bundles + id: current_download + uses: robinraju/release-downloader@a96f54c1b5f5e09e47d9504526e96febd949d4c2 # v1 + with: + tag: steps.current_version.outputs.version + fileName: element-*.tar.gz* + out-file-path: ${{ runner.temp }}/download-verify-element-tarball + + - name: Verify tarball + run: gpg --verify element-*.tar.gz.asc element-*.tar.gz + working-directory: ${{ runner.temp }}/download-verify-element-tarball + + - name: Extract tarball + run: tar xvzf element-*.tar.gz -C webapp --strip-components=1 + working-directory: ${{ runner.temp }}/download-verify-element-tarball + + - name: Move webapp to out-file-path + run: mv ${{ runner.temp }}/download-verify-element-tarball/webapp ${{ inputs.out-file-path }} + + - name: Clean up temp directory + run: rm -R ${{ runner.temp }}/download-verify-element-tarball 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..8bbcfe726f 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,10 @@ jobs: if: github.repository == 'element-hq/element-web' runs-on: ubuntu-24.04 environment: develop + permissions: + checks: read + pages: write + deployments: write env: R2_BUCKET: "element-web-develop" R2_URL: ${{ vars.CF_R2_S3_API }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000000..a41a4dcec7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,88 @@ +# Manual deploy workflow for deploying to app.element.io & staging.element.io +# Runs automatically for staging.element.io when an RC or Release is published +# Note: Does *NOT* run automatically for app.element.io so that it gets tested on staging.element.io beforehand +name: Build and Deploy ${{ inputs.site || 'staging.element.io' }} +on: + release: + types: [published] + workflow_dispatch: + inputs: + site: + description: Which site to deploy to + required: true + default: staging.element.io + type: choice + options: + - staging.element.io + - app.element.io +concurrency: ${{ inputs.site || 'staging.element.io' }} +permissions: {} +jobs: + deploy: + name: "Deploy to Cloudflare Pages" + runs-on: ubuntu-24.04 + environment: ${{ inputs.site || 'staging.element.io' }} + permissions: + checks: read + deployments: write + env: + SITE: ${{ inputs.site || 'staging.element.io' }} + steps: + - name: Load GPG key + run: | + curl https://packages.element.io/element-release-key.gpg | gpg --import + gpg -k "$GPG_FINGERPRINT" + env: + GPG_FINGERPRINT: ${{ secrets.GPG_FINGERPRINT }} + + - name: Check current version on deployment + id: current_version + run: | + echo "version=$(curl -s https://$SITE/version)" >> $GITHUB_OUTPUT + + # The current version bundle melding dance is skipped if the version we're deploying is the same + # as then we're just doing a re-deploy of the same version with potentially different configs. + - name: Download current version for its old bundles + id: current_download + if: steps.current_version.outputs.version != github.ref_name + uses: element-hq/element-web/.github/actions/download-verify-element-tarball@${{ github.ref_name }} + with: + tag: steps.current_version.outputs.version + out-file-path: current_version + + - name: Download target version + uses: element-hq/element-web/.github/actions/download-verify-element-tarball@${{ github.ref_name }} + with: + tag: ${{ github.ref_name }} + out-file-path: _deploy + + - name: Merge current bundles into target + if: steps.current_download.outcome == 'success' + run: cp -vnpr current_version/bundles/* _deploy/bundles/ + + - name: Copy config + run: cp element.io/app/config.json _deploy/config.json + + - name: Populate 404.html + run: echo "404 Not Found" > _deploy/404.html + + - name: Populate _headers + run: cp .github/cfp_headers _deploy/_headers + + - name: Wait for other steps to succeed + uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork + with: + ref: ${{ github.sha }} + running-workflow-name: "Build and Deploy ${{ env.SITE }}" + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 10 + check-regexp: ^((?!SonarCloud|SonarQube|issue|board|label|Release|prepare|GitHub Pages).)*$ + + - name: Deploy to Cloudflare Pages + uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1 + with: + apiToken: ${{ secrets.CF_PAGES_TOKEN }} + accountId: ${{ secrets.CF_PAGES_ACCOUNT_ID }} + projectName: ${{ env.SITE == 'staging.element.io' && 'element-web-staging' || 'element-web' }} + directory: _deploy + gitHubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/dockerhub.yaml b/.github/workflows/dockerhub.yaml index 65457ab8f9..8dae6cf5ab 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: @@ -39,7 +39,7 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5 + uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5 with: images: | vectorim/element-web @@ -51,7 +51,7 @@ jobs: - name: Build and push id: build-and-push - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6 + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6 with: context: . push: true 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..d48fed1792 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: write 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..019bc1b9ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,14 @@ 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 + pull-requests: read secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} @@ -42,6 +47,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..031221041a 100644 --- a/.github/workflows/release_prepare.yml +++ b/.github/workflows/release_prepare.yml @@ -17,9 +17,25 @@ on: required: true type: boolean default: true +permissions: {} # Uses ELEMENT_BOT_TOKEN instead jobs: + checks: + name: Sanity checks + strategy: + matrix: + repo: + - matrix-org/matrix-js-sdk + - element-hq/element-web + - element-hq/element-desktop + uses: matrix-org/matrix-js-sdk/.github/workflows/release-checks.yml@develop + secrets: + ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + with: + repository: ${{ matrix.repo }} + prepare: runs-on: ubuntu-24.04 + needs: checks env: # The order is specified bottom-up to avoid any races for allchange REPOS: matrix-js-sdk element-web element-desktop 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..0c531f89b4 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@66088c44e212a906c32a047529a213d81809ec1c 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/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/CONTRIBUTING.md b/CONTRIBUTING.md index 49741b073c..fa887929fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,26 +20,26 @@ Definitely don't use the GitHub default of "Update file.ts". As for your PR description, it should include these things: -- References to any bugs fixed by the change (in GitHub's `Fixes` notation) -- Describe the why and what is changing in the PR description so it's easy for - onlookers and reviewers to onboard and context switch. This information is - also helpful when we come back to look at this in 6 months and ask "why did - we do it like that?" we have a chance of finding out. - - Why didn't it work before? Why does it work now? What use cases does it - unlock? - - If you find yourself adding information on how the code works or why you - chose to do it the way you did, make sure this information is instead - written as comments in the code itself. - - Sometimes a PR can change considerably as it is developed. In this case, - the description should be updated to reflect the most recent state of - the PR. (It can be helpful to retain the old content under a suitable - heading, for additional context.) -- Include both **before** and **after** screenshots to easily compare and discuss - what's changing. -- Include a step-by-step testing strategy so that a reviewer can check out the - code locally and easily get to the point of testing your change. -- Add comments to the diff for the reviewer that might help them to understand - why the change is necessary or how they might better understand and review it. +- References to any bugs fixed by the change (in GitHub's `Fixes` notation) +- Describe the why and what is changing in the PR description so it's easy for + onlookers and reviewers to onboard and context switch. This information is + also helpful when we come back to look at this in 6 months and ask "why did + we do it like that?" we have a chance of finding out. + - Why didn't it work before? Why does it work now? What use cases does it + unlock? + - If you find yourself adding information on how the code works or why you + chose to do it the way you did, make sure this information is instead + written as comments in the code itself. + - Sometimes a PR can change considerably as it is developed. In this case, + the description should be updated to reflect the most recent state of + the PR. (It can be helpful to retain the old content under a suitable + heading, for additional context.) +- Include both **before** and **after** screenshots to easily compare and discuss + what's changing. +- Include a step-by-step testing strategy so that a reviewer can check out the + code locally and easily get to the point of testing your change. +- Add comments to the diff for the reviewer that might help them to understand + why the change is necessary or how they might better understand and review it. ### Changelogs @@ -79,8 +79,8 @@ element-web notes: Fix a bug where the 'Herd' button only worked on Tuesdays This example is for Element Web. You can specify: -- element-web -- element-desktop +- element-web +- element-desktop If your PR introduces a breaking change, use the `Notes` section in the same way, additionally adding the `X-Breaking-Change` label (see below). There's no need @@ -96,10 +96,10 @@ Notes: Remove legacy `Camelopard` class. `Giraffe` should be used instead. Other metadata can be added using labels. -- `X-Breaking-Change`: A breaking change - adding this label will mean the change causes a _major_ version bump. -- `T-Enhancement`: A new feature - adding this label will mean the change causes a _minor_ version bump. -- `T-Defect`: A bug fix (in either code or docs). -- `T-Task`: No user-facing changes, eg. code comments, CI fixes, refactors or tests. Won't have a changelog entry unless you specify one. +- `X-Breaking-Change`: A breaking change - adding this label will mean the change causes a _major_ version bump. +- `T-Enhancement`: A new feature - adding this label will mean the change causes a _minor_ version bump. +- `T-Defect`: A bug fix (in either code or docs). +- `T-Task`: No user-facing changes, eg. code comments, CI fixes, refactors or tests. Won't have a changelog entry unless you specify one. If you don't have permission to add labels, your PR reviewer(s) can work with you to add them: ask in the PR description or comments. diff --git a/README.md b/README.md index fa4ac89ff9..87e451c9ff 100644 --- a/README.md +++ b/README.md @@ -16,28 +16,28 @@ JS SDK](https://github.com/matrix-org/matrix-js-sdk). Element has several tiers of support for different environments: -- Supported - - Definition: - - Issues **actively triaged**, regressions **block** the release - - Last 2 major versions of Chrome, Firefox, and Edge on desktop OSes - - Last 2 versions of Safari - - Latest release of official Element Desktop app on desktop OSes - - Desktop OSes means macOS, Windows, and Linux versions for desktop devices - that are actively supported by the OS vendor and receive security updates -- Best effort - - Definition: - - Issues **accepted**, regressions **do not block** the release - - The wider Element Products(including Element Call and the Enterprise Server Suite) do still not officially support these browsers. - - The element web project and its contributors should keep the client functioning and gracefully degrade where other sibling features (E.g. Element Call) may not function. - - Last major release of Firefox ESR and Chrome/Edge Extended Stable -- Community Supported - - Definition: - - Issues **accepted**, regressions **do not block** the release - - Community contributions are welcome to support these issues - - Mobile web for current stable version of Chrome, Firefox, and Safari on Android, iOS, and iPadOS -- Not supported - - Definition: Issues only affecting unsupported environments are **closed** - - Everything else +- Supported + - Definition: + - Issues **actively triaged**, regressions **block** the release + - Last 2 major versions of Chrome, Firefox, and Edge on desktop OSes + - Last 2 versions of Safari + - Latest release of official Element Desktop app on desktop OSes + - Desktop OSes means macOS, Windows, and Linux versions for desktop devices + that are actively supported by the OS vendor and receive security updates +- Best effort + - Definition: + - Issues **accepted**, regressions **do not block** the release + - The wider Element Products(including Element Call and the Enterprise Server Suite) do still not officially support these browsers. + - The element web project and its contributors should keep the client functioning and gracefully degrade where other sibling features (E.g. Element Call) may not function. + - Last major release of Firefox ESR and Chrome/Edge Extended Stable +- Community Supported + - Definition: + - Issues **accepted**, regressions **do not block** the release + - Community contributions are welcome to support these issues + - Mobile web for current stable version of Chrome, Firefox, and Safari on Android, iOS, and iPadOS +- Not supported + - Definition: Issues only affecting unsupported environments are **closed** + - Everything else The period of support for these tiers should last until the releases specified above, plus 1 app release cycle(2 weeks). In the case of Firefox ESR this is extended further to allow it land in Debian Stable. @@ -74,16 +74,16 @@ situation, but it's still not good practice to do it in the first place. See Unless you have special requirements, you will want to add the following to your web server configuration when hosting Element Web: -- The `X-Frame-Options: SAMEORIGIN` header, to prevent Element Web from being - framed and protect from [clickjacking][owasp-clickjacking]. -- The `frame-ancestors 'self'` directive to your `Content-Security-Policy` - header, as the modern replacement for `X-Frame-Options` (though both should be - included since not all browsers support it yet, see - [this][owasp-clickjacking-csp]). -- The `X-Content-Type-Options: nosniff` header, to [disable MIME - sniffing][mime-sniffing]. -- The `X-XSS-Protection: 1; mode=block;` header, for basic XSS protection in - legacy browsers. +- The `X-Frame-Options: SAMEORIGIN` header, to prevent Element Web from being + framed and protect from [clickjacking][owasp-clickjacking]. +- The `frame-ancestors 'self'` directive to your `Content-Security-Policy` + header, as the modern replacement for `X-Frame-Options` (though both should be + included since not all browsers support it yet, see + [this][owasp-clickjacking-csp]). +- The `X-Content-Type-Options: nosniff` header, to [disable MIME + sniffing][mime-sniffing]. +- The `X-XSS-Protection: 1; mode=block;` header, for basic XSS protection in + legacy browsers. [mime-sniffing]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#mime_sniffing [owasp-clickjacking-csp]: https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html#content-security-policy-frame-ancestors-examples diff --git a/__mocks__/FontManager.js b/__mocks__/FontManager.js deleted file mode 100644 index 41eab4bf94..0000000000 --- a/__mocks__/FontManager.js +++ /dev/null @@ -1,6 +0,0 @@ -// Stub out FontManager for tests as it doesn't validate anything we don't already know given -// our fixed test environment and it requires the installation of node-canvas. - -module.exports = { - fixupColorFonts: () => Promise.resolve(), -}; diff --git a/code_style.md b/code_style.md index e5f7485cec..9aa6836442 100644 --- a/code_style.md +++ b/code_style.md @@ -3,9 +3,9 @@ This code style applies to projects which the element-web team directly maintains or is reasonably adjacent to. As of writing, these are: -- element-desktop -- element-web -- matrix-js-sdk +- element-desktop +- element-web +- matrix-js-sdk Other projects might extend this code style for increased strictness. For example, matrix-events-sdk has stricter code organization to reduce the maintenance burden. These projects will declare their code diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 23229bf00a..57b017bc1c 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,55 +1,55 @@ # Summary -- [Introduction](../README.md) +- [Introduction](../README.md) # Usage -- [Betas](betas.md) -- [Labs](labs.md) +- [Betas](betas.md) +- [Labs](labs.md) # Setup -- [Install](install.md) -- [Config](config.md) -- [Custom home page](custom-home.md) -- [Kubernetes](kubernetes.md) -- [Jitsi](jitsi.md) -- [Encryption](e2ee.md) +- [Install](install.md) +- [Config](config.md) +- [Custom home page](custom-home.md) +- [Kubernetes](kubernetes.md) +- [Jitsi](jitsi.md) +- [Encryption](e2ee.md) # Build -- [Customisations](customisations.md) -- [Modules](modules.md) -- [Native Node modules](native-node-modules.md) +- [Customisations](customisations.md) +- [Modules](modules.md) +- [Native Node modules](native-node-modules.md) # Contribution -- [Choosing an issue](choosing-an-issue.md) -- [Translation](translating.md) -- [Netlify builds](pr-previews.md) -- [Code review](review.md) +- [Choosing an issue](choosing-an-issue.md) +- [Translation](translating.md) +- [Netlify builds](pr-previews.md) +- [Code review](review.md) # Development -- [App load order](app-load.md) -- [Translation](translating-dev.md) -- [Theming](theming.md) -- [Playwright end to end tests](playwright.md) -- [Memory profiling](memory-profiles-and-leaks.md) -- [Jitsi](jitsi-dev.md) -- [Feature flags](feature-flags.md) -- [OIDC and delegated authentication](oidc.md) -- [Release Process](release.md) +- [App load order](app-load.md) +- [Translation](translating-dev.md) +- [Theming](theming.md) +- [Playwright end to end tests](playwright.md) +- [Memory profiling](memory-profiles-and-leaks.md) +- [Jitsi](jitsi-dev.md) +- [Feature flags](feature-flags.md) +- [OIDC and delegated authentication](oidc.md) +- [Release Process](release.md) # Deep dive -- [Skinning](skinning.md) -- [Cider editor](ciderEditor.md) -- [Iconography](icons.md) -- [Jitsi](jitsi.md) -- [Local echo](local-echo-dev.md) -- [Media](media-handling.md) -- [Room List Store](room-list-store.md) -- [Scrolling](scrolling.md) -- [Usercontent](usercontent.md) -- [Widget layouts](widget-layouts.md) +- [Skinning](skinning.md) +- [Cider editor](ciderEditor.md) +- [Iconography](icons.md) +- [Jitsi](jitsi.md) +- [Local echo](local-echo-dev.md) +- [Media](media-handling.md) +- [Room List Store](room-list-store.md) +- [Scrolling](scrolling.md) +- [Usercontent](usercontent.md) +- [Widget layouts](widget-layouts.md) diff --git a/docs/app-load.md b/docs/app-load.md index 849e95cb8d..7f72b3fea4 100644 --- a/docs/app-load.md +++ b/docs/app-load.md @@ -61,18 +61,18 @@ flowchart TD Key: -- Parallelogram: async/await task -- Box: sync task -- Diamond: conditional branch -- Circle: user interaction -- Blue arrow: async task is allowed to settle but allowed to fail -- Red arrow: async task success is asserted +- Parallelogram: async/await task +- Box: sync task +- Diamond: conditional branch +- Circle: user interaction +- Blue arrow: async task is allowed to settle but allowed to fail +- Red arrow: async task success is asserted Notes: -- A task begins when all its dependencies (arrows going into it) are fulfilled. -- The success of setting up rageshake is never asserted, element-web has a fallback path for running without IDB (and thus rageshake). -- Everything is awaited to be settled before the Modernizr check, to allow it to make use of things like i18n if they are successful. +- A task begins when all its dependencies (arrows going into it) are fulfilled. +- The success of setting up rageshake is never asserted, element-web has a fallback path for running without IDB (and thus rageshake). +- Everything is awaited to be settled before the Modernizr check, to allow it to make use of things like i18n if they are successful. Underlying dependencies: diff --git a/docs/choosing-an-issue.md b/docs/choosing-an-issue.md index 9d008782a1..ca17979367 100644 --- a/docs/choosing-an-issue.md +++ b/docs/choosing-an-issue.md @@ -32,19 +32,19 @@ someone to add something. When you're looking through the list, here are some things that might make an issue a **GOOD** choice: -- It is a problem or feature you care about. -- It concerns a type of code you know a little about. -- You think you can understand what's needed. -- It already has approval from Element Web's designers (look for comments from - members of the - [Product](https://github.com/orgs/element-hq/teams/product/members) or - [Design](https://github.com/orgs/element-hq/teams/design/members) teams). +- It is a problem or feature you care about. +- It concerns a type of code you know a little about. +- You think you can understand what's needed. +- It already has approval from Element Web's designers (look for comments from + members of the + [Product](https://github.com/orgs/element-hq/teams/product/members) or + [Design](https://github.com/orgs/element-hq/teams/design/members) teams). Here are some things that might make it a **BAD** choice: -- You don't understand it (maybe add a comment asking a clarifying question). -- It sounds difficult, or is part of a larger change you don't know about. -- **It is tagged with `X-Needs-Design` or `X-Needs-Product`.** +- You don't understand it (maybe add a comment asking a clarifying question). +- It sounds difficult, or is part of a larger change you don't know about. +- **It is tagged with `X-Needs-Design` or `X-Needs-Product`.** **Element Web's Design and Product teams tend to be very busy**, so if you make changes that require approval from one of those teams, you will probably have diff --git a/docs/config.md b/docs/config.md index cc40179740..8ca4ba4eb8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -455,7 +455,7 @@ If you would like to use Scalar, the integration manager maintained by Element, For widgets in general (from an integration manager or not) there is also: -- `default_widget_container_height` +- `default_widget_container_height` This controls the height that the top widget panel initially appears as and is the height in pixels, default 280. @@ -551,38 +551,38 @@ preferences. Currently, the following UI feature flags are supported: -- `UIFeature.urlPreviews` - Whether URL previews are enabled across the entire application. -- `UIFeature.feedback` - Whether prompts to supply feedback are shown. -- `UIFeature.voip` - Whether or not VoIP is shown readily to the user. When disabled, - Jitsi widgets will still work though they cannot easily be added. -- `UIFeature.widgets` - Whether or not widgets will be shown. -- `UIFeature.advancedSettings` - Whether or not sections titled "advanced" in room and - user settings are shown to the user. -- `UIFeature.shareQrCode` - Whether or not the QR code on the share room/event dialog - is shown. -- `UIFeature.shareSocial` - Whether or not the social icons on the share room/event dialog - are shown. -- `UIFeature.identityServer` - Whether or not functionality requiring an identity server - is shown. When disabled, the user will not be able to interact with the identity - server (sharing email addresses, 3PID invites, etc). -- `UIFeature.thirdPartyId` - Whether or not UI relating to third party identifiers (3PIDs) - is shown. Typically this is considered "contact information" on the homeserver, and is - not directly related to the identity server. -- `UIFeature.registration` - Whether or not the registration page is accessible. Typically - useful if accounts are managed externally. -- `UIFeature.passwordReset` - Whether or not the password reset page is accessible. Typically - useful if accounts are managed externally. -- `UIFeature.deactivate` - Whether or not the deactivate account button is accessible. Typically - useful if accounts are managed externally. -- `UIFeature.advancedEncryption` - Whether or not advanced encryption options are shown to the - user. -- `UIFeature.roomHistorySettings` - Whether or not the room history settings are shown to the user. - This should only be used if the room history visibility options are managed by the server. -- `UIFeature.TimelineEnableRelativeDates` - Display relative date separators (eg: 'Today', 'Yesterday') in the - timeline for recent messages. When false day dates will be used. -- `UIFeature.BulkUnverifiedSessionsReminder` - Display popup reminders to verify or remove unverified sessions. Defaults - to true. -- `UIFeature.locationSharing` - Whether or not location sharing menus will be shown. +- `UIFeature.urlPreviews` - Whether URL previews are enabled across the entire application. +- `UIFeature.feedback` - Whether prompts to supply feedback are shown. +- `UIFeature.voip` - Whether or not VoIP is shown readily to the user. When disabled, + Jitsi widgets will still work though they cannot easily be added. +- `UIFeature.widgets` - Whether or not widgets will be shown. +- `UIFeature.advancedSettings` - Whether or not sections titled "advanced" in room and + user settings are shown to the user. +- `UIFeature.shareQrCode` - Whether or not the QR code on the share room/event dialog + is shown. +- `UIFeature.shareSocial` - Whether or not the social icons on the share room/event dialog + are shown. +- `UIFeature.identityServer` - Whether or not functionality requiring an identity server + is shown. When disabled, the user will not be able to interact with the identity + server (sharing email addresses, 3PID invites, etc). +- `UIFeature.thirdPartyId` - Whether or not UI relating to third party identifiers (3PIDs) + is shown. Typically this is considered "contact information" on the homeserver, and is + not directly related to the identity server. +- `UIFeature.registration` - Whether or not the registration page is accessible. Typically + useful if accounts are managed externally. +- `UIFeature.passwordReset` - Whether or not the password reset page is accessible. Typically + useful if accounts are managed externally. +- `UIFeature.deactivate` - Whether or not the deactivate account button is accessible. Typically + useful if accounts are managed externally. +- `UIFeature.advancedEncryption` - Whether or not advanced encryption options are shown to the + user. +- `UIFeature.roomHistorySettings` - Whether or not the room history settings are shown to the user. + This should only be used if the room history visibility options are managed by the server. +- `UIFeature.TimelineEnableRelativeDates` - Display relative date separators (eg: 'Today', 'Yesterday') in the + timeline for recent messages. When false day dates will be used. +- `UIFeature.BulkUnverifiedSessionsReminder` - Display popup reminders to verify or remove unverified sessions. Defaults + to true. +- `UIFeature.locationSharing` - Whether or not location sharing menus will be shown. ## Undocumented / developer options @@ -592,4 +592,3 @@ The following are undocumented or intended for developer use only. 2. `sync_timeline_limit` 3. `dangerously_allow_unsafe_and_insecure_passwords` 4. `latex_maths_delims`: An optional setting to override the default delimiters used for maths parsing. See https://github.com/matrix-org/matrix-react-sdk/pull/5939 for details. Only used when `feature_latex_maths` is enabled. -5. `voice_broadcast.chunk_length`: Target chunk length in seconds for the Voice Broadcast feature currently under development. diff --git a/docs/customisations.md b/docs/customisations.md index a6f72ab1ab..42cb8c7c5c 100644 --- a/docs/customisations.md +++ b/docs/customisations.md @@ -50,9 +50,9 @@ that properties/state machines won't change. UI for some actions can be hidden via the ComponentVisibility customisation: -- inviting users to rooms and spaces, -- creating rooms, -- creating spaces, +- inviting users to rooms and spaces, +- creating rooms, +- creating spaces, To customise visibility create a customisation module from [ComponentVisibility](https://github.com/element-hq/element-web/blob/master/src/customisations/ComponentVisibility.ts) following the instructions above. diff --git a/docs/e2ee.md b/docs/e2ee.md index 1229f55f38..835c38a1d5 100644 --- a/docs/e2ee.md +++ b/docs/e2ee.md @@ -31,9 +31,9 @@ Set the following on your homeserver's When `force_disable` is true: -- all rooms will be created with encryption disabled, and it will not be possible to enable - encryption from room settings. -- any `io.element.e2ee.default` value will be disregarded. +- all rooms will be created with encryption disabled, and it will not be possible to enable + encryption from room settings. +- any `io.element.e2ee.default` value will be disregarded. Note: If the server is configured to forcibly enable encryption for some or all rooms, this behaviour will be overridden. diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 46e5f1243e..54d54e3b1b 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -5,10 +5,10 @@ flexibility and control over when and where those features are enabled. For example, flags make the following things possible: -- Extended testing of a feature via labs on develop -- Enabling features when ready instead of the first moment the code is released -- Testing a feature with a specific set of users (by enabling only on a specific - Element instance) +- Extended testing of a feature via labs on develop +- Enabling features when ready instead of the first moment the code is released +- Testing a feature with a specific set of users (by enabling only on a specific + Element instance) The size of the feature controlled by a feature flag may vary widely: it could be a large project like reactions or a smaller change to an existing algorithm. diff --git a/docs/features/composer.md b/docs/features/composer.md index 408c78a8d9..1af4c9c894 100644 --- a/docs/features/composer.md +++ b/docs/features/composer.md @@ -2,37 +2,37 @@ ## Auto Complete -- Hitting tab tries to auto-complete the word before the caret as a room member - - If no matching name is found, a visual bell is shown -- @ + a letter opens auto complete for members starting with the given letter - - When inserting a user pill at the start in the composer, a colon and space is appended to the pill - - When inserting a user pill anywhere else in composer, only a space is appended to the pill -- # + a letter opens auto complete for rooms starting with the given letter -- : open auto complete for emoji -- Pressing arrow-up/arrow-down while the autocomplete is open navigates between auto complete options -- Pressing tab while the autocomplete is open goes to the next autocomplete option, - wrapping around at the end after reverting to the typed text first. +- Hitting tab tries to auto-complete the word before the caret as a room member + - If no matching name is found, a visual bell is shown +- @ + a letter opens auto complete for members starting with the given letter + - When inserting a user pill at the start in the composer, a colon and space is appended to the pill + - When inserting a user pill anywhere else in composer, only a space is appended to the pill +- # + a letter opens auto complete for rooms starting with the given letter +- : open auto complete for emoji +- Pressing arrow-up/arrow-down while the autocomplete is open navigates between auto complete options +- Pressing tab while the autocomplete is open goes to the next autocomplete option, + wrapping around at the end after reverting to the typed text first. ## Formatting -- When selecting text, a formatting bar appears above the selection. -- The formatting bar allows to format the selected test as: - bold, italic, strikethrough, a block quote, and a code block (inline if no linebreak is selected). -- Formatting is applied as markdown syntax. -- Hitting ctrl/cmd+B also marks the selected text as bold -- Hitting ctrl/cmd+I also marks the selected text as italic -- Hitting ctrl/cmd+> also marks the selected text as a blockquote +- When selecting text, a formatting bar appears above the selection. +- The formatting bar allows to format the selected test as: + bold, italic, strikethrough, a block quote, and a code block (inline if no linebreak is selected). +- Formatting is applied as markdown syntax. +- Hitting ctrl/cmd+B also marks the selected text as bold +- Hitting ctrl/cmd+I also marks the selected text as italic +- Hitting ctrl/cmd+> also marks the selected text as a blockquote ## Misc -- When hitting the arrow-up button while having the caret at the start in the composer, - the last message sent by the syncing user is edited. -- Clicking a display name on an event in the timeline inserts a user pill into the composer -- Emoticons (like :-), >:-), :-/, ...) are replaced by emojis while typing if the relevant setting is enabled -- Typing in the composer sends typing notifications in the room -- Pressing ctrl/mod+z and ctrl/mod+y undoes/redoes modifications -- Pressing shift+enter inserts a line break -- Pressing enter sends the message. -- Choosing "Quote" in the context menu of an event inserts a quote of the event body in the composer. -- Choosing "Reply" in the context menu of an event shows a preview above the composer to reply to. -- Pressing alt+arrow up/arrow down navigates in previously sent messages, putting them in the composer. +- When hitting the arrow-up button while having the caret at the start in the composer, + the last message sent by the syncing user is edited. +- Clicking a display name on an event in the timeline inserts a user pill into the composer +- Emoticons (like :-), >:-), :-/, ...) are replaced by emojis while typing if the relevant setting is enabled +- Typing in the composer sends typing notifications in the room +- Pressing ctrl/mod+z and ctrl/mod+y undoes/redoes modifications +- Pressing shift+enter inserts a line break +- Pressing enter sends the message. +- Choosing "Quote" in the context menu of an event inserts a quote of the event body in the composer. +- Choosing "Reply" in the context menu of an event shows a preview above the composer to reply to. +- Pressing alt+arrow up/arrow down navigates in previously sent messages, putting them in the composer. diff --git a/docs/icons.md b/docs/icons.md index b0582356ce..449663e24a 100644 --- a/docs/icons.md +++ b/docs/icons.md @@ -8,9 +8,9 @@ Icons have `role="presentation"` and `aria-hidden` automatically applied. These SVG file recommendations: -- Colours should not be defined absolutely. Use `currentColor` instead. -- SVG files should be taken from the design compound as they are. Some icons contain special padding. - This means that there should be icons for each size, e.g. warning-16px and warning-32px. +- Colours should not be defined absolutely. Use `currentColor` instead. +- SVG files should be taken from the design compound as they are. Some icons contain special padding. + This means that there should be icons for each size, e.g. warning-16px and warning-32px. Example usage: diff --git a/docs/jitsi.md b/docs/jitsi.md index 48d1a7bf3e..20e64db379 100644 --- a/docs/jitsi.md +++ b/docs/jitsi.md @@ -81,27 +81,27 @@ which takes several parameters: _Query string_: -- `widgetId`: The ID of the widget. This is needed for communication back to the - react-sdk. -- `parentUrl`: The URL of the parent window. This is also needed for - communication back to the react-sdk. +- `widgetId`: The ID of the widget. This is needed for communication back to the + react-sdk. +- `parentUrl`: The URL of the parent window. This is also needed for + communication back to the react-sdk. _Hash/fragment (formatted as a query string)_: -- `conferenceDomain`: The domain to connect Jitsi Meet to. -- `conferenceId`: The room or conference ID to connect Jitsi Meet to. -- `isAudioOnly`: Boolean for whether this is a voice-only conference. May not - be present, should default to `false`. -- `startWithAudioMuted`: Boolean for whether the calls start with audio - muted. May not be present. -- `startWithVideoMuted`: Boolean for whether the calls start with video - muted. May not be present. -- `displayName`: The display name of the user viewing the widget. May not - be present or could be null. -- `avatarUrl`: The HTTP(S) URL for the avatar of the user viewing the widget. May - not be present or could be null. -- `userId`: The MXID of the user viewing the widget. May not be present or could - be null. +- `conferenceDomain`: The domain to connect Jitsi Meet to. +- `conferenceId`: The room or conference ID to connect Jitsi Meet to. +- `isAudioOnly`: Boolean for whether this is a voice-only conference. May not + be present, should default to `false`. +- `startWithAudioMuted`: Boolean for whether the calls start with audio + muted. May not be present. +- `startWithVideoMuted`: Boolean for whether the calls start with video + muted. May not be present. +- `displayName`: The display name of the user viewing the widget. May not + be present or could be null. +- `avatarUrl`: The HTTP(S) URL for the avatar of the user viewing the widget. May + not be present or could be null. +- `userId`: The MXID of the user viewing the widget. May not be present or could + be null. The react-sdk will assume that `jitsi.html` is at the path of wherever it is currently being served. For example, `https://develop.element.io/jitsi.html` or `vector://webapp/jitsi.html`. diff --git a/docs/playwright.md b/docs/playwright.md index 7eae8e783d..1454c4868f 100644 --- a/docs/playwright.md +++ b/docs/playwright.md @@ -2,10 +2,10 @@ ## Contents -- How to run the tests -- How the tests work -- How to write great Playwright tests -- Visual testing +- How to run the tests +- How the tests work +- How to write great Playwright tests +- Visual testing ## Running the Tests @@ -123,15 +123,15 @@ When a Synapse instance is started, it's given a config generated from one of th templates in `playwright/plugins/homeserver/synapse/templates`. There are a couple of special files in these templates: -- `homeserver.yaml`: - Template substitution happens in this file. Template variables are: - - `REGISTRATION_SECRET`: The secret used to register users via the REST API. - - `MACAROON_SECRET_KEY`: Generated each time for security - - `FORM_SECRET`: Generated each time for security - - `PUBLIC_BASEURL`: The localhost url + port combination the synapse is accessible at -- `localhost.signing.key`: A signing key is auto-generated and saved to this file. - Config templates should not contain a signing key and instead assume that one will exist - in this file. +- `homeserver.yaml`: + Template substitution happens in this file. Template variables are: + - `REGISTRATION_SECRET`: The secret used to register users via the REST API. + - `MACAROON_SECRET_KEY`: Generated each time for security + - `FORM_SECRET`: Generated each time for security + - `PUBLIC_BASEURL`: The localhost url + port combination the synapse is accessible at +- `localhost.signing.key`: A signing key is auto-generated and saved to this file. + Config templates should not contain a signing key and instead assume that one will exist + in this file. All other files in the template are copied recursively to `/data/`, so the file `foo.html` in a template can be referenced in the config as `/data/foo.html`. diff --git a/docs/release.md b/docs/release.md index 5074039374..b2c797b66b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -82,28 +82,28 @@ This label will automagically convert to `X-Release-Blocker` at the conclusion o This release process revolves around our main repositories: -- [Element Desktop](https://github.com/element-hq/element-desktop/) -- [Element Web](https://github.com/element-hq/element-web/) -- [Matrix JS SDK](https://github.com/matrix-org/matrix-js-sdk/) +- [Element Desktop](https://github.com/element-hq/element-desktop/) +- [Element Web](https://github.com/element-hq/element-web/) +- [Matrix JS SDK](https://github.com/matrix-org/matrix-js-sdk/) We own other repositories, but they have more ad-hoc releases and are not part of the bi-weekly cycle: -- https://github.com/matrix-org/matrix-web-i18n/ -- https://github.com/matrix-org/matrix-react-sdk-module-api +- https://github.com/matrix-org/matrix-web-i18n/ +- https://github.com/matrix-org/matrix-react-sdk-module-api

Prerequisites

-- You must be part of the 2 Releasers GitHub groups: - - - - -- You will need access to the **VPN** ([docs](https://gitlab.matrix.org/new-vector/internal/-/wikis/SRE/Tailscale)) to be able to follow the instructions under Deploy below. -- You will need the ability to **SSH** in to the production machines to be able to follow the instructions under Deploy below. Ensure that your SSH key has a non-empty passphrase, and you registered your SSH key with Ops. Log a ticket at https://github.com/matrix-org/matrix-ansible-private and ask for: - - Two-factor authentication to be set up on your SSH key. (This is needed to get access to production). - - SSH access to `horme` (staging.element.io and app.element.io) - - Permission to sudo on horme as the user `element` -- You need "**jumphost**" configuration in your local `~/.ssh/config`. This should have been set up as part of your onboarding. +- You must be part of the 2 Releasers GitHub groups: + - + - +- You will need access to the **VPN** ([docs](https://gitlab.matrix.org/new-vector/internal/-/wikis/SRE/Tailscale)) to be able to follow the instructions under Deploy below. +- You will need the ability to **SSH** in to the production machines to be able to follow the instructions under Deploy below. Ensure that your SSH key has a non-empty passphrase, and you registered your SSH key with Ops. Log a ticket at https://github.com/matrix-org/matrix-ansible-private and ask for: + - Two-factor authentication to be set up on your SSH key. (This is needed to get access to production). + - SSH access to `horme` (staging.element.io and app.element.io) + - Permission to sudo on horme as the user `element` +- You need "**jumphost**" configuration in your local `~/.ssh/config`. This should have been set up as part of your onboarding.
@@ -177,7 +177,7 @@ For security, you may wish to merge the security advisory private fork or apply It is worth noting that at the end of the Final/Hotfix/Security release `staging` is merged to `master` which is merged back into `develop` - this means that any commit which goes to `staging` will eventually make its way back to the default branch. -- [ ] The staging branch is prepared +- [ ] The staging branch is prepared # Releasing @@ -192,21 +192,21 @@ switched back to the version of the dependency from the master branch to not lea ### Matrix JS SDK -- [ ] Check the draft release which has been generated by [the automation](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release-drafter.yml) -- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** -- [ ] Kick off a release using [the automation](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. +- [ ] Check the draft release which has been generated by [the automation](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release-drafter.yml) +- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** +- [ ] Kick off a release using [the automation](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. ### Element Web -- [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/element-web/actions/workflows/release-drafter.yml) -- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** -- [ ] Kick off a release using [the automation](https://github.com/element-hq/element-web/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. +- [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/element-web/actions/workflows/release-drafter.yml) +- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** +- [ ] Kick off a release using [the automation](https://github.com/element-hq/element-web/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. ### Element Desktop -- [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/element-desktop/actions/workflows/release-drafter.yml) -- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** -- [ ] Kick off a release using [the automation](https://github.com/element-hq/element-desktop/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. +- [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/element-desktop/actions/workflows/release-drafter.yml) +- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** +- [ ] Kick off a release using [the automation](https://github.com/element-hq/element-desktop/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. # Deploying @@ -214,23 +214,23 @@ We ship the SDKs to npm, this happens as part of the release process. We ship Element Web to dockerhub, `*.element.io`, and packages.element.io. We ship Element Desktop to packages.element.io. -- [ ] Check that element-web has shipped to dockerhub -- [ ] Deploy staging.element.io. [See docs.](https://handbook.element.io/books/element-web-team/page/deploying-appstagingelementio) -- [ ] Test staging.element.io +- [ ] Check that element-web has shipped to dockerhub +- [ ] Deploy staging.element.io. [See docs.](https://handbook.element.io/books/element-web-team/page/deploying-appstagingelementio) +- [ ] Test staging.element.io For final releases additionally do these steps: -- [ ] Deploy app.element.io. [See docs.](https://handbook.element.io/books/element-web-team/page/deploying-appstagingelementio) -- [ ] Test app.element.io -- [ ] Ensure Element Web package has shipped to packages.element.io -- [ ] Ensure Element Desktop packages have shipped to packages.element.io +- [ ] Deploy app.element.io. [See docs.](https://handbook.element.io/books/element-web-team/page/deploying-appstagingelementio) +- [ ] Test app.element.io +- [ ] Ensure Element Web package has shipped to packages.element.io +- [ ] Ensure Element Desktop packages have shipped to packages.element.io # Housekeeping We have some manual housekeeping to do in order to prepare for the next release. -- [ ] Update topics using [the automation](https://github.com/element-hq/element-web/actions/workflows/update-topics.yaml). It will autodetect the current latest version. Don't forget the date you supply should be e.g. September 5th (including the "th") for the script to work. -- [ ] Announce the release in [#element-web-announcements:matrix.org](https://matrix.to/#/#element-web-announcements:matrix.org) +- [ ] Update topics using [the automation](https://github.com/element-hq/element-web/actions/workflows/update-topics.yaml). It will autodetect the current latest version. Don't forget the date you supply should be e.g. September 5th (including the "th") for the script to work. +- [ ] Announce the release in [#element-web-announcements:matrix.org](https://matrix.to/#/#element-web-announcements:matrix.org)
(show) @@ -246,15 +246,15 @@ With wording like: For the first RC of a given release cycle do these steps: -- [ ] Go to the [matrix-js-sdk Renovate dashboard](https://github.com/matrix-org/matrix-js-sdk/issues/2406) and click the checkbox to create/update its PRs. +- [ ] Go to the [matrix-js-sdk Renovate dashboard](https://github.com/matrix-org/matrix-js-sdk/issues/2406) and click the checkbox to create/update its PRs. -- [ ] Go to the [element-web Renovate dashboard](https://github.com/element-hq/element-web/issues/22941) and click the checkbox to create/update its PRs. +- [ ] Go to the [element-web Renovate dashboard](https://github.com/element-hq/element-web/issues/22941) and click the checkbox to create/update its PRs. -- [ ] Go to the [element-desktop Renovate dashboard](https://github.com/element-hq/element-desktop/issues/465) and click the checkbox to create/update its PRs. +- [ ] Go to the [element-desktop Renovate dashboard](https://github.com/element-hq/element-desktop/issues/465) and click the checkbox to create/update its PRs. -- [ ] Later, check back and merge the PRs that succeeded to build. The ones that failed will get picked up by the [maintainer](https://docs.google.com/document/d/1V5VINWXATMpz9UBw4IKmVVB8aw3CxM0Jt7igtHnDfSk/edit#). +- [ ] Later, check back and merge the PRs that succeeded to build. The ones that failed will get picked up by the [maintainer](https://docs.google.com/document/d/1V5VINWXATMpz9UBw4IKmVVB8aw3CxM0Jt7igtHnDfSk/edit#). For final releases additionally do these steps: -- [ ] Archive done column on the [team board](https://github.com/orgs/element-hq/projects/67/views/34) _Note: this should be automated_ -- [ ] Add entry to the [milestones diary](https://docs.google.com/document/d/1cpRFJdfNCo2Ps6jqzQmatzbYEToSrQpyBug0aP_iwZE/edit#heading=h.6y55fw4t283z). The document says only to add significant releases, but we add all of them just in case. +- [ ] Archive done column on the [team board](https://github.com/orgs/element-hq/projects/67/views/34) _Note: this should be automated_ +- [ ] Add entry to the [milestones diary](https://docs.google.com/document/d/1cpRFJdfNCo2Ps6jqzQmatzbYEToSrQpyBug0aP_iwZE/edit#heading=h.6y55fw4t283z). The document says only to add significant releases, but we add all of them just in case. diff --git a/docs/review.md b/docs/review.md index 8f8dc5f09b..c565db5297 100644 --- a/docs/review.md +++ b/docs/review.md @@ -10,53 +10,53 @@ When reviewing code, here are some things we look for and also things we avoid: ### We review for -- Correctness -- Performance -- Accessibility -- Security -- Quality via automated and manual testing -- Comments and documentation where needed -- Sharing knowledge of different areas among the team -- Ensuring it's something we're comfortable maintaining for the long term -- Progress indicators and local echo where appropriate with network activity +- Correctness +- Performance +- Accessibility +- Security +- Quality via automated and manual testing +- Comments and documentation where needed +- Sharing knowledge of different areas among the team +- Ensuring it's something we're comfortable maintaining for the long term +- Progress indicators and local echo where appropriate with network activity ### We should avoid -- Style nits that are already handled by the linter -- Dramatically increasing scope +- Style nits that are already handled by the linter +- Dramatically increasing scope ### Good practices -- Use empathetic language - - See also [Mindful Communication in Code - Reviews](https://kickstarter.engineering/a-guide-to-mindful-communication-in-code-reviews-48aab5282e5e) - and [How to Do Code Reviews Like a Human](https://mtlynch.io/human-code-reviews-1/) -- Authors should prefer smaller commits for easier reviewing and bisection -- Reviewers should be explicit about required versus optional changes - - Reviews are conversations and the PR author should feel comfortable - discussing and pushing back on changes before making them -- Reviewers are encouraged to ask for tests where they believe it is reasonable -- Core team should lead by example through their tone and language -- Take the time to thank and point out good code changes -- Using softer language like "please" and "what do you think?" goes a long way - towards making others feel like colleagues working towards a common goal +- Use empathetic language + - See also [Mindful Communication in Code + Reviews](https://kickstarter.engineering/a-guide-to-mindful-communication-in-code-reviews-48aab5282e5e) + and [How to Do Code Reviews Like a Human](https://mtlynch.io/human-code-reviews-1/) +- Authors should prefer smaller commits for easier reviewing and bisection +- Reviewers should be explicit about required versus optional changes + - Reviews are conversations and the PR author should feel comfortable + discussing and pushing back on changes before making them +- Reviewers are encouraged to ask for tests where they believe it is reasonable +- Core team should lead by example through their tone and language +- Take the time to thank and point out good code changes +- Using softer language like "please" and "what do you think?" goes a long way + towards making others feel like colleagues working towards a common goal ### Workflow -- Authors should request review from the element-web team by default (if someone on - the team is clearly the expert in an area, a direct review request to them may - be more appropriate) -- Reviewers should remove the team review request and request review from - themselves when starting a review to avoid double review -- If there are multiple related PRs authors should reference each of the PRs in - the others before requesting review. Reviewers might start reviewing from - different places and could miss other required PRs. -- Avoid force pushing to a PR after the first round of review -- Use the GitHub default of merge commits when landing (avoid alternate options - like squash or rebase) -- PR author merges after review (assuming they have write access) -- Assign issues only when in progress to indicate to others what can be picked - up +- Authors should request review from the element-web team by default (if someone on + the team is clearly the expert in an area, a direct review request to them may + be more appropriate) +- Reviewers should remove the team review request and request review from + themselves when starting a review to avoid double review +- If there are multiple related PRs authors should reference each of the PRs in + the others before requesting review. Reviewers might start reviewing from + different places and could miss other required PRs. +- Avoid force pushing to a PR after the first round of review +- Use the GitHub default of merge commits when landing (avoid alternate options + like squash or rebase) +- PR author merges after review (assuming they have write access) +- Assign issues only when in progress to indicate to others what can be picked + up ## Code Quality @@ -64,10 +64,10 @@ In the past, we have occasionally written different kinds of tests for Element and the SDKs, but it hasn't been a consistent focus. Going forward, we'd like to change that. -- For new features, code reviewers will expect some form of automated testing to - be included by default -- For bug fixes, regression tests are of course great to have, but we don't want - to block fixes on this, so we won't require them at this time +- For new features, code reviewers will expect some form of automated testing to + be included by default +- For bug fixes, regression tests are of course great to have, but we don't want + to block fixes on this, so we won't require them at this time The above policy is not a strict rule, but instead it's meant to be a conversation between the author and reviewer. As an author, try to think about @@ -104,10 +104,10 @@ perspective. In more detail, our usual process for changes that affect the UI or alter user functionality is: -- For changes that will go live when merged, always flag Design and Product - teams as appropriate -- For changes guarded by a feature flag, Design and Product review is not - required (though may still be useful) since we can continue tweaking +- For changes that will go live when merged, always flag Design and Product + teams as appropriate +- For changes guarded by a feature flag, Design and Product review is not + required (though may still be useful) since we can continue tweaking As it can be difficult to review design work from looking at just the changed files in a PR, a [preview site](./pr-previews.md) that includes your changes diff --git a/docs/room-list-store.md b/docs/room-list-store.md index b87bf5f7bd..4e131ee309 100644 --- a/docs/room-list-store.md +++ b/docs/room-list-store.md @@ -6,11 +6,11 @@ It's so complicated it needs its own README. Legend: -- Orange = External event. -- Purple = Deterministic flow. -- Green = Algorithm definition. -- Red = Exit condition/point. -- Blue = Process definition. +- Orange = External event. +- Purple = Deterministic flow. +- Green = Algorithm definition. +- Red = Exit condition/point. +- Blue = Process definition. ## Algorithms involved @@ -68,14 +68,14 @@ simply get the manual sorting algorithm applied to them with no further involvem algorithm. There are 4 categories: Red, Grey, Bold, and Idle. Each has their own definition based off relative (perceived) importance to the user: -- **Red**: The room has unread mentions waiting for the user. -- **Grey**: The room has unread notifications waiting for the user. Notifications are simply unread - messages which cause a push notification or badge count. Typically, this is the default as rooms get - set to 'All Messages'. -- **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without - a badge/notification count (or 'Mentions Only'/'Muted'). -- **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user - last read it. +- **Red**: The room has unread mentions waiting for the user. +- **Grey**: The room has unread notifications waiting for the user. Notifications are simply unread + messages which cause a push notification or badge count. Typically, this is the default as rooms get + set to 'All Messages'. +- **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without + a badge/notification count (or 'Mentions Only'/'Muted'). +- **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user + last read it. Conveniently, each tag gets ordered by those categories as presented: red rooms appear above grey, grey above bold, etc. diff --git a/docs/settings.md b/docs/settings.md index 3f0636d380..e555cd7c1e 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -10,13 +10,13 @@ of dealing with the different levels and exposes easy to use getters and setters Granular Settings rely on a series of known levels in order to use the correct value for the scenario. These levels, in order of priority, are: -- `device` - The current user's device -- `room-device` - The current user's device, but only when in a specific room -- `room-account` - The current user's account, but only when in a specific room -- `account` - The current user's account -- `room` - A specific room (setting for all members of the room) -- `config` - Values are defined by the `setting_defaults` key (usually) in `config.json` -- `default` - The hardcoded default for the settings +- `device` - The current user's device +- `room-device` - The current user's device, but only when in a specific room +- `room-account` - The current user's account, but only when in a specific room +- `account` - The current user's account +- `room` - A specific room (setting for all members of the room) +- `config` - Values are defined by the `setting_defaults` key (usually) in `config.json` +- `default` - The hardcoded default for the settings Individual settings may control which levels are appropriate for them as part of the defaults. This is often to ensure that room administrators cannot force account-only settings upon participants. diff --git a/docs/translating-dev.md b/docs/translating-dev.md index e2a8e2c82a..fd1ac23294 100644 --- a/docs/translating-dev.md +++ b/docs/translating-dev.md @@ -2,9 +2,9 @@ ## Requirements -- A working [Development Setup](../README.md#setting-up-a-dev-environment) -- Latest LTS version of Node.js installed -- Be able to understand English +- A working [Development Setup](../README.md#setting-up-a-dev-environment) +- Latest LTS version of Node.js installed +- Be able to understand English ## Translating strings vs. marking strings for translation @@ -65,17 +65,17 @@ There you can also require all translations to be redone if the meaning of the s 1. Add it to the array in `_t` for example `_t(TKEY, {variable: this.variable})` 1. Add the variable inside the string. The syntax for variables is `%(variable)s`. Please note the _s_ at the end. The name of the variable has to match the previous used name. -- You can use the special `count` variable to choose between multiple versions of the same string, in order to get the correct pluralization. E.g. `_t('You have %(count)s new messages', { count: 2 })` would show 'You have 2 new messages', while `_t('You have %(count)s new messages', { count: 1 })` would show 'You have one new message' (assuming a singular version of the string has been added to the translation file. See above). Passing in `count` is much preferred over having an if-statement choose the correct string to use, because some languages have much more complicated plural rules than english (e.g. they might need a completely different form if there are three things rather than two). -- If you want to translate text that includes e.g. hyperlinks or other HTML you have to also use tag substitution, e.g. `_t('Click here!', {}, { 'a': (sub) => {sub} })`. If you don't do the tag substitution you will end up showing literally '' rather than making a hyperlink. -- You can also use React components with normal variable substitution if you want to insert HTML markup, e.g. `_t('Your email address is %(emailAddress)s', { emailAddress: {userEmailAddress} })`. +- You can use the special `count` variable to choose between multiple versions of the same string, in order to get the correct pluralization. E.g. `_t('You have %(count)s new messages', { count: 2 })` would show 'You have 2 new messages', while `_t('You have %(count)s new messages', { count: 1 })` would show 'You have one new message' (assuming a singular version of the string has been added to the translation file. See above). Passing in `count` is much preferred over having an if-statement choose the correct string to use, because some languages have much more complicated plural rules than english (e.g. they might need a completely different form if there are three things rather than two). +- If you want to translate text that includes e.g. hyperlinks or other HTML you have to also use tag substitution, e.g. `_t('Click here!', {}, { 'a': (sub) => {sub} })`. If you don't do the tag substitution you will end up showing literally '' rather than making a hyperlink. +- You can also use React components with normal variable substitution if you want to insert HTML markup, e.g. `_t('Your email address is %(emailAddress)s', { emailAddress: {userEmailAddress} })`. ## Things to know/Style Guides -- Do not use `_t()` inside `getDefaultProps`: the translations aren't loaded when `getDefaultProps` is called, leading to missing translations. Use `_td()` to indicate that `_t()` will be called on the string later. -- If using translated strings as constants, translated strings can't be in constants loaded at class-load time since the translations won't be loaded. Mark the strings using `_td()` instead and perform the actual translation later. -- If a string is presented in the UI with punctuation like a full stop, include this in the translation strings, since punctuation varies between languages too. -- Avoid "translation in parts", i.e. concatenating translated strings or using translated strings in variable substitutions. Context is important for translations, and translating partial strings this way is simply not always possible. -- Concatenating strings often also introduces an implicit assumption about word order (e.g. that the subject of the sentence comes first), which is incorrect for many languages. -- Translation 'smell test': If you have a string that does not begin with a capital letter (is not the start of a sentence) or it ends with e.g. ':' or a preposition (e.g. 'to') you should recheck that you are not trying to translate a partial sentence. -- If you have multiple strings, that are almost identical, except some part (e.g. a word or two) it is still better to translate the full sentence multiple times. It may seem like inefficient repetition, but unlike programming where you try to minimize repetition, translation is much faster if you have many, full, clear, sentences to work with, rather than fewer, but incomplete sentence fragments. -- Don't forget curly braces when you assign an expression to JSX attributes in the render method) +- Do not use `_t()` inside `getDefaultProps`: the translations aren't loaded when `getDefaultProps` is called, leading to missing translations. Use `_td()` to indicate that `_t()` will be called on the string later. +- If using translated strings as constants, translated strings can't be in constants loaded at class-load time since the translations won't be loaded. Mark the strings using `_td()` instead and perform the actual translation later. +- If a string is presented in the UI with punctuation like a full stop, include this in the translation strings, since punctuation varies between languages too. +- Avoid "translation in parts", i.e. concatenating translated strings or using translated strings in variable substitutions. Context is important for translations, and translating partial strings this way is simply not always possible. +- Concatenating strings often also introduces an implicit assumption about word order (e.g. that the subject of the sentence comes first), which is incorrect for many languages. +- Translation 'smell test': If you have a string that does not begin with a capital letter (is not the start of a sentence) or it ends with e.g. ':' or a preposition (e.g. 'to') you should recheck that you are not trying to translate a partial sentence. +- If you have multiple strings, that are almost identical, except some part (e.g. a word or two) it is still better to translate the full sentence multiple times. It may seem like inefficient repetition, but unlike programming where you try to minimize repetition, translation is much faster if you have many, full, clear, sentences to work with, rather than fewer, but incomplete sentence fragments. +- Don't forget curly braces when you assign an expression to JSX attributes in the render method) diff --git a/docs/translating.md b/docs/translating.md index 657b8cebbc..2b82453f93 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -2,9 +2,9 @@ ## Requirements -- Web Browser -- Be able to understand English -- Be able to understand the language you want to translate Element into +- Web Browser +- Be able to understand English +- Be able to understand the language you want to translate Element into ## Join #element-translations:matrix.org diff --git a/jest.config.ts b/jest.config.ts index 04f1a91e77..326f2040d9 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -32,7 +32,6 @@ const config: Config = { "decoderWorker\\.min\\.wasm": "/__mocks__/empty.js", "waveWorker\\.min\\.js": "/__mocks__/empty.js", "context-filter-polyfill": "/__mocks__/empty.js", - "FontManager.ts": "/__mocks__/FontManager.js", "workers/(.+)Factory": "/__mocks__/workerFactoryMock.js", "^!!raw-loader!.*": "jest-raw-loader", "recorderWorkletFactory": "/__mocks__/empty.js", diff --git a/package.json b/package.json index e01c670965..12a77864aa 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": { @@ -73,7 +73,7 @@ "resolutions": { "oidc-client-ts": "3.1.0", "jwt-decode": "4.0.0", - "caniuse-lite": "1.0.30001679", + "caniuse-lite": "1.0.30001684", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0", "wrap-ansi": "npm:wrap-ansi@^7.0.0" }, @@ -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", @@ -114,10 +114,10 @@ "jsrsasign": "^11.0.0", "jszip": "^3.7.0", "katex": "^0.16.0", - "linkify-element": "4.1.3", - "linkify-react": "4.1.3", - "linkify-string": "4.1.3", - "linkifyjs": "4.1.3", + "linkify-element": "4.1.4", + "linkify-react": "4.1.4", + "linkify-string": "4.1.4", + "linkifyjs": "4.1.4", "lodash": "^4.17.21", "maplibre-gl": "^4.0.0", "matrix-encrypt-attachment": "^1.0.3", @@ -268,11 +268,12 @@ "postcss-preset-env": "^10.0.0", "postcss-scss": "^4.0.4", "postcss-simple-vars": "^7.0.1", - "prettier": "3.3.3", + "prettier": "3.4.1", "process": "^0.11.10", "raw-loader": "^4.0.2", "rimraf": "^6.0.0", "semver": "^7.5.2", + "source-map-loader": "^5.0.0", "stylelint": "^16.1.0", "stylelint-config-standard": "^36.0.0", "stylelint-scss": "^6.0.0", diff --git a/playwright/Dockerfile b/playwright/Dockerfile index 9d478ff231..2b30c416f7 100644 --- a/playwright/Dockerfile +++ b/playwright/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/playwright:v1.48.2-jammy +FROM mcr.microsoft.com/playwright:v1.49.0-jammy WORKDIR /work diff --git a/playwright/e2e/crypto/decryption-failure-messages.spec.ts b/playwright/e2e/crypto/decryption-failure-messages.spec.ts index ce7ca34d8e..b2a1209a70 100644 --- a/playwright/e2e/crypto/decryption-failure-messages.spec.ts +++ b/playwright/e2e/crypto/decryption-failure-messages.spec.ts @@ -67,6 +67,9 @@ test.describe("Cryptography", function () { await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click(); await app.viewRoomByName("Test room"); + // In this case, the call to cryptoApi.isEncryptionEnabledInRoom is taking a long time to resolve + await page.waitForTimeout(1000); + // There should be two historical events in the timeline const tiles = await page.locator(".mx_EventTile").all(); expect(tiles.length).toBeGreaterThanOrEqual(2); diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts index b5d3790aaa..c6382f1d72 100644 --- a/playwright/e2e/crypto/event-shields.spec.ts +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -16,6 +16,7 @@ import { logOutOfElement, verify, } from "./utils"; +import { bootstrapCrossSigningForClient } from "../../pages/client.ts"; test.describe("Cryptography", function () { test.use({ @@ -307,5 +308,30 @@ test.describe("Cryptography", function () { const penultimate = page.locator(".mx_EventTile").filter({ hasText: "test encrypted from verified" }); await expect(penultimate.locator(".mx_EventTile_e2eIcon")).not.toBeVisible(); }); + + test("should show correct shields on events sent by users with changed identity", async ({ + page, + app, + bot: bob, + homeserver, + }) => { + // Verify Bob + await verify(app, bob); + + // Bob logs in a new device and resets cross-signing + const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); + await bootstrapCrossSigningForClient(await bobSecondDevice.prepareClient(), bob.credentials, true); + + /* should show an error for a message from a previously verified device */ + await bobSecondDevice.sendMessage(testRoomId, "test encrypted from user that was previously verified"); + const last = page.locator(".mx_EventTile_last"); + await expect(last).toContainText("test encrypted from user that was previously verified"); + const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await lastE2eIcon.focus(); + await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText( + "Sender's verified identity has changed", + ); + }); }); }); diff --git a/playwright/e2e/read-receipts/readme.md b/playwright/e2e/read-receipts/readme.md index 4e4dce297f..33bcfeb93d 100644 --- a/playwright/e2e/read-receipts/readme.md +++ b/playwright/e2e/read-receipts/readme.md @@ -2,19 +2,19 @@ Tips for writing these tests: -- Break up your tests into the smallest test case possible. The purpose of - these tests is to understand hard-to-find bugs, so small tests are necessary. - We know that Playwright recommends combining tests together for performance, but - that will frustrate our goals here. (We will need to find a different way to - reduce CI time.) +- Break up your tests into the smallest test case possible. The purpose of + these tests is to understand hard-to-find bugs, so small tests are necessary. + We know that Playwright recommends combining tests together for performance, but + that will frustrate our goals here. (We will need to find a different way to + reduce CI time.) -- Try to assert something after every action, to make sure it has completed. - E.g.: - markAsRead(room2); - assertRead(room2); - You should especially follow this rule if you are jumping to a different - room or similar straight afterward. +- Try to assert something after every action, to make sure it has completed. + E.g.: + markAsRead(room2); + assertRead(room2); + You should especially follow this rule if you are jumping to a different + room or similar straight afterward. -- Use assertStillRead() if you are asserting something is read when it was - also read before. This waits a little while to make sure you're not getting a - false positive. +- Use assertStillRead() if you are asserting something is read when it was + also read before. This waits a little while to make sure you're not getting a + false positive. diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index ea95a6bbdc..f789ca543b 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -20,7 +20,7 @@ import { randB64Bytes } from "../../utils/rand"; // Docker tag to use for synapse docker image. // We target a specific digest as every now and then a Synapse update will break our CI. // This digest is updated by the playwright-image-updates.yaml workflow periodically. -const DOCKER_TAG = "develop@sha256:d947f40999b060ad4856c0af741b8619fa131430a29922606e374fdba532082b"; +const DOCKER_TAG = "develop@sha256:489fe921e03440af87e001106c41c70ffc55a1e8078d1a7f45e16fbaddc5088a"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); diff --git a/playwright/plugins/oauth_server/README.md b/playwright/plugins/oauth_server/README.md index 541756384f..5260704a66 100644 --- a/playwright/plugins/oauth_server/README.md +++ b/playwright/plugins/oauth_server/README.md @@ -4,16 +4,16 @@ A very simple OAuth identity provider server. The following endpoints are exposed: -- `/oauth/auth.html`: An OAuth2 [authorization endpoint](https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint). - In a proper OAuth2 system, this would prompt the user to log in; we just give a big "Submit" button (and an - auth code that can be changed if we want the next step to fail). It redirects back to the calling application - with a "code". +- `/oauth/auth.html`: An OAuth2 [authorization endpoint](https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint). + In a proper OAuth2 system, this would prompt the user to log in; we just give a big "Submit" button (and an + auth code that can be changed if we want the next step to fail). It redirects back to the calling application + with a "code". -- `/oauth/token`: An OAuth2 [token endpoint](https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint). - Receives the code issued by "auth.html" and, if it is valid, exchanges it for an OAuth2 access token. +- `/oauth/token`: An OAuth2 [token endpoint](https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint). + Receives the code issued by "auth.html" and, if it is valid, exchanges it for an OAuth2 access token. -- `/oauth/userinfo`: An OAuth2 [userinfo endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo). - Returns details about the owner of the offered access token. +- `/oauth/userinfo`: An OAuth2 [userinfo endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo). + Returns details about the owner of the offered access token. To start the server, do: 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/res/css/_components.pcss b/res/css/_components.pcss index 12239fac2d..e9a53cd43c 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -319,6 +319,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"; @@ -392,9 +393,3 @@ @import "./views/voip/_LegacyCallViewHeader.pcss"; @import "./views/voip/_LegacyCallViewSidebar.pcss"; @import "./views/voip/_VideoFeed.pcss"; -@import "./voice-broadcast/atoms/_LiveBadge.pcss"; -@import "./voice-broadcast/atoms/_VoiceBroadcastControl.pcss"; -@import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss"; -@import "./voice-broadcast/atoms/_VoiceBroadcastRecordingConnectionError.pcss"; -@import "./voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss"; -@import "./voice-broadcast/molecules/_VoiceBroadcastBody.pcss"; diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index 741a4e90dc..d24a6e4ac7 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -22,20 +22,6 @@ Please see LICENSE files in the repository root for full details. pointer-events: none; /* makes the avatar non-draggable */ } } - - .mx_UserMenu_userAvatarLive { - align-items: center; - background-color: $alert; - border-radius: 6px; - color: $live-badge-color; - display: flex; - height: 12px; - justify-content: center; - left: 25px; - position: absolute; - top: 20px; - width: 12px; - } } .mx_UserMenu_contextMenuButton { diff --git a/res/css/views/rooms/_MessageComposer.pcss b/res/css/views/rooms/_MessageComposer.pcss index 3f11e9fa6c..73ac15c9c9 100644 --- a/res/css/views/rooms/_MessageComposer.pcss +++ b/res/css/views/rooms/_MessageComposer.pcss @@ -256,10 +256,6 @@ Please see LICENSE files in the repository root for full details. mask-image: url("@vector-im/compound-design-tokens/icons/mic-on-solid.svg"); } -.mx_MessageComposer_voiceBroadcast::before { - mask-image: url("$(res)/img/element-icons/live.svg"); -} - .mx_MessageComposer_plain_text::before { mask-image: url("$(res)/img/element-icons/room/composer/plain_text.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/voice-broadcast/atoms/_LiveBadge.pcss b/res/css/voice-broadcast/atoms/_LiveBadge.pcss deleted file mode 100644 index 7d5f23819b..0000000000 --- a/res/css/voice-broadcast/atoms/_LiveBadge.pcss +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -.mx_LiveBadge { - align-items: center; - background-color: $alert; - border-radius: 2px; - color: $live-badge-color; - display: inline-flex; - font-size: $font-12px; - font-weight: var(--cpd-font-weight-semibold); - gap: $spacing-4; - padding: 2px 4px; -} - -.mx_LiveBadge--grey { - background-color: $quaternary-content; -} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss deleted file mode 100644 index 5bd7bfe098..0000000000 --- a/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -.mx_VoiceBroadcastControl { - align-items: center; - background-color: $background; - border-radius: 50%; - color: $secondary-content; - display: flex; - flex: 0 0 32px; - height: 32px; - justify-content: center; - width: 32px; -} - -.mx_VoiceBroadcastControl-recording { - color: $alert; -} - -.mx_VoiceBroadcastControl-play .mx_Icon { - left: 1px; - position: relative; -} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss deleted file mode 100644 index c5e21233b7..0000000000 --- a/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss +++ /dev/null @@ -1,60 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -.mx_VoiceBroadcastHeader { - align-items: flex-start; - display: flex; - gap: $spacing-8; - line-height: 20px; - margin-bottom: $spacing-16; - min-width: 0; -} - -.mx_VoiceBroadcastHeader_content { - flex-grow: 1; - min-width: 0; -} - -.mx_VoiceBroadcastHeader_room_wrapper { - align-items: center; - display: flex; - gap: 4px; - justify-content: flex-start; -} - -.mx_VoiceBroadcastHeader_room { - font-size: $font-12px; - font-weight: var(--cpd-font-weight-semibold); - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mx_VoiceBroadcastHeader_line { - align-items: center; - color: $secondary-content; - font-size: $font-12px; - display: flex; - gap: $spacing-4; - - .mx_Spinner { - flex: 0 0 14px; - padding: 1px; - } - - span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -.mx_VoiceBroadcastHeader_mic--clickable { - cursor: pointer; -} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastRecordingConnectionError.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastRecordingConnectionError.pcss deleted file mode 100644 index f21c0bb733..0000000000 --- a/res/css/voice-broadcast/atoms/_VoiceBroadcastRecordingConnectionError.pcss +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -.mx_VoiceBroadcastRecordingConnectionError { - align-items: center; - color: $alert; - display: flex; - gap: $spacing-12; - - svg path { - fill: $alert; - } -} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss deleted file mode 100644 index e0748e7626..0000000000 --- a/res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss +++ /dev/null @@ -1,14 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -.mx_RoomTile .mx_RoomTile_titleContainer .mx_RoomTile_subtitle.mx_RoomTile_subtitle--voice-broadcast { - align-items: center; - color: $alert; - display: flex; - gap: $spacing-4; -} diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss deleted file mode 100644 index 45ed0e98f9..0000000000 --- a/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss +++ /dev/null @@ -1,75 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -.mx_VoiceBroadcastBody { - background-color: $quinary-content; - border-radius: 8px; - color: $secondary-content; - display: inline-block; - font-size: $font-12px; - padding: $spacing-12; - width: 271px; - - .mx_Clock { - line-height: 1; - } -} - -.mx_VoiceBroadcastBody--pip { - background-color: $system; - box-shadow: 0 2px 8px 0 #0000004a; -} - -.mx_VoiceBroadcastBody--small { - display: flex; - gap: $spacing-8; - width: 192px; - - .mx_VoiceBroadcastHeader { - margin-bottom: 0; - } - - .mx_VoiceBroadcastControl { - align-self: center; - } - - .mx_LiveBadge { - margin-top: 4px; - } -} - -.mx_VoiceBroadcastBody_divider { - background-color: $quinary-content; - border: 0; - height: 1px; - margin: $spacing-12 0; -} - -.mx_VoiceBroadcastBody_controls { - align-items: center; - display: flex; - gap: $spacing-32; - justify-content: center; - margin-bottom: $spacing-8; -} - -.mx_VoiceBroadcastBody_timerow { - display: flex; - justify-content: space-between; -} - -.mx_AccessibleButton.mx_VoiceBroadcastBody_blockButton { - display: flex; - gap: $spacing-8; -} - -.mx_VoiceBroadcastBody__small-close { - right: 8px; - position: absolute; - top: 8px; -} diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 deleted file mode 100644 index 90f444b1a1..0000000000 Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 and /dev/null differ diff --git a/res/themes/dark/css/_dark.pcss b/res/themes/dark/css/_dark.pcss index 8b0673f692..2d3ea2e4f4 100644 --- a/res/themes/dark/css/_dark.pcss +++ b/res/themes/dark/css/_dark.pcss @@ -240,11 +240,6 @@ $location-live-secondary-color: #deddfd; } /* ******************** */ -/* Voice Broadcast */ -/* ******************** */ -$live-badge-color: #ffffff; -/* ******************** */ - /* One-off colors */ /* ******************** */ $progressbar-bg-color: var(--cpd-color-gray-200); diff --git a/res/themes/legacy-dark/css/_legacy-dark.pcss b/res/themes/legacy-dark/css/_legacy-dark.pcss index 45bb1870f1..ea5228b6c7 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.pcss +++ b/res/themes/legacy-dark/css/_legacy-dark.pcss @@ -226,11 +226,6 @@ $location-live-color: #5c56f5; $location-live-secondary-color: #deddfd; /* ******************** */ -/* Voice Broadcast */ -/* ******************** */ -$live-badge-color: #ffffff; -/* ******************** */ - body { color-scheme: dark; } diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss index 76e0eec588..32ca7d3d1a 100644 --- a/res/themes/legacy-light/css/_legacy-light.pcss +++ b/res/themes/legacy-light/css/_legacy-light.pcss @@ -325,11 +325,6 @@ $location-live-color: #5c56f5; $location-live-secondary-color: #deddfd; /* ******************** */ -/* Voice Broadcast */ -/* ******************** */ -$live-badge-color: #ffffff; -/* ******************** */ - body { color-scheme: light; } diff --git a/res/themes/light/css/_fonts.pcss b/res/themes/light/css/_fonts.pcss index 62613fcee5..a9d31f2b2b 100644 --- a/res/themes/light/css/_fonts.pcss +++ b/res/themes/light/css/_fonts.pcss @@ -143,3 +143,21 @@ $inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, U+25c2 unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } + +/* Twemoji COLR */ +@font-face { + font-family: "Twemoji"; + font-weight: 400; + src: url("$(res)/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2") format("woff2"); +} +/* For at least Chrome on Windows 10, we have to explictly add extra weights for the emoji to appear in bold messages, etc. */ +@font-face { + font-family: "Twemoji"; + font-weight: 600; + src: url("$(res)/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2") format("woff2"); +} +@font-face { + font-family: "Twemoji"; + font-weight: 700; + src: url("$(res)/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2") format("woff2"); +} diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss index 5f278c6f16..1a1705a9c1 100644 --- a/res/themes/light/css/_light.pcss +++ b/res/themes/light/css/_light.pcss @@ -10,8 +10,8 @@ /* Noto Color Emoji contains digits, in fixed-width, therefore causing digits in flowed text to stand out. TODO: Consider putting all emoji fonts to the end rather than the front. */ -$font-family: "Inter", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Arial", "Helvetica", sans-serif, - "Noto Color Emoji"; +$font-family: "Inter", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Arial", "Helvetica", + sans-serif, "Noto Color Emoji"; $monospace-font-family: "Inconsolata", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace, "Noto Color Emoji"; @@ -355,11 +355,6 @@ $location-live-color: var(--cpd-color-purple-900); $location-live-secondary-color: var(--cpd-color-purple-600); /* ******************** */ -/* Voice Broadcast */ -/* ******************** */ -$live-badge-color: var(--cpd-color-icon-on-solid-primary); -/* ******************** */ - body { color-scheme: light; } diff --git a/scripts/copy-i18n.py b/scripts/copy-i18n.py deleted file mode 100755 index 07b1271239..0000000000 --- a/scripts/copy-i18n.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python - -import json -import sys -import os - -if len(sys.argv) < 3: - print "Usage: %s " % (sys.argv[0],) - print "eg. %s pt_BR.json pt.json" % (sys.argv[0],) - print - print "Adds any translations to that exist in but not " - sys.exit(1) - -srcpath = sys.argv[1] -dstpath = sys.argv[2] -tmppath = dstpath + ".tmp" - -with open(srcpath) as f: - src = json.load(f) - -with open(dstpath) as f: - dst = json.load(f) - -toAdd = {} -for k,v in src.iteritems(): - if k not in dst: - print "Adding %s" % (k,) - toAdd[k] = v - -# don't just json.dumps as we'll probably re-order all the keys (and they're -# not in any given order so we can't just sort_keys). Append them to the end. -with open(dstpath) as ifp: - with open(tmppath, 'w') as ofp: - for line in ifp: - strippedline = line.strip() - if strippedline in ('{', '}'): - ofp.write(line) - elif strippedline.endswith(','): - ofp.write(line) - else: - ofp.write(' '+strippedline+',') - toAddStr = json.dumps(toAdd, indent=4, separators=(',', ': '), ensure_ascii=False, encoding="utf8").strip("{}\n") - ofp.write("\n") - ofp.write(toAddStr.encode('utf8')) - ofp.write("\n") - -os.rename(tmppath, dstpath) diff --git a/scripts/fetch-develop.deps.sh b/scripts/fetch-develop.deps.sh deleted file mode 100755 index 5814b43ff7..0000000000 --- a/scripts/fetch-develop.deps.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash - -# Fetches the js-sdk dependency for development or testing purposes -# If there exists a branch of that dependency with the same name as -# the branch the current checkout is on, use that branch. Otherwise, -# use develop. - -set -x - -GIT_CLONE_ARGS=("$@") -[ -z "$defbranch" ] && defbranch="develop" - -# clone a specific branch of a github repo -function clone() { - org=$1 - repo=$2 - branch=$3 - - # Chop 'origin' off the start as jenkins ends up using - # branches on the origin, but this doesn't work if we - # specify the branch when cloning. - branch=${branch#origin/} - - if [ -n "$branch" ] - then - echo "Trying to use $org/$repo#$branch" - # Disable auth prompts: https://serverfault.com/a/665959 - GIT_TERMINAL_PROMPT=0 git clone https://github.com/$org/$repo.git $repo --branch $branch \ - "${GIT_CLONE_ARGS[@]}" - return $? - fi - return 1 -} - -function dodep() { - deforg=$1 - defrepo=$2 - rm -rf $defrepo - - # Try the PR author's branch in case it exists on the deps as well. - # Try the target branch of the push or PR. - # Use the default branch as the last resort. - if [[ "$BUILDKITE" == true ]]; then - # If BUILDKITE_BRANCH is set, it will contain either: - # * "branch" when the author's branch and target branch are in the same repo - # * "author:branch" when the author's branch is in their fork - # We can split on `:` into an array to check. - BUILDKITE_BRANCH_ARRAY=(${BUILDKITE_BRANCH//:/ }) - if [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "2" ]]; then - prAuthor=${BUILDKITE_BRANCH_ARRAY[0]} - prBranch=${BUILDKITE_BRANCH_ARRAY[1]} - else - prAuthor=$deforg - prBranch=$BUILDKITE_BRANCH - fi - clone $prAuthor $defrepo $prBranch || - clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH || - clone $deforg $defrepo $defbranch || - return $? - else - clone $deforg $defrepo $ghprbSourceBranch || - clone $deforg $defrepo $GIT_BRANCH || - clone $deforg $defrepo `git rev-parse --abbrev-ref HEAD` || - clone $deforg $defrepo $defbranch || - return $? - fi - - echo "$defrepo set to branch "`git -C "$defrepo" rev-parse --abbrev-ref HEAD` -} - -############################## - -echo 'Setting up matrix-js-sdk' - -dodep matrix-org matrix-js-sdk - -pushd matrix-js-sdk -yarn link -yarn install --frozen-lockfile -popd - -yarn link matrix-js-sdk - -############################## diff --git a/scripts/genflags.sh b/scripts/genflags.sh deleted file mode 100755 index aa882a99b4..0000000000 --- a/scripts/genflags.sh +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright 2017-2024 New Vector Ltd. - -# SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -# Please see LICENSE in the repository root for full details. - - -# genflags.sh - Generates pngs for use with CountryDropdown.js -# -# Dependencies: -# - imagemagick --with-rsvg (because default imagemagick SVG -# renderer does not produce accurate results) -# -# on macOS, this is most easily done with: -# brew install imagemagick --with-librsvg -# -# This will clone the googlei18n flag repo before converting -# all phonenumber.js-supported country flags (as SVGs) into -# PNGs that can be used by CountryDropdown.js. - -set -e - -# Allow CTRL+C to terminate the script -trap "echo Exited!; exit;" SIGINT SIGTERM - -# git clone the google repo to get flag SVGs -git clone git@github.com:googlei18n/region-flags -for f in region-flags/svg/*.svg; do - # Skip state flags - if [[ $f =~ [A-Z]{2}-[A-Z]{2,3}.svg ]] ; then - echo "Skipping state flag "$f - continue - fi - - # Skip countries not included in phonenumber.js - if [[ $f =~ (AC|CP|DG|EA|EU|IC|TA|UM|UN|XK).svg ]] ; then - echo "Skipping non-phonenumber supported flag "$f - continue - fi - - # Run imagemagick convert - # -background none : transparent background - # -resize 50x30 : resize the flag to have a height of 15px (2x) - # By default, aspect ratio is respected so the width will - # be correct and not necessarily 25px. - # -filter Lanczos : use sharper resampling to avoid muddiness - # -gravity Center : keep the image central when adding an -extent - # -border 1 : add a 1px border around the flag - # -bordercolor : set the border colour - # -extent 54x54 : surround the image with padding so that it - # has the dimensions 27x27px (2x). - convert $f -background none -filter Lanczos -resize 50x30 \ - -gravity Center -border 1 -bordercolor \#e0e0e0 \ - -extent 54x54 $f.png - - # $f.png will be region-flags/svg/XX.svg.png at this point - - # Extract filename from path $f - newname=${f##*/} - # Replace .svg with .png - newname=${newname%.svg}.png - # Move the file to flags directory - mv $f.png ../res/flags/$newname - echo "Generated res/flags/"$newname -done diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index dcef9c2eb9..41ccfcbb3b 100644 --- a/src/@types/matrix-js-sdk.d.ts +++ b/src/@types/matrix-js-sdk.d.ts @@ -10,7 +10,6 @@ import type { IWidget } from "matrix-widget-api"; import type { BLURHASH_FIELD } from "../utils/image-media"; import type { JitsiCallMemberEventType, JitsiCallMemberContent } from "../call-types"; import type { ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/types"; -import type { VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType } from "../voice-broadcast/types"; import type { EncryptedFile } from "matrix-js-sdk/src/types"; // Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types @@ -22,6 +21,13 @@ declare module "matrix-js-sdk/src/types" { [BLURHASH_FIELD]?: string; } + export interface ImageInfo { + /** + * @see https://github.com/matrix-org/matrix-spec-proposals/pull/4230 + */ + "org.matrix.msc4230.is_animated"?: boolean; + } + export interface StateEvents { // Jitsi-backed video room state events [JitsiCallMemberEventType]: JitsiCallMemberContent; @@ -30,9 +36,6 @@ declare module "matrix-js-sdk/src/types" { "im.vector.modular.widgets": IWidget | {}; [WIDGET_LAYOUT_EVENT_TYPE]: ILayoutStateEvent; - // Unstable voice broadcast state events - [VoiceBroadcastInfoEventType]: VoiceBroadcastInfoEventContent; - // Element custom state events "im.vector.web.settings": Record; "org.matrix.room.preview_urls": { disable: boolean }; @@ -71,7 +74,5 @@ declare module "matrix-js-sdk/src/types" { waveform?: number[]; }; "org.matrix.msc3245.voice"?: {}; - - "io.element.voice_broadcast_chunk"?: { sequence: number }; } } diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 895e168f3b..344a2f112c 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -56,6 +56,7 @@ import { createThumbnail } from "./utils/image-media"; import { attachMentions, attachRelation } from "./components/views/rooms/SendMessageComposer"; import { doMaybeLocalRoomAction } from "./utils/local-room"; import { SdkContextClass } from "./contexts/SDKContext"; +import { blobIsAnimated } from "./utils/Image.ts"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre @@ -150,15 +151,20 @@ async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imag thumbnailType = "image/jpeg"; } + // We don't await this immediately so it can happen in the background + const isAnimatedPromise = blobIsAnimated(imageFile.type, imageFile); + const imageElement = await loadImageElement(imageFile); const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType); const imageInfo = result.info; + imageInfo["org.matrix.msc4230.is_animated"] = await isAnimatedPromise; + // For lesser supported image types, always include the thumbnail even if it is larger if (!ALWAYS_INCLUDE_THUMBNAIL.includes(imageFile.type)) { // we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from. - const sizeDifference = imageFile.size - imageInfo.thumbnail_info!.size; + const sizeDifference = imageFile.size - imageInfo.thumbnail_info!.size!; if ( // image is small enough already imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index e80c9402b2..e34af95962 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -230,12 +230,15 @@ export default class DeviceListener { private async getKeyBackupInfo(): Promise { if (!this.client) return null; const now = new Date().getTime(); + const crypto = this.client.getCrypto(); + if (!crypto) return null; + if ( !this.keyBackupInfo || !this.keyBackupFetchedAt || this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL ) { - this.keyBackupInfo = await this.client.getKeyBackupVersion(); + this.keyBackupInfo = await crypto.getKeyBackupInfo(); this.keyBackupFetchedAt = now; } return this.keyBackupInfo; diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index 72bee5d0ab..5dd500402d 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -175,13 +175,6 @@ export interface IConfigOptions { sync_timeline_limit?: number; dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option - voice_broadcast?: { - // length per voice chunk in seconds - chunk_length?: number; - // max voice broadcast length in seconds - max_length?: number; - }; - user_notice?: { title: string; description: string; diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index a06480e9cd..b804ca0084 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -55,8 +55,6 @@ import { OpenInviteDialogPayload } from "./dispatcher/payloads/OpenInviteDialogP import { findDMForUser } from "./utils/dm/findDMForUser"; import { getJoinedNonFunctionalMembers } from "./utils/room/getJoinedNonFunctionalMembers"; import { localNotificationsAreSilenced } from "./utils/notifications"; -import { SdkContextClass } from "./contexts/SDKContext"; -import { showCantStartACallDialog } from "./voice-broadcast/utils/showCantStartACallDialog"; import { isNotNull } from "./Typeguards"; import { BackgroundAudio } from "./audio/BackgroundAudio"; import { Jitsi } from "./widgets/Jitsi.ts"; @@ -859,15 +857,6 @@ export default class LegacyCallHandler extends EventEmitter { return; } - // Pause current broadcast, if any - SdkContextClass.instance.voiceBroadcastPlaybacksStore.getCurrent()?.pause(); - - if (SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent()) { - // Do not start a call, if recording a broadcast - showCantStartACallDialog(); - return; - } - // We might be using managed hybrid widgets if (isManagedHybridWidgetEnabled(room)) { await addManagedHybridWidget(room); diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 9804ab5d82..ce87953118 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -35,13 +35,11 @@ import IdentityAuthClient from "./IdentityAuthClient"; import { crossSigningCallbacks } from "./SecurityManager"; import { SlidingSyncManager } from "./SlidingSyncManager"; import { _t, UserFriendlyError } from "./languageHandler"; -import { SettingLevel } from "./settings/SettingLevel"; import MatrixClientBackedController from "./settings/controllers/MatrixClientBackedController"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import PlatformPeg from "./PlatformPeg"; import { formatList } from "./utils/FormattingUtils"; import SdkConfig from "./SdkConfig"; -import { Features } from "./settings/Settings"; import { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts"; export interface IMatrixClientCreds { @@ -333,11 +331,6 @@ class MatrixClientPegClass implements IMatrixClientPeg { logger.error("Warning! Not using an encryption key for rust crypto store."); } - // Record the fact that we used the Rust crypto stack with this client. This just guards against people - // rolling back to versions of EW that did not default to Rust crypto (which would lead to an error, since - // we cannot migrate from Rust to Legacy crypto). - await SettingsStore.setValue(Features.RustCrypto, null, SettingLevel.DEVICE, true); - await this.matrixClient.initRustCrypto({ storageKey: rustCryptoStoreKey, storagePassword: rustCryptoStorePassword, diff --git a/src/Notifier.ts b/src/Notifier.ts index 961d2171a8..45e6a1195d 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -49,8 +49,6 @@ import { SdkContextClass } from "./contexts/SDKContext"; import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications"; import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast"; import ToastStore from "./stores/ToastStore"; -import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType } from "./voice-broadcast"; -import { getSenderName } from "./utils/event/getSenderName"; import { stripPlainReply } from "./utils/Reply"; import { BackgroundAudio } from "./audio/BackgroundAudio"; @@ -81,17 +79,6 @@ const msgTypeHandlers: Record string | null> = { return TextForEvent.textForLocationEvent(event)(); }, [MsgType.Audio]: (event: MatrixEvent): string | null => { - if (event.getContent()?.[VoiceBroadcastChunkEventType]) { - if (event.getContent()?.[VoiceBroadcastChunkEventType]?.sequence === 1) { - // Show a notification for the first broadcast chunk. - // At this point a user received something to listen to. - return _t("notifier|io.element.voice_broadcast_chunk", { senderName: getSenderName(event) }); - } - - // Mute other broadcast chunks - return null; - } - return TextForEvent.textForEvent(event, MatrixClientPeg.safeGet()); }, }; @@ -460,8 +447,6 @@ class NotifierClass extends TypedEventEmitter, 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/SdkConfig.ts b/src/SdkConfig.ts index cbec03381a..c9a0010b0f 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -46,10 +46,6 @@ export const DEFAULTS: DeepReadonly = { logo: require("../res/img/element-desktop-logo.svg").default, url: "https://element.io/get-started", }, - voice_broadcast: { - chunk_length: 2 * 60, // two minutes - max_length: 4 * 60 * 60, // four hours - }, feedback: { existing_issues_url: diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 1ffae62aea..49d8b739b7 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -36,7 +36,6 @@ import AccessibleButton from "./components/views/elements/AccessibleButton"; import RightPanelStore from "./stores/right-panel/RightPanelStore"; import { highlightEvent, isLocationEvent } from "./utils/EventUtils"; import { ElementCall } from "./models/Call"; -import { textForVoiceBroadcastStoppedEvent, VoiceBroadcastInfoEventType } from "./voice-broadcast"; import { getSenderName } from "./utils/event/getSenderName"; import PosthogTrackers from "./PosthogTrackers.ts"; @@ -906,7 +905,6 @@ const stateHandlers: IHandlers = { // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) "im.vector.modular.widgets": textForWidgetEvent, [WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent, - [VoiceBroadcastInfoEventType]: textForVoiceBroadcastStoppedEvent, }; // Add all the Mjolnir stuff to the renderer diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 1258bde2ca..932f6d7fcf 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -279,7 +279,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private unmounted = false; private dispatcherRef?: string; diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 74a91d8cbc..4c580cb9fe 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -34,6 +34,7 @@ import { Layout } from "../../settings/enums/Layout"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; import Measured from "../views/elements/Measured"; import EmptyState from "../views/right_panel/EmptyState"; +import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx"; interface IProps { roomId: string; @@ -51,7 +52,7 @@ interface IState { */ class FilePanel extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; // This is used to track if a decrypted event was a live event and should be // added to the timeline. @@ -104,7 +105,11 @@ class FilePanel extends React.Component { } if (!this.state.timelineSet.eventIdToTimeline(ev.getId()!)) { - this.state.timelineSet.addEventToTimeline(ev, timeline, false); + this.state.timelineSet.addEventToTimeline(ev, timeline, { + fromCache: false, + addToState: false, + toStartOfTimeline: false, + }); } } @@ -269,12 +274,10 @@ class FilePanel extends React.Component { if (this.state.timelineSet) { return ( - { layout={Layout.Group} /> - + ); } else { return ( - + { > - + ); } } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 0042169f45..019f9cd1a8 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -23,7 +23,6 @@ import classNames from "classnames"; import { isOnlyCtrlOrCmdKeyEvent, Key } from "../../Keyboard"; import PageTypes from "../../PageTypes"; import MediaDeviceHandler from "../../MediaDeviceHandler"; -import { fixupColorFonts } from "../../utils/FontManager"; import dis from "../../dispatcher/dispatcher"; import { IMatrixClientCreds } from "../../MatrixClientPeg"; import SettingsStore from "../../settings/SettingsStore"; @@ -149,8 +148,6 @@ class LoggedInView extends React.Component { MediaDeviceHandler.loadDevices(); - fixupColorFonts(); - this._roomView = React.createRef(); this._resizeContainer = React.createRef(); this.resizeHandler = React.createRef(); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index e51dd96647..548dbff983 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -119,7 +119,6 @@ import { ValidatedServerConfig } from "../../utils/ValidatedServerConfig"; import { isLocalRoom } from "../../utils/localRoom/isLocalRoom"; import { SDKContext, SdkContextClass } from "../../contexts/SDKContext"; import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSettings"; -import { cleanUpBroadcasts, VoiceBroadcastResumer } from "../../voice-broadcast"; import GenericToast from "../views/toasts/GenericToast"; import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog"; import { findDMForUser } from "../../utils/dm/findDMForUser"; @@ -227,7 +226,6 @@ export default class MatrixChat extends React.PureComponent { private focusNext: FocusNextType; private subTitleStatus: string; private prevWindowWidth: number; - private voiceBroadcastResumer?: VoiceBroadcastResumer; private readonly loggedInView = createRef(); private dispatcherRef?: string; @@ -501,7 +499,6 @@ export default class MatrixChat extends React.PureComponent { window.removeEventListener("resize", this.onWindowResized); this.stores.accountPasswordStore.clearPassword(); - this.voiceBroadcastResumer?.destroy(); } private onWindowResized = (): void => { @@ -651,10 +648,9 @@ export default class MatrixChat extends React.PureComponent { break; case "logout": LegacyCallHandler.instance.hangupAllCalls(); - Promise.all([ - ...[...CallStore.instance.connectedCalls].map((call) => call.disconnect()), - cleanUpBroadcasts(this.stores), - ]).finally(() => Lifecycle.logout(this.stores.oidcClientStore)); + Promise.all([...[...CallStore.instance.connectedCalls].map((call) => call.disconnect())]).finally(() => + Lifecycle.logout(this.stores.oidcClientStore), + ); break; case "require_registration": startAnyRegistrationFlow(payload as any); @@ -1638,7 +1634,7 @@ export default class MatrixChat extends React.PureComponent { } else { // otherwise check the server to see if there's a new one try { - newVersionInfo = await cli.getKeyBackupVersion(); + newVersionInfo = (await cli.getCrypto()?.getKeyBackupInfo()) ?? null; if (newVersionInfo !== null) haveNewVersion = true; } catch (e) { logger.error("Saw key backup error but failed to check backup version!", e); @@ -1679,8 +1675,6 @@ export default class MatrixChat extends React.PureComponent { }); } }); - - this.voiceBroadcastResumer = new VoiceBroadcastResumer(cli); } /** diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index b26de2e645..d2133f4f13 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -196,7 +196,7 @@ interface IReadReceiptForUser { */ export default class MessagePanel extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public static defaultProps = { disableGrouping: false, diff --git a/src/components/structures/NotificationPanel.tsx b/src/components/structures/NotificationPanel.tsx index edec675b14..236da25409 100644 --- a/src/components/structures/NotificationPanel.tsx +++ b/src/components/structures/NotificationPanel.tsx @@ -19,6 +19,7 @@ import { Layout } from "../../settings/enums/Layout"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; import Measured from "../views/elements/Measured"; import EmptyState from "../views/right_panel/EmptyState"; +import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx"; interface IProps { onClose(): void; @@ -33,7 +34,7 @@ interface IState { */ export default class NotificationPanel extends React.PureComponent { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private card = React.createRef(); @@ -79,12 +80,10 @@ export default class NotificationPanel extends React.PureComponent } {content} - + ); } } diff --git a/src/components/structures/PipContainer.tsx b/src/components/structures/PipContainer.tsx index 731e720b12..c9fabfe0c9 100644 --- a/src/components/structures/PipContainer.tsx +++ b/src/components/structures/PipContainer.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, { MutableRefObject, ReactNode, useContext, useRef } from "react"; +import React, { MutableRefObject, ReactNode, useRef } from "react"; import { CallEvent, CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { logger } from "matrix-js-sdk/src/logger"; import { Optional } from "matrix-events-sdk"; @@ -21,19 +21,7 @@ import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../stores/ActiveWidgetStore"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; -import { SDKContext, SdkContextClass } from "../../contexts/SDKContext"; -import { - useCurrentVoiceBroadcastPreRecording, - useCurrentVoiceBroadcastRecording, - VoiceBroadcastPlayback, - VoiceBroadcastPlaybackBody, - VoiceBroadcastPreRecording, - VoiceBroadcastPreRecordingPip, - VoiceBroadcastRecording, - VoiceBroadcastRecordingPip, - VoiceBroadcastSmallPlaybackBody, -} from "../../voice-broadcast"; -import { useCurrentVoiceBroadcastPlayback } from "../../voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback"; +import { SdkContextClass } from "../../contexts/SDKContext"; import { WidgetPip } from "../views/pips/WidgetPip"; const SHOW_CALL_IN_STATES = [ @@ -46,9 +34,6 @@ const SHOW_CALL_IN_STATES = [ ]; interface IProps { - voiceBroadcastRecording: Optional; - voiceBroadcastPreRecording: Optional; - voiceBroadcastPlayback: Optional; movePersistedElement: MutableRefObject<(() => void) | undefined>; } @@ -245,52 +230,9 @@ class PipContainerInner extends React.Component { this.setState({ showWidgetInPip, persistentWidgetId, persistentRoomId }); } - private createVoiceBroadcastPlaybackPipContent(voiceBroadcastPlayback: VoiceBroadcastPlayback): CreatePipChildren { - const content = - this.state.viewedRoomId === voiceBroadcastPlayback.infoEvent.getRoomId() ? ( - - ) : ( - - ); - - return ({ onStartMoving }) => ( -
- {content} -
- ); - } - - private createVoiceBroadcastPreRecordingPipContent( - voiceBroadcastPreRecording: VoiceBroadcastPreRecording, - ): CreatePipChildren { - return ({ onStartMoving }) => ( -
- -
- ); - } - - private createVoiceBroadcastRecordingPipContent( - voiceBroadcastRecording: VoiceBroadcastRecording, - ): CreatePipChildren { - return ({ onStartMoving }) => ( -
- -
- ); - } - public render(): ReactNode { const pipMode = true; - let pipContent: Array = []; - - if (this.props.voiceBroadcastRecording) { - pipContent = [this.createVoiceBroadcastRecordingPipContent(this.props.voiceBroadcastRecording)]; - } else if (this.props.voiceBroadcastPreRecording) { - pipContent = [this.createVoiceBroadcastPreRecordingPipContent(this.props.voiceBroadcastPreRecording)]; - } else if (this.props.voiceBroadcastPlayback) { - pipContent = [this.createVoiceBroadcastPlaybackPipContent(this.props.voiceBroadcastPlayback)]; - } + const pipContent: Array = []; if (this.state.primaryCall) { // get a ref to call inside the current scope @@ -338,24 +280,7 @@ class PipContainerInner extends React.Component { } export const PipContainer: React.FC = () => { - const sdkContext = useContext(SDKContext); - const voiceBroadcastPreRecordingStore = sdkContext.voiceBroadcastPreRecordingStore; - const { currentVoiceBroadcastPreRecording } = useCurrentVoiceBroadcastPreRecording(voiceBroadcastPreRecordingStore); - - const voiceBroadcastRecordingsStore = sdkContext.voiceBroadcastRecordingsStore; - const { currentVoiceBroadcastRecording } = useCurrentVoiceBroadcastRecording(voiceBroadcastRecordingsStore); - - const voiceBroadcastPlaybacksStore = sdkContext.voiceBroadcastPlaybacksStore; - const { currentVoiceBroadcastPlayback } = useCurrentVoiceBroadcastPlayback(voiceBroadcastPlaybacksStore); - const movePersistedElement = useRef<() => void>(); - return ( - - ); + return ; }; diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 9a9f29f82e..a1f2016243 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -63,7 +63,7 @@ interface IState { export default class RightPanel extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: Props, context: React.ContextType) { super(props, context); @@ -109,10 +109,10 @@ export default class RightPanel extends React.Component { } // redraw the badge on the membership list - if (this.state.phase === RightPanelPhases.RoomMemberList) { + if (this.state.phase === RightPanelPhases.MemberList) { this.delayedUpdate(); } else if ( - this.state.phase === RightPanelPhases.RoomMemberInfo && + this.state.phase === RightPanelPhases.MemberInfo && member.userId === this.state.cardState?.member?.userId ) { // refresh the member info (e.g. new power level) @@ -157,7 +157,7 @@ export default class RightPanel extends React.Component { const phase = this.props.overwriteCard?.phase ?? this.state.phase; const cardState = this.props.overwriteCard?.state ?? this.state.cardState; switch (phase) { - case RightPanelPhases.RoomMemberList: + case RightPanelPhases.MemberList: if (!!roomId) { card = ( { ); } break; - case RightPanelPhases.SpaceMemberList: - if (!!cardState?.spaceId || !!roomId) { - card = ( - - ); - } - break; - case RightPanelPhases.RoomMemberInfo: - case RightPanelPhases.SpaceMemberInfo: + case RightPanelPhases.MemberInfo: case RightPanelPhases.EncryptionPanel: { if (!!cardState?.member) { const roomMember = cardState.member instanceof RoomMember ? cardState.member : undefined; @@ -203,8 +189,7 @@ export default class RightPanel extends React.Component { } break; } - case RightPanelPhases.Room3pidMemberInfo: - case RightPanelPhases.Space3pidMemberInfo: + case RightPanelPhases.ThreePidMemberInfo: if (!!cardState?.memberInfoEvent) { card = ( diff --git a/src/components/structures/RoomSearchView.tsx b/src/components/structures/RoomSearchView.tsx index de2d9d2198..82146bcc5e 100644 --- a/src/components/structures/RoomSearchView.tsx +++ b/src/components/structures/RoomSearchView.tsx @@ -26,7 +26,7 @@ import ErrorDialog from "../views/dialogs/ErrorDialog"; import ResizeNotifier from "../../utils/ResizeNotifier"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; -import RoomContext from "../../contexts/RoomContext"; +import { useScopedRoomContext } from "../../contexts/ScopedRoomContext.tsx"; const DEBUG = false; let debuglog = function (msg: string): void {}; @@ -53,7 +53,7 @@ interface Props { export const RoomSearchView = forwardRef( ({ term, scope, promise, abortController, resizeNotifier, className, onUpdate, inProgress }: Props, ref) => { const client = useContext(MatrixClientContext); - const roomContext = useContext(RoomContext); + const roomContext = useScopedRoomContext("showHiddenEvents"); const [highlights, setHighlights] = useState(null); const [results, setResults] = useState(null); const aborted = useRef(false); diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index 76f3b0c229..3bd69148ae 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -89,7 +89,7 @@ interface IState { export default class RoomStatusBar extends React.PureComponent { private unmounted = false; public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 470b73de7c..891e6b97f4 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -9,7 +9,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, { ChangeEvent, ComponentProps, createRef, ReactElement, ReactNode, RefObject, useContext } from "react"; +import React, { ChangeEvent, ComponentProps, createRef, ReactElement, ReactNode, RefObject, JSX } from "react"; import classNames from "classnames"; import { IRecommendedVersion, @@ -29,6 +29,7 @@ import { MatrixError, ISearchResults, THREAD_RELATION_TYPE, + MatrixClient, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -54,7 +55,7 @@ import WidgetEchoStore from "../../stores/WidgetEchoStore"; import SettingsStore from "../../settings/SettingsStore"; import { Layout } from "../../settings/enums/Layout"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; -import RoomContext, { TimelineRenderingType, MainSplitContentType } from "../../contexts/RoomContext"; +import { TimelineRenderingType, MainSplitContentType } from "../../contexts/RoomContext"; import { E2EStatus, shieldStatusForRoom } from "../../utils/ShieldUtils"; import { Action } from "../../dispatcher/actions"; import { IMatrixClientCreds } from "../../MatrixClientPeg"; @@ -126,6 +127,7 @@ import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import { onView3pidInvite } from "../../stores/right-panel/action-handlers"; import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel"; import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner"; +import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ScopedRoomContext"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; @@ -233,6 +235,11 @@ export interface IRoomState { liveTimeline?: EventTimeline; narrow: boolean; msc3946ProcessDynamicPredecessor: boolean; + /** + * Whether the room is encrypted or not. + * If null, we are still determining the encryption status. + */ + isRoomEncrypted: boolean | null; canAskToJoin: boolean; promptAskToJoin: boolean; @@ -246,6 +253,7 @@ interface LocalRoomViewProps { permalinkCreator: RoomPermalinkCreator; roomView: RefObject; onFileDrop: (dataTransfer: DataTransfer) => Promise; + mainSplitContentType: MainSplitContentType; } /** @@ -255,7 +263,7 @@ interface LocalRoomViewProps { * @returns {ReactElement} */ function LocalRoomView(props: LocalRoomViewProps): ReactElement { - const context = useContext(RoomContext); + const context = useScopedRoomContext("room"); const room = context.room as LocalRoom; const encryptionEvent = props.localRoom.currentState.getStateEvents(EventType.RoomEncryption)[0]; let encryptionTile: ReactNode; @@ -323,6 +331,7 @@ interface ILocalRoomCreateLoaderProps { localRoom: LocalRoom; names: string; resizeNotifier: ResizeNotifier; + mainSplitContentType: MainSplitContentType; } /** @@ -363,7 +372,7 @@ export class RoomView extends React.Component { private roomViewBody = createRef(); public static contextType = SDKContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IRoomProps, context: React.ContextType) { super(props, context); @@ -417,6 +426,7 @@ export class RoomView extends React.Component { canAskToJoin: this.askToJoinEnabled, promptAskToJoin: false, viewRoomOpts: { buttons: [] }, + isRoomEncrypted: null, }; } @@ -655,6 +665,7 @@ export class RoomView extends React.Component { // the RoomView instance if (initial) { newState.room = this.context.client!.getRoom(newState.roomId) || undefined; + newState.isRoomEncrypted = null; if (newState.room) { newState.showApps = this.shouldShowApps(newState.room); this.onRoomLoaded(newState.room); @@ -697,6 +708,14 @@ export class RoomView extends React.Component { if (initial) { this.setupRoom(newState.room, newState.roomId, !!newState.joining, !!newState.shouldPeek); } + + // We don't block the initial setup but we want to make it early to not block the timeline rendering + const isRoomEncrypted = await this.getIsRoomEncrypted(newState.roomId); + this.setState({ + isRoomEncrypted, + ...(isRoomEncrypted && + newState.roomId && { e2eStatus: RoomView.e2eStatusCache.get(newState.roomId) ?? E2EStatus.Warning }), + }); }; private onConnectedCalls = (): void => { @@ -1214,18 +1233,18 @@ export class RoomView extends React.Component { if (payload.member) { if (payload.push) { RightPanelStore.instance.pushCard({ - phase: RightPanelPhases.RoomMemberInfo, + phase: RightPanelPhases.MemberInfo, state: { member: payload.member }, }); } else { RightPanelStore.instance.setCards([ { phase: RightPanelPhases.RoomSummary }, - { phase: RightPanelPhases.RoomMemberList }, - { phase: RightPanelPhases.RoomMemberInfo, state: { member: payload.member } }, + { phase: RightPanelPhases.MemberList }, + { phase: RightPanelPhases.MemberInfo, state: { member: payload.member } }, ]); } } else { - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomMemberList); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList); } break; case Action.View3pidInvite: @@ -1342,13 +1361,12 @@ export class RoomView extends React.Component { this.context.widgetLayoutStore.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); this.calculatePeekRules(room); - this.updatePreviewUrlVisibility(room); this.loadMembersIfJoined(room); this.calculateRecommendedVersion(room); - this.updateE2EStatus(room); this.updatePermissions(room); this.checkWidgets(room); this.loadVirtualRoom(room); + this.updateRoomEncrypted(room); if ( this.getMainSplitContentType(room) !== MainSplitContentType.Timeline && @@ -1377,6 +1395,13 @@ export class RoomView extends React.Component { return room?.currentState.getStateEvents(EventType.RoomTombstone, "") ?? undefined; } + private async getIsRoomEncrypted(roomId = this.state.roomId): Promise { + const crypto = this.context.client?.getCrypto(); + if (!crypto || !roomId) return false; + + return await crypto.isEncryptionEnabledInRoom(roomId); + } + private async calculateRecommendedVersion(room: Room): Promise { const upgradeRecommendation = await room.getRecommendedVersion(); if (this.unmounted) return; @@ -1409,12 +1434,15 @@ export class RoomView extends React.Component { }); } - private updatePreviewUrlVisibility({ roomId }: Room): void { - // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit - const key = this.context.client?.isRoomEncrypted(roomId) ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"; - this.setState({ - showUrlPreview: SettingsStore.getValue(key, roomId), - }); + private updatePreviewUrlVisibility(room: Room): void { + this.setState(({ isRoomEncrypted }) => ({ + showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted), + })); + } + + private getPreviewUrlVisibility({ roomId }: Room, isRoomEncrypted: boolean | null): boolean { + const key = isRoomEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"; + return SettingsStore.getValue(key, roomId); } private onRoom = (room: Room): void => { @@ -1456,22 +1484,20 @@ export class RoomView extends React.Component { }; private async updateE2EStatus(room: Room): Promise { - if (!this.context.client?.isRoomEncrypted(room.roomId)) return; - - // If crypto is not currently enabled, we aren't tracking devices at all, - // so we don't know what the answer is. Let's error on the safe side and show - // a warning for this case. - let e2eStatus = RoomView.e2eStatusCache.get(room.roomId) ?? E2EStatus.Warning; - // set the state immediately then update, so we don't scare the user into thinking the room is unencrypted + if (!this.context.client || !this.state.isRoomEncrypted) return; + const e2eStatus = await this.cacheAndGetE2EStatus(room, this.context.client); + if (this.unmounted) return; this.setState({ e2eStatus }); + } - if (this.context.client.getCrypto()) { - /* At this point, the user has encryption on and cross-signing on */ - e2eStatus = await shieldStatusForRoom(this.context.client, room); - RoomView.e2eStatusCache.set(room.roomId, e2eStatus); - if (this.unmounted) return; - this.setState({ e2eStatus }); - } + private async cacheAndGetE2EStatus(room: Room, client: MatrixClient): Promise { + let e2eStatus = RoomView.e2eStatusCache.get(room.roomId); + // set the state immediately then update, so we don't scare the user into thinking the room is unencrypted + if (e2eStatus) this.setState({ e2eStatus }); + + e2eStatus = await shieldStatusForRoom(client, room); + RoomView.e2eStatusCache.set(room.roomId, e2eStatus); + return e2eStatus; } private onUrlPreviewsEnabledChange = (): void => { @@ -1480,20 +1506,36 @@ export class RoomView extends React.Component { } }; - private onRoomStateEvents = (ev: MatrixEvent, state: RoomState): void => { + private onRoomStateEvents = async (ev: MatrixEvent, state: RoomState): Promise => { // ignore if we don't have a room yet - if (!this.state.room || this.state.room.roomId !== state.roomId) return; + if (!this.state.room || this.state.room.roomId !== state.roomId || !this.context.client) return; switch (ev.getType()) { case EventType.RoomTombstone: this.setState({ tombstone: this.getRoomTombstone() }); break; - + case EventType.RoomEncryption: { + await this.updateRoomEncrypted(); + break; + } default: this.updatePermissions(this.state.room); } }; + private async updateRoomEncrypted(room = this.state.room): Promise { + if (!room || !this.context.client) return; + + const isRoomEncrypted = await this.getIsRoomEncrypted(room.roomId); + const newE2EStatus = isRoomEncrypted ? await this.cacheAndGetE2EStatus(room, this.context.client) : null; + + this.setState({ + isRoomEncrypted, + showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted), + ...(newE2EStatus && { e2eStatus: newE2EStatus }), + }); + } + private onRoomStateUpdate = (state: RoomState): void => { // ignore members in other rooms if (state.roomId !== this.state.room?.roomId) { @@ -1959,35 +2001,41 @@ export class RoomView extends React.Component { if (!this.state.room || !this.context?.client) return null; const names = this.state.room.getDefaultRoomName(this.context.client.getSafeUserId()); return ( - - - + + + ); } private renderLocalRoomView(localRoom: LocalRoom): ReactNode { return ( - + - + ); } private renderWaitingForThirdPartyRoomView(inviteEvent: MatrixEvent): ReactNode { return ( - + - + ); } @@ -2027,6 +2075,8 @@ export class RoomView extends React.Component { public render(): ReactNode { if (!this.context.client) return null; + const { isRoomEncrypted } = this.state; + const isRoomEncryptionLoading = isRoomEncrypted === null; if (this.state.room instanceof LocalRoom) { if (this.state.room.state === LocalRoomState.CREATING) { @@ -2242,14 +2292,16 @@ export class RoomView extends React.Component { let aux: JSX.Element | undefined; let previewBar; if (this.state.timelineRenderingType === TimelineRenderingType.Search) { - aux = ( - - ); + if (!isRoomEncryptionLoading) { + aux = ( + + ); + } } else if (showRoomUpgradeBar) { aux = ; } else if (myMembership !== KnownMembership.Join) { @@ -2325,8 +2377,10 @@ export class RoomView extends React.Component { let messageComposer; const showComposer = + !isRoomEncryptionLoading && // joined and not showing search results - myMembership === KnownMembership.Join && !this.state.search; + myMembership === KnownMembership.Join && + !this.state.search; if (showComposer) { messageComposer = ( { highlightedEventId = this.state.initialEventId; } - const messagePanel = ( -
+ {children} ); @@ -650,17 +670,14 @@ export default class MImageBody extends React.Component { } interface PlaceholderIProps { - hover?: boolean; maxWidth?: number; } export class HiddenImagePlaceholder extends React.PureComponent { public render(): React.ReactNode { const maxWidth = this.props.maxWidth ? this.props.maxWidth + "px" : null; - let className = "mx_HiddenImagePlaceholder"; - if (this.props.hover) className += " mx_HiddenImagePlaceholder_hover"; return ( -
+
{_t("timeline|m.image|show_image")} diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index b226476fa8..7735e64b03 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -30,7 +30,7 @@ interface IState { export default class MLocationBody extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private unmounted = false; private mapId: string; diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index de9be6cf91..ba3962779f 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -139,7 +139,7 @@ export function launchPollEditor(mxEvent: MatrixEvent, getRelationsForEvent?: Ge export default class MPollBody extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private seenEventIds: string[] = []; // Events we have already seen public constructor(props: IBodyProps, context: React.ContextType) { @@ -383,7 +383,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/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index 4036b9ddec..822d2c3f59 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -34,7 +34,7 @@ interface IState { export default class MVideoBody extends React.PureComponent { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private videoRef = React.createRef(); private sizeWatcher?: string; diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index fdd0200429..9d21b8fa45 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -58,7 +58,6 @@ import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts"; import { Action } from "../../../dispatcher/actions"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { GetRelationsForEvent, IEventTileType } from "../rooms/EventTile"; -import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast/types"; import { ButtonEvent } from "../elements/AccessibleButton"; import PinningUtils from "../../../utils/PinningUtils"; import PosthogTrackers from "../../../PosthogTrackers.ts"; @@ -262,7 +261,7 @@ interface IMessageActionBarProps { export default class MessageActionBar extends React.PureComponent { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public componentDidMount(): void { if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) { @@ -354,8 +353,7 @@ export default class MessageActionBar extends React.PureComponent { @@ -85,7 +84,7 @@ export default class MessageEvent extends React.Component implements IMe private evTypes = new Map>(baseEvTypes.entries()); public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); @@ -276,10 +275,6 @@ export default class MessageEvent extends React.Component implements IMe if (M_LOCATION.matches(type) || (type === EventType.RoomMessage && msgtype === MsgType.Location)) { BodyType = MLocationBody; } - - if (type === VoiceBroadcastInfoEventType && content?.state === VoiceBroadcastInfoState.Started) { - BodyType = VoiceBroadcastBody; - } } if (SettingsStore.getValue("feature_mjolnir")) { diff --git a/src/components/views/messages/ReactionsRow.tsx b/src/components/views/messages/ReactionsRow.tsx index eba9499606..605e6a7dfe 100644 --- a/src/components/views/messages/ReactionsRow.tsx +++ b/src/components/views/messages/ReactionsRow.tsx @@ -75,7 +75,7 @@ interface IState { export default class ReactionsRow extends React.PureComponent { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/messages/ReactionsRowButton.tsx b/src/components/views/messages/ReactionsRowButton.tsx index 4a1d8d67fe..709edeffd8 100644 --- a/src/components/views/messages/ReactionsRowButton.tsx +++ b/src/components/views/messages/ReactionsRowButton.tsx @@ -38,7 +38,7 @@ export interface IProps { export default class ReactionsRowButton extends React.PureComponent { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public onClick = (): void => { const { mxEvent, myReactionEvent, content } = this.props; diff --git a/src/components/views/messages/ReactionsRowButtonTooltip.tsx b/src/components/views/messages/ReactionsRowButtonTooltip.tsx index 9790356762..5f407e2e20 100644 --- a/src/components/views/messages/ReactionsRowButtonTooltip.tsx +++ b/src/components/views/messages/ReactionsRowButtonTooltip.tsx @@ -28,7 +28,7 @@ interface IProps { export default class ReactionsRowButtonTooltip extends React.PureComponent> { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public render(): React.ReactNode { const { content, reactionEvents, mxEvent, children } = this.props; diff --git a/src/components/views/messages/RoomPredecessorTile.tsx b/src/components/views/messages/RoomPredecessorTile.tsx index 2e8633febd..afc8142234 100644 --- a/src/components/views/messages/RoomPredecessorTile.tsx +++ b/src/components/views/messages/RoomPredecessorTile.tsx @@ -7,7 +7,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, { useCallback, useContext } from "react"; +import React, { useCallback } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixEvent, Room, RoomState } from "matrix-js-sdk/src/matrix"; @@ -18,10 +18,10 @@ import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import EventTileBubble from "./EventTileBubble"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import RoomContext from "../../../contexts/RoomContext"; import { useRoomState } from "../../../hooks/useRoomState"; import SettingsStore from "../../../settings/SettingsStore"; import MatrixToPermalinkConstructor from "../../../utils/permalinks/MatrixToPermalinkConstructor"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps { /** The m.room.create MatrixEvent that this tile represents */ @@ -40,7 +40,7 @@ export const RoomPredecessorTile: React.FC = ({ mxEvent, timestamp }) => // the information inside mxEvent. This allows us the flexibility later to // use a different predecessor (e.g. through MSC3946) and still display it // in the timeline location of the create event. - const roomContext = useContext(RoomContext); + const roomContext = useScopedRoomContext("room"); const predecessor = useRoomState( roomContext.room, useCallback( diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 0c05236176..ae99754cba 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -52,10 +52,8 @@ export default class TextualBody extends React.Component { private tooltips = new ReactRootManager(); private reactRoots = new ReactRootManager(); - private ref = createRef(); - public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public state = { links: [], @@ -86,7 +84,7 @@ export default class TextualBody extends React.Component { if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") { // Handle expansion and add buttons - const pres = this.ref.current?.getElementsByTagName("pre"); + const pres = [...content.getElementsByTagName("pre")]; if (pres && pres.length > 0) { for (let i = 0; i < pres.length; i++) { // If there already is a div wrapping the codeblock we want to skip this. @@ -115,13 +113,14 @@ export default class TextualBody extends React.Component { root.className = "mx_EventTile_pre_container"; // Insert containing div in place of
 block
-        pre.parentNode?.replaceChild(root, pre);
+        pre.replaceWith(root);
 
         this.reactRoots.render(
             
                 {pre}
             ,
             root,
+            pre,
         );
     }
 
@@ -196,10 +195,9 @@ export default class TextualBody extends React.Component {
                     
                 );
 
-                this.reactRoots.render(spoiler, spoilerContainer);
-
-                node.parentNode?.replaceChild(spoilerContainer, node);
+                this.reactRoots.render(spoiler, spoilerContainer, node);
 
+                node.replaceWith(spoilerContainer);
                 node = spoilerContainer;
             }
 
@@ -479,12 +477,7 @@ export default class TextualBody extends React.Component {
 
         if (isEmote) {
             return (
-                
+
{mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()} @@ -497,7 +490,7 @@ export default class TextualBody extends React.Component { } if (isNotice) { return ( -
+
{body} {widgets}
@@ -505,14 +498,14 @@ export default class TextualBody extends React.Component { } if (isCaption) { return ( -
+
{body} {widgets}
); } return ( -
+
{body} {widgets}
diff --git a/src/components/views/messages/TextualEvent.tsx b/src/components/views/messages/TextualEvent.tsx index 1c1ba26d08..1c54963f76 100644 --- a/src/components/views/messages/TextualEvent.tsx +++ b/src/components/views/messages/TextualEvent.tsx @@ -19,7 +19,7 @@ interface IProps { export default class TextualEvent extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public render(): React.ReactNode { const text = TextForEvent.textForEvent( diff --git a/src/components/views/right_panel/PinnedMessagesCard.tsx b/src/components/views/right_panel/PinnedMessagesCard.tsx index af7106f9c5..d6161e9434 100644 --- a/src/components/views/right_panel/PinnedMessagesCard.tsx +++ b/src/components/views/right_panel/PinnedMessagesCard.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, { useCallback, useEffect, JSX } from "react"; +import React, { useCallback, useEffect, JSX, useContext } from "react"; import { Room, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { Button, Separator } from "@vector-im/compound-web"; import classNames from "classnames"; @@ -18,7 +18,7 @@ import Spinner from "../elements/Spinner"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { PinnedEventTile } from "../rooms/PinnedEventTile"; import { useRoomState } from "../../../hooks/useRoomState"; -import RoomContext, { TimelineRenderingType, useRoomContext } from "../../../contexts/RoomContext"; +import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { ReadPinsEventId } from "./types"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { filterBoolean } from "../../../utils/arrays"; @@ -27,6 +27,7 @@ import { UnpinAllDialog } from "../dialogs/UnpinAllDialog"; import EmptyState from "./EmptyState"; import { usePinnedEvents, useReadPinnedEvents, useSortedFetchedPinnedEvents } from "../../../hooks/usePinnedEvents"; import PinningUtils from "../../../utils/PinningUtils.ts"; +import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx"; /** * List the pinned messages in a room inside a Card. @@ -48,7 +49,7 @@ interface PinnedMessagesCardProps { export function PinnedMessagesCard({ room, onClose, permalinkCreator }: PinnedMessagesCardProps): JSX.Element { const cli = useMatrixClientContext(); - const roomContext = useRoomContext(); + const roomContext = useContext(RoomContext); const pinnedEventIds = usePinnedEvents(room); const readPinnedEvents = useReadPinnedEvents(room); const pinnedEvents = useSortedFetchedPinnedEvents(room, pinnedEventIds); @@ -89,14 +90,9 @@ export function PinnedMessagesCard({ room, onClose, permalinkCreator }: PinnedMe className="mx_PinnedMessagesCard" onClose={onClose} > - + {content} - + ); } diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index 664977bbe2..d0e3694ea5 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -51,7 +51,7 @@ import ShareDialog from "../dialogs/ShareDialog"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { E2EStatus } from "../../../utils/ShieldUtils"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; +import { TimelineRenderingType } from "../../../contexts/RoomContext"; import RoomName from "../elements/RoomName"; import ExportDialog from "../dialogs/ExportDialog"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; @@ -76,6 +76,7 @@ import { useTransition } from "../../../hooks/useTransition"; import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; import { usePinnedEvents } from "../../../hooks/usePinnedEvents"; import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps { room: Room; @@ -86,7 +87,7 @@ interface IProps { } const onRoomMembersClick = (): void => { - RightPanelStore.instance.pushCard({ phase: RightPanelPhases.RoomMemberList }, true); + RightPanelStore.instance.pushCard({ phase: RightPanelPhases.MemberList }, true); }; const onRoomThreadsClick = (): void => { @@ -232,7 +233,7 @@ const RoomSummaryCard: React.FC = ({ }; const isRoomEncrypted = useIsEncrypted(cli, room); - const roomContext = useContext(RoomContext); + const roomContext = useScopedRoomContext("e2eStatus", "timelineRenderingType"); const e2eStatus = roomContext.e2eStatus; const isVideoRoom = calcIsVideoRoom(room); diff --git a/src/components/views/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index e0988eeaa5..f62319f3cd 100644 --- a/src/components/views/right_panel/TimelineCard.tsx +++ b/src/components/views/right_panel/TimelineCard.tsx @@ -38,6 +38,7 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import Measured from "../elements/Measured"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { SdkContextClass } from "../../../contexts/SDKContext"; +import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps { room: Room; @@ -68,7 +69,7 @@ interface IState { export default class TimelineCard extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private dispatcherRef?: string; private layoutWatcherRef?: string; @@ -199,13 +200,11 @@ export default class TimelineCard extends React.Component { const showComposer = myMembership === KnownMembership.Join; return ( - { /> )} - + ); } } diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index d07b3566e2..b4a775367c 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -1739,13 +1739,13 @@ export const UserInfoHeader: React.FC<{ interface IProps { user: Member; room?: Room; - phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.SpaceMemberInfo | RightPanelPhases.EncryptionPanel; + phase: RightPanelPhases.MemberInfo | RightPanelPhases.EncryptionPanel; onClose(): void; verificationRequest?: VerificationRequest; verificationRequestPromise?: Promise; } -const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPhases.RoomMemberInfo, ...props }) => { +const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPhases.MemberInfo, ...props }) => { const cli = useContext(MatrixClientContext); // fetch latest room member if we have a room, so we don't show historical information, falling back to user @@ -1767,8 +1767,6 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha // We have no previousPhase for when viewing a UserInfo without a Room at this time if (room && phase === RightPanelPhases.EncryptionPanel) { cardState = { member }; - } else if (room?.isSpaceRoom()) { - cardState = { spaceId: room.roomId }; } const onEncryptionPanelClose = (): void => { @@ -1777,8 +1775,7 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha let content: JSX.Element | undefined; switch (phase) { - case RightPanelPhases.RoomMemberInfo: - case RightPanelPhases.SpaceMemberInfo: + case RightPanelPhases.MemberInfo: content = ( = ({ user, room, onClose, phase = RightPanelPha closeLabel={closeLabel} cardState={cardState} onBack={(ev: ButtonEvent) => { - if (RightPanelStore.instance.previousCard.phase === RightPanelPhases.RoomMemberList) { + if (RightPanelStore.instance.previousCard.phase === RightPanelPhases.MemberList) { PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoBackButton", ev); } }} diff --git a/src/components/views/room_settings/AliasSettings.tsx b/src/components/views/room_settings/AliasSettings.tsx index 3c1a745530..0bb29b7f89 100644 --- a/src/components/views/room_settings/AliasSettings.tsx +++ b/src/components/views/room_settings/AliasSettings.tsx @@ -94,7 +94,7 @@ interface IState { export default class AliasSettings extends React.Component { public static contextType = MatrixClientContext; - public declare context: ContextType; + declare public context: ContextType; public static defaultProps = { canSetAliases: false, 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..3ffd6648ea 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"; @@ -48,7 +49,7 @@ export default class Autocomplete extends React.PureComponent { private completionRefs: Record> = {}; public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); @@ -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/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index d62a451b8b..f4b5c3698e 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -43,25 +43,6 @@ import { attachMentions, attachRelation } from "./SendMessageComposer"; import { filterBoolean } from "../../../utils/arrays"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -function getHtmlReplyFallback(mxEvent: MatrixEvent): string { - const html = mxEvent.getContent().formatted_body; - if (!html) { - return ""; - } - const rootNode = new DOMParser().parseFromString(html, "text/html").body; - const mxReply = rootNode.querySelector("mx-reply"); - return (mxReply && mxReply.outerHTML) || ""; -} - -function getTextReplyFallback(mxEvent: MatrixEvent): string { - const body: string = mxEvent.getContent().body; - const lines = body.split("\n").map((l) => l.trim()); - if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) { - return `${lines[0]}\n\n`; - } - return ""; -} - // exported for tests export function createEditContent( model: EditorModel, @@ -72,15 +53,6 @@ export function createEditContent( if (isEmote) { model = stripEmoteCommand(model); } - const isReply = !!editedEvent.replyEventId; - let plainPrefix = ""; - let htmlPrefix = ""; - - if (isReply) { - plainPrefix = getTextReplyFallback(editedEvent); - htmlPrefix = getHtmlReplyFallback(editedEvent); - } - const body = textSerialize(model); const newContent: RoomMessageEventContent = { @@ -89,19 +61,18 @@ export function createEditContent( }; const contentBody: RoomMessageTextEventContent & Omit, "m.relates_to"> = { "msgtype": newContent.msgtype, - "body": `${plainPrefix} * ${body}`, + "body": `* ${body}`, "m.new_content": newContent, }; const formattedBody = htmlSerializeIfNeeded(model, { - forceHTML: isReply, useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), }); if (formattedBody) { newContent.format = "org.matrix.custom.html"; newContent.formatted_body = formattedBody; contentBody.format = newContent.format; - contentBody.formatted_body = `${htmlPrefix} * ${formattedBody}`; + contentBody.formatted_body = `* ${formattedBody}`; } // Build the mentions properties for both the content and new_content. @@ -121,7 +92,7 @@ interface IState { class EditMessageComposer extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private readonly editorRef = createRef(); private dispatcherRef?: string; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 22da73bef7..8c755f00bd 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -296,7 +296,7 @@ export class UnwrappedEventTile extends React.Component }; public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private unmounted = false; @@ -757,6 +757,14 @@ export class UnwrappedEventTile extends React.Component case EventShieldReason.MISMATCHED_SENDER_KEY: shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender_key"); break; + + case EventShieldReason.SENT_IN_CLEAR: + shieldReasonMessage = _t("common|unencrypted"); + break; + + case EventShieldReason.VERIFICATION_VIOLATION: + shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified"); + break; } if (this.state.shieldColour === EventShieldColour.GREY) { @@ -767,7 +775,7 @@ export class UnwrappedEventTile extends React.Component } } - if (MatrixClientPeg.safeGet().isRoomEncrypted(ev.getRoomId()!)) { + if (this.context.isRoomEncrypted) { // else if room is encrypted // and event is being encrypted or is not_sent (Unknown Devices/Network Error) if (ev.status === EventStatus.ENCRYPTING) { diff --git a/src/components/views/rooms/HistoryTile.tsx b/src/components/views/rooms/HistoryTile.tsx index c52ab044a7..3aa74b8b0c 100644 --- a/src/components/views/rooms/HistoryTile.tsx +++ b/src/components/views/rooms/HistoryTile.tsx @@ -6,15 +6,15 @@ 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, { useContext } from "react"; +import React from "react"; import { EventTimeline } from "matrix-js-sdk/src/matrix"; import EventTileBubble from "../messages/EventTileBubble"; -import RoomContext from "../../../contexts/RoomContext"; import { _t } from "../../../languageHandler"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; const HistoryTile: React.FC = () => { - const { room } = useContext(RoomContext); + const { room } = useScopedRoomContext("room"); const oldState = room?.getLiveTimeline().getState(EventTimeline.BACKWARDS); const historyState = oldState?.getStateEvents("m.room.history_visibility")[0]?.getContent().history_visibility; diff --git a/src/components/views/rooms/MemberList.tsx b/src/components/views/rooms/MemberList.tsx index e503ce2363..5587b56bf8 100644 --- a/src/components/views/rooms/MemberList.tsx +++ b/src/components/views/rooms/MemberList.tsx @@ -75,7 +75,7 @@ export default class MemberList extends React.Component { private unmounted = false; public static contextType = SDKContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private tiles: Map = new Map(); public constructor(props: IProps, context: React.ContextType) { diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 62029f46c3..f5716d728b 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"; @@ -47,14 +48,9 @@ import MessageComposerButtons from "./MessageComposerButtons"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; -import { Features } from "../../../settings/Settings"; import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysiwyg_composer/"; import { MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext"; -import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording"; -import { SdkContextClass } from "../../../contexts/SDKContext"; -import { VoiceBroadcastInfoState } from "../../../voice-broadcast"; -import { createCantStartVoiceMessageBroadcastDialog } from "../dialogs/CantStartVoiceMessageBroadcastDialog"; import { UIFeature } from "../../../settings/UIFeature"; import { formatTimeLeft } from "../../../DateUtils"; import RoomReplacedSvg from "../../../../res/img/room_replaced.svg"; @@ -100,7 +96,6 @@ interface IState { isStickerPickerOpen: boolean; showStickersButton: boolean; showPollsButton: boolean; - showVoiceBroadcastButton: boolean; isWysiwygLabEnabled: boolean; isRichTextEnabled: boolean; initialComposerContent: string; @@ -122,11 +117,10 @@ export class MessageComposer extends React.Component { private _voiceRecording: Optional; public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public static defaultProps = { compact: false, - showVoiceBroadcastButton: false, isRichTextEnabled: true, }; @@ -154,7 +148,6 @@ export class MessageComposer extends React.Component { isStickerPickerOpen: false, showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"), showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"), - showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast), isWysiwygLabEnabled: isWysiwygLabEnabled, isRichTextEnabled: isRichTextEnabled, initialComposerContent: initialComposerContent, @@ -249,7 +242,6 @@ export class MessageComposer extends React.Component { SettingsStore.monitorSetting("MessageComposerInput.showStickersButton", null); SettingsStore.monitorSetting("MessageComposerInput.showPollsButton", null); - SettingsStore.monitorSetting(Features.VoiceBroadcast, null); SettingsStore.monitorSetting("feature_wysiwyg_composer", null); this.dispatcherRef = dis.register(this.onAction); @@ -300,12 +292,6 @@ export class MessageComposer extends React.Component { } break; } - case Features.VoiceBroadcast: { - if (this.state.showVoiceBroadcastButton !== settingUpdatedPayload.newValue) { - this.setState({ showVoiceBroadcastButton: !!settingUpdatedPayload.newValue }); - } - break; - } case "feature_wysiwyg_composer": { if (this.state.isWysiwygLabEnabled !== settingUpdatedPayload.newValue) { this.setState({ isWysiwygLabEnabled: Boolean(settingUpdatedPayload.newValue) }); @@ -532,13 +518,7 @@ export class MessageComposer extends React.Component { } private onRecordStartEndClick = (): void => { - const currentBroadcastRecording = SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent(); - - if (currentBroadcastRecording && currentBroadcastRecording.getState() !== VoiceBroadcastInfoState.Stopped) { - createCantStartVoiceMessageBroadcastDialog(); - } else { - this.voiceRecordingButton.current?.onRecordStartEndClick(); - } + this.voiceRecordingButton.current?.onRecordStartEndClick(); if (this.context.narrow) { this.toggleButtonMenu(); @@ -669,6 +649,7 @@ export class MessageComposer extends React.Component {
+ { isRichTextEnabled={this.state.isRichTextEnabled} onComposerModeClick={this.onRichTextToggle} toggleButtonMenu={this.toggleButtonMenu} - showVoiceBroadcastButton={this.state.showVoiceBroadcastButton} - onStartVoiceBroadcastClick={() => { - setUpVoiceBroadcastPreRecording( - this.props.room, - MatrixClientPeg.safeGet(), - SdkContextClass.instance.voiceBroadcastPlaybacksStore, - SdkContextClass.instance.voiceBroadcastRecordingsStore, - SdkContextClass.instance.voiceBroadcastPreRecordingStore, - ); - this.toggleButtonMenu(); - }} /> )} {showSendButton && ( diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index 003c2afed9..19b86834dd 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -21,7 +21,6 @@ import PollCreateDialog from "../elements/PollCreateDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import ContentMessages from "../../../ContentMessages"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import RoomContext from "../../../contexts/RoomContext"; import { useDispatcher } from "../../../hooks/useDispatcher"; import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu"; @@ -29,6 +28,7 @@ import { EmojiButton } from "./EmojiButton"; import { filterBoolean } from "../../../utils/arrays"; import { useSettingValue } from "../../../hooks/useSettings"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps { addEmoji: (emoji: string) => boolean; @@ -43,8 +43,6 @@ interface IProps { showPollsButton: boolean; showStickersButton: boolean; toggleButtonMenu: () => void; - showVoiceBroadcastButton: boolean; - onStartVoiceBroadcastClick: () => void; isRichTextEnabled: boolean; onComposerModeClick: () => void; } @@ -54,7 +52,7 @@ export const OverflowMenuContext = createContext(null const MessageComposerButtons: React.FC = (props: IProps) => { const matrixClient = useContext(MatrixClientContext); - const { room, narrow } = useContext(RoomContext); + const { room, narrow } = useScopedRoomContext("room", "narrow"); const isWysiwygLabEnabled = useSettingValue("feature_wysiwyg_composer"); @@ -80,7 +78,6 @@ const MessageComposerButtons: React.FC = (props: IProps) => { uploadButton(), // props passed via UploadButtonContext showStickersButton(props), voiceRecordingButton(props, narrow), - startVoiceBroadcastButton(props), props.showPollsButton ? pollButton(room, props.relation) : null, showLocationButton(props, room, matrixClient), ]; @@ -100,7 +97,6 @@ const MessageComposerButtons: React.FC = (props: IProps) => { moreButtons = [ showStickersButton(props), voiceRecordingButton(props, narrow), - startVoiceBroadcastButton(props), props.showPollsButton ? pollButton(room, props.relation) : null, showLocationButton(props, room, matrixClient), ]; @@ -168,7 +164,7 @@ interface IUploadButtonProps { // We put the file input outside the UploadButton component so that it doesn't get killed when the context menu closes. const UploadButtonContextProvider: React.FC = ({ roomId, relation, children }) => { const cli = useContext(MatrixClientContext); - const roomContext = useContext(RoomContext); + const roomContext = useScopedRoomContext("timelineRenderingType"); const uploadInput = useRef(null); const onUploadClick = (): void => { @@ -254,18 +250,6 @@ function showStickersButton(props: IProps): ReactElement | null { ) : null; } -const startVoiceBroadcastButton: React.FC = (props: IProps): ReactElement | null => { - return props.showVoiceBroadcastButton ? ( - - ) : null; -}; - function voiceRecordingButton(props: IProps, narrow: boolean): ReactElement | null { // XXX: recording UI does not work well in narrow mode, so hide for now return narrow ? null : ( @@ -290,7 +274,7 @@ interface IPollButtonProps { class PollButton extends React.PureComponent { public static contextType = OverflowMenuContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private onCreateClick = (): void => { this.context?.(); // close overflow menu diff --git a/src/components/views/rooms/MessageComposerFormatBar.tsx b/src/components/views/rooms/MessageComposerFormatBar.tsx index 34798cc608..0ab359d9dd 100644 --- a/src/components/views/rooms/MessageComposerFormatBar.tsx +++ b/src/components/views/rooms/MessageComposerFormatBar.tsx @@ -33,6 +33,12 @@ interface IState { export default class MessageComposerFormatBar extends React.PureComponent { private readonly formatBarRef = createRef(); + /** + * The height of the format bar in pixels. + * Height 32px + 2px border + * @private + */ + private readonly BAR_HEIGHT = 34; public constructor(props: IProps) { super(props); @@ -96,7 +102,7 @@ export default class MessageComposerFormatBar extends React.PureComponent { const cli = useContext(MatrixClientContext); - const { room, roomId } = useContext(RoomContext); + const { room, roomId } = useScopedRoomContext("room", "roomId"); if (!room || !roomId) { throw new Error("Unable to create a NewRoomIntro without room and roomId"); diff --git a/src/components/views/rooms/ReplyPreview.tsx b/src/components/views/rooms/ReplyPreview.tsx index c820154b2b..7851f7914d 100644 --- a/src/components/views/rooms/ReplyPreview.tsx +++ b/src/components/views/rooms/ReplyPreview.tsx @@ -31,7 +31,7 @@ interface IProps { export default class ReplyPreview extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public render(): JSX.Element | null { if (!this.props.replyToEvent) return null; diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index c2642ea733..d133587fc9 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.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, { useCallback, useContext, useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web"; import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid"; import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call"; @@ -48,10 +48,10 @@ import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton"; import { ButtonEvent } from "../elements/AccessibleButton"; import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator"; import { IOOBData } from "../../../stores/ThreepidInviteStore"; -import RoomContext from "../../../contexts/RoomContext"; import { MainSplitContentType } from "../../structures/RoomView"; import defaultDispatcher from "../../../dispatcher/dispatcher.ts"; import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog.tsx"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; export default function RoomHeader({ room, @@ -229,7 +229,7 @@ export default function RoomHeader({ voiceCallButton = undefined; } - const roomContext = useContext(RoomContext); + const roomContext = useScopedRoomContext("mainSplitContentType"); const isVideoRoom = calcIsVideoRoom(room); const showChatButton = isVideoRoom || @@ -392,7 +392,7 @@ export default function RoomHeader({ viewUserOnClick={false} tooltipLabel={_t("room|header_face_pile_tooltip")} onClick={(e: ButtonEvent) => { - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomMemberList); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList); e.stopPropagation(); }} aria-label={_t("common|n_members", { count: memberCount })} diff --git a/src/components/views/rooms/RoomInfoLine.tsx b/src/components/views/rooms/RoomInfoLine.tsx index 710ef61758..1487bfe15b 100644 --- a/src/components/views/rooms/RoomInfoLine.tsx +++ b/src/components/views/rooms/RoomInfoLine.tsx @@ -64,7 +64,7 @@ const RoomInfoLine: FC = ({ room }) => { // summary is not still loading const viewMembers = (): void => RightPanelStore.instance.setCard({ - phase: room.isSpaceRoom() ? RightPanelPhases.SpaceMemberList : RightPanelPhases.RoomMemberList, + phase: RightPanelPhases.MemberList, }); members = ( diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 853bebc4fe..f3bde66af9 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -424,7 +424,7 @@ export default class RoomList extends React.PureComponent { private treeRef = createRef(); public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 8351c176ff..7953c5068d 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -39,7 +39,6 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu"; import { CallStore, CallStoreEvent } from "../../../stores/CallStore"; import { SdkContextClass } from "../../../contexts/SDKContext"; -import { useHasRoomLiveVoiceBroadcast } from "../../../voice-broadcast"; import { RoomTileSubtitle } from "./RoomTileSubtitle"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; @@ -53,10 +52,6 @@ interface Props { tag: TagID; } -interface ClassProps extends Props { - hasLiveVoiceBroadcast: boolean; -} - type PartialDOMRect = Pick; interface State { @@ -77,13 +72,13 @@ export const contextMenuBelow = (elementRect: PartialDOMRect): MenuProps => { return { left, top, chevronFace }; }; -export class RoomTile extends React.PureComponent { +class RoomTile extends React.PureComponent { private dispatcherRef?: string; private roomTileRef = createRef(); private notificationState: NotificationState; private roomProps: RoomEchoChamber; - public constructor(props: ClassProps) { + public constructor(props: Props) { super(props); this.state = { @@ -370,15 +365,10 @@ export class RoomTile extends React.PureComponent { /** * RoomTile has a subtile if one of the following applies: * - there is a call - * - there is a live voice broadcast * - message previews are enabled and there is a previewable message */ private get shouldRenderSubtitle(): boolean { - return ( - !!this.state.call || - this.props.hasLiveVoiceBroadcast || - (this.props.showMessagePreview && !!this.state.messagePreview) - ); + return !!this.state.call || (this.props.showMessagePreview && !!this.state.messagePreview); } public render(): React.ReactElement { @@ -409,7 +399,6 @@ export class RoomTile extends React.PureComponent { const subtitle = this.shouldRenderSubtitle ? ( { } } -const RoomTileHOC: React.FC = (props: Props) => { - const hasLiveVoiceBroadcast = useHasRoomLiveVoiceBroadcast(props.room); - return ; -}; - -export default RoomTileHOC; +export default RoomTile; diff --git a/src/components/views/rooms/RoomTileSubtitle.tsx b/src/components/views/rooms/RoomTileSubtitle.tsx index ea4a96d259..479b9c4f71 100644 --- a/src/components/views/rooms/RoomTileSubtitle.tsx +++ b/src/components/views/rooms/RoomTileSubtitle.tsx @@ -13,11 +13,9 @@ import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons" import { MessagePreview } from "../../../stores/room-list/MessagePreviewStore"; import { Call } from "../../../models/Call"; import { RoomTileCallSummary } from "./RoomTileCallSummary"; -import { VoiceBroadcastRoomSubtitle } from "../../../voice-broadcast"; interface Props { call: Call | null; - hasLiveVoiceBroadcast: boolean; messagePreview: MessagePreview | null; roomId: string; showMessagePreview: boolean; @@ -25,13 +23,7 @@ interface Props { const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`; -export const RoomTileSubtitle: React.FC = ({ - call, - hasLiveVoiceBroadcast, - messagePreview, - roomId, - showMessagePreview, -}) => { +export const RoomTileSubtitle: React.FC = ({ call, messagePreview, roomId, showMessagePreview }) => { if (call) { return (
@@ -40,10 +32,6 @@ export const RoomTileSubtitle: React.FC = ({ ); } - if (hasLiveVoiceBroadcast) { - return ; - } - if (showMessagePreview && messagePreview) { const className = classNames("mx_RoomTile_subtitle", { "mx_RoomTile_subtitle--thread-reply": messagePreview.isThreadReply, diff --git a/src/components/views/rooms/RoomUpgradeWarningBar.tsx b/src/components/views/rooms/RoomUpgradeWarningBar.tsx index 66519fa766..e92be96cb2 100644 --- a/src/components/views/rooms/RoomUpgradeWarningBar.tsx +++ b/src/components/views/rooms/RoomUpgradeWarningBar.tsx @@ -25,7 +25,7 @@ interface IState { export default class RoomUpgradeWarningBar extends React.PureComponent { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx index 5ebbaffdd9..94f5e6da9d 100644 --- a/src/components/views/rooms/SearchResultTile.tsx +++ b/src/components/views/rooms/SearchResultTile.tsx @@ -36,7 +36,7 @@ interface IProps { export default class SearchResultTile extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; // A map of private callEventGroupers = new Map(); diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 252957c2c7..b3767cbd2a 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -193,7 +193,6 @@ export function createMessageContent( body: body, }; const formattedBody = htmlSerializeIfNeeded(model, { - forceHTML: !!replyToEvent, useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), }); if (formattedBody) { @@ -241,7 +240,7 @@ interface ISendMessageComposerProps extends MatrixClientProps { export class SendMessageComposer extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private readonly prepareToEncrypt?: DebouncedFunc<() => void>; private readonly editorRef = createRef(); diff --git a/src/components/views/rooms/ThreadSummary.tsx b/src/components/views/rooms/ThreadSummary.tsx index 4a3032d641..ac23331f66 100644 --- a/src/components/views/rooms/ThreadSummary.tsx +++ b/src/components/views/rooms/ThreadSummary.tsx @@ -16,7 +16,6 @@ import { CardContext } from "../right_panel/context"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import PosthogTrackers from "../../../PosthogTrackers"; import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; -import RoomContext from "../../../contexts/RoomContext"; import MemberAvatar from "../avatars/MemberAvatar"; import { Action } from "../../../dispatcher/actions"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; @@ -24,6 +23,7 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications"; import { notificationLevelToIndicator } from "../../../utils/notifications"; import { EventPreviewTile, useEventPreview } from "./EventPreview.tsx"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps { mxEvent: MatrixEvent; @@ -31,7 +31,7 @@ interface IProps { } const ThreadSummary: React.FC = ({ mxEvent, thread, ...props }) => { - const roomContext = useContext(RoomContext); + const roomContext = useScopedRoomContext("narrow"); const cardContext = useContext(CardContext); const count = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.length); const { level } = useUnreadNotifications(thread.room, thread.id); diff --git a/src/components/views/rooms/UserIdentityWarning.tsx b/src/components/views/rooms/UserIdentityWarning.tsx new file mode 100644 index 0000000000..564ab71987 --- /dev/null +++ b/src/components/views/rooms/UserIdentityWarning.tsx @@ -0,0 +1,328 @@ +/* +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, { useCallback, useRef, useState } from "react"; +import { EventType, KnownMembership, MatrixEvent, Room, RoomStateEvent, RoomMember } from "matrix-js-sdk/src/matrix"; +import { CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; +import { logger } from "matrix-js-sdk/src/logger"; +import { Button, Separator } from "@vector-im/compound-web"; + +import { _t } from "../../../languageHandler"; +import MemberAvatar from "../avatars/MemberAvatar"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; + +interface UserIdentityWarningProps { + /** + * The current room being viewed. + */ + room: Room; + /** + * The ID of the room being viewed. This is used to ensure that the + * component's state and references are cleared when the room changes. + */ + key: string; +} + +/** + * Does the given user's identity need to be approved? + */ +async function userNeedsApproval(crypto: CryptoApi, userId: string): Promise { + 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/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index a6d4a2fc27..a8335a9902 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -53,7 +53,7 @@ interface IState { */ export default class VoiceRecordComposerTile extends React.PureComponent { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private voiceRecordingId: string; public constructor(props: IProps, context: React.ContextType) { diff --git a/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx b/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx index b7a6d65e23..9ab3d210ab 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx @@ -13,14 +13,14 @@ import { EmojiButton } from "../../EmojiButton"; import dis from "../../../../../dispatcher/dispatcher"; import { ComposerInsertPayload } from "../../../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from "../../../../../dispatcher/actions"; -import { useRoomContext } from "../../../../../contexts/RoomContext"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; interface EmojiProps { menuPosition: MenuProps; } export function Emoji({ menuPosition }: EmojiProps): JSX.Element { - const roomContext = useRoomContext(); + const roomContext = useScopedRoomContext("timelineRenderingType"); return ( , ): JSX.Element | null => { - const { room } = useRoomContext(); + const { room } = useScopedRoomContext("room"); const client = useMatrixClientContext(); function handleConfirm(completion: ICompletion): void { diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index 5b6361a58e..f1e42ce091 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -19,12 +19,12 @@ import { Editor } from "./Editor"; import { useInputEventProcessor } from "../hooks/useInputEventProcessor"; import { useSetCursorPosition } from "../hooks/useSetCursorPosition"; import { useIsFocused } from "../hooks/useIsFocused"; -import { useRoomContext } from "../../../../../contexts/RoomContext"; import defaultDispatcher from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; import { parsePermalink } from "../../../../../utils/permalinks/Permalinks"; import { isNotNull } from "../../../../../Typeguards"; import { useSettingValue } from "../../../../../hooks/useSettings"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; interface WysiwygComposerProps { disabled?: boolean; @@ -56,7 +56,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({ children, eventRelation, }: WysiwygComposerProps) { - const { room } = useRoomContext(); + const { room } = useScopedRoomContext("room"); const autocompleteRef = useRef(null); const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts index 5d1c3b867e..20f394e8a3 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts @@ -10,10 +10,10 @@ import { ISendEventResponse } from "matrix-js-sdk/src/matrix"; import { useCallback, useState } from "react"; import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; -import { useRoomContext } from "../../../../../contexts/RoomContext"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; import { endEditing } from "../utils/editing"; import { editMessage } from "../utils/message"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; export function useEditing( editorStateTransfer: EditorStateTransfer, @@ -24,7 +24,7 @@ export function useEditing( editMessage(): Promise; endEditing(): void; } { - const roomContext = useRoomContext(); + const roomContext = useScopedRoomContext("timelineRenderingType"); const mxClient = useMatrixClientContext(); const [isSaveDisabled, setIsSaveDisabled] = useState(true); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts index 2e0ddd3ccd..3a3799496b 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts @@ -10,11 +10,11 @@ import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { useMemo } from "react"; import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; -import { useRoomContext } from "../../../../../contexts/RoomContext"; import { parseEvent } from "../../../../../editor/deserialize"; import { CommandPartCreator, Part } from "../../../../../editor/parts"; import SettingsStore from "../../../../../settings/SettingsStore"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; function getFormattedContent(editorStateTransfer: EditorStateTransfer): string { return ( @@ -60,12 +60,12 @@ export function parseEditorStateTransfer( } export function useInitialContent(editorStateTransfer: EditorStateTransfer): string | undefined { - const roomContext = useRoomContext(); + const { room } = useScopedRoomContext("room"); const mxClient = useMatrixClientContext(); return useMemo(() => { - if (editorStateTransfer && roomContext.room && mxClient) { - return parseEditorStateTransfer(editorStateTransfer, roomContext.room, mxClient); + if (editorStateTransfer && room && mxClient) { + return parseEditorStateTransfer(editorStateTransfer, room, mxClient); } - }, [editorStateTransfer, roomContext, mxClient]); + }, [editorStateTransfer, room, mxClient]); } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts index 8eac63eb36..cab3bdefb8 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -16,7 +16,6 @@ import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts import { findEditableEvent } from "../../../../../utils/EventUtils"; import dis from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; -import { useRoomContext } from "../../../../../contexts/RoomContext"; import { IRoomState } from "../../../../structures/RoomView"; import { ComposerContextState, useComposerContext } from "../ComposerContext"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; @@ -26,6 +25,7 @@ import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/ev import { endEditing } from "../utils/editing"; import Autocomplete from "../../Autocomplete"; import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; export function useInputEventProcessor( onSend: () => void, @@ -33,7 +33,7 @@ export function useInputEventProcessor( initialContent?: string, eventRelation?: IEventRelation, ): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null { - const roomContext = useRoomContext(); + const roomContext = useScopedRoomContext("liveTimeline", "room", "replyToEvent", "timelineRenderingType"); const composerContext = useComposerContext(); const mxClient = useMatrixClientContext(); const isCtrlEnterToSend = useSettingValue("MessageComposerInput.ctrlEnterToSend"); @@ -94,7 +94,7 @@ function handleKeyboardEvent( initialContent: string | undefined, composer: Wysiwyg, editor: HTMLElement, - roomContext: IRoomState, + roomContext: Pick, composerContext: ComposerContextState, mxClient: MatrixClient | undefined, autocompleteRef: React.RefObject, @@ -175,7 +175,7 @@ function dispatchEditEvent( isForward: boolean, editorStateTransfer: EditorStateTransfer | undefined, composerContext: ComposerContextState, - roomContext: IRoomState, + roomContext: Pick, mxClient: MatrixClient, ): boolean { const foundEvents = editorStateTransfer diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts index 742d24fe34..1dc23cc274 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts @@ -16,8 +16,8 @@ import Autocomplete from "../../Autocomplete"; import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils"; import { useSuggestion } from "./useSuggestion"; import { isNotNull, isNotUndefined } from "../../../../../Typeguards"; -import { useRoomContext } from "../../../../../contexts/RoomContext"; import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; function isDivElement(target: EventTarget): target is HTMLDivElement { return target instanceof HTMLDivElement; @@ -63,7 +63,7 @@ export function usePlainTextListeners( onSelect: (event: SyntheticEvent) => void; suggestion: MappedSuggestion | null; } { - const roomContext = useRoomContext(); + const roomContext = useScopedRoomContext("room", "timelineRenderingType", "replyToEvent"); const mxClient = useMatrixClientContext(); const ref = useRef(null); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts index e1e34623c8..eb76d77af5 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts @@ -11,20 +11,21 @@ import { RefObject, useCallback, useRef } from "react"; import defaultDispatcher from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; import { ActionPayload } from "../../../../../dispatcher/payloads"; -import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext"; +import { TimelineRenderingType } from "../../../../../contexts/RoomContext"; import { useDispatcher } from "../../../../../hooks/useDispatcher"; import { focusComposer } from "./utils"; import { ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload"; import { ComposerFunctions } from "../types"; import { setSelection } from "../utils/selection"; import { useComposerContext } from "../ComposerContext"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; export function useWysiwygEditActionHandler( disabled: boolean, composerElement: RefObject, composerFunctions: ComposerFunctions, ): void { - const roomContext = useRoomContext(); + const roomContext = useScopedRoomContext("timelineRenderingType"); const composerContext = useComposerContext(); const timeoutId = useRef(null); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts index 16e1e608ec..d11f3498fd 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts @@ -11,20 +11,21 @@ import { MutableRefObject, useCallback, useRef } from "react"; import defaultDispatcher from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; import { ActionPayload } from "../../../../../dispatcher/payloads"; -import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext"; +import { TimelineRenderingType } from "../../../../../contexts/RoomContext"; import { useDispatcher } from "../../../../../hooks/useDispatcher"; import { focusComposer } from "./utils"; import { ComposerFunctions } from "../types"; import { ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload"; import { useComposerContext } from "../ComposerContext"; import { setSelection } from "../utils/selection"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; export function useWysiwygSendActionHandler( disabled: boolean, composerElement: MutableRefObject, composerFunctions: ComposerFunctions, ): void { - const roomContext = useRoomContext(); + const roomContext = useScopedRoomContext("timelineRenderingType"); const composerContext = useComposerContext(); const timeoutId = useRef(null); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index 39317ea88c..3345c9f474 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -22,7 +22,7 @@ import { isNotNull } from "../../../../../Typeguards"; export function focusComposer( composerElement: MutableRefObject, renderingType: TimelineRenderingType, - roomContext: IRoomState, + roomContext: Pick, timeoutId: MutableRefObject, ): void { if (renderingType === roomContext.timelineRenderingType) { @@ -123,7 +123,7 @@ export function handleEventWithAutocomplete( export function handleClipboardEvent( event: ClipboardEvent | InputEvent, data: DataTransfer | null, - roomContext: IRoomState, + roomContext: Pick, mxClient: MatrixClient, eventRelation?: IEventRelation, ): boolean { diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts index 7f42ed2327..58d09b3d12 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -27,28 +27,6 @@ function attachRelation(content: IContent, relation?: IEventRelation): void { } } -function getHtmlReplyFallback(mxEvent: MatrixEvent): string { - const html = mxEvent.getContent().formatted_body; - if (!html) { - return ""; - } - const rootNode = new DOMParser().parseFromString(html, "text/html").body; - const mxReply = rootNode.querySelector("mx-reply"); - return (mxReply && mxReply.outerHTML) || ""; -} - -function getTextReplyFallback(mxEvent: MatrixEvent): string { - const body = mxEvent.getContent().body; - if (typeof body !== "string") { - return ""; - } - const lines = body.split("\n").map((l) => l.trim()); - if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) { - return `${lines[0]}\n\n`; - } - return ""; -} - interface CreateMessageContentParams { relation?: IEventRelation; replyToEvent?: MatrixEvent; @@ -63,8 +41,6 @@ export async function createMessageContent( { relation, replyToEvent, editedEvent }: CreateMessageContentParams, ): Promise { const isEditing = isMatrixEvent(editedEvent); - const isReply = isEditing ? Boolean(editedEvent.replyEventId) : isMatrixEvent(replyToEvent); - const isReplyAndEditing = isEditing && isReply; const isEmote = message.startsWith(EMOTE_PREFIX); if (isEmote) { @@ -82,12 +58,10 @@ export async function createMessageContent( // if we're editing rich text, the message content is pure html // BUT if we're not, the message content will be plain text where we need to convert the mentions const body = isHTML ? await richToPlain(message, false) : convertPlainTextToBody(message); - const bodyPrefix = (isReplyAndEditing && getTextReplyFallback(editedEvent)) || ""; - const formattedBodyPrefix = (isReplyAndEditing && getHtmlReplyFallback(editedEvent)) || ""; const content = { msgtype: isEmote ? MsgType.Emote : MsgType.Text, - body: isEditing ? `${bodyPrefix} * ${body}` : body, + body: isEditing ? `* ${body}` : body, } as RoomMessageTextEventContent & ReplacementEvent; // TODO markdown support @@ -97,7 +71,7 @@ export async function createMessageContent( if (formattedBody) { content.format = "org.matrix.custom.html"; - content.formatted_body = isEditing ? `${formattedBodyPrefix} * ${formattedBody}` : formattedBody; + content.formatted_body = isEditing ? `* ${formattedBody}` : formattedBody; } if (isEditing) { diff --git a/src/components/views/rooms/wysiwyg_composer/utils/editing.ts b/src/components/views/rooms/wysiwyg_composer/utils/editing.ts index 58a9e24492..462763b8f4 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/editing.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/editing.ts @@ -13,7 +13,7 @@ import dis from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; -export function endEditing(roomContext: IRoomState): void { +export function endEditing(roomContext: Pick): void { // todo local storage // localStorage.removeItem(this.editorRoomKey); // localStorage.removeItem(this.editorStateKey); diff --git a/src/components/views/rooms/wysiwyg_composer/utils/event.ts b/src/components/views/rooms/wysiwyg_composer/utils/event.ts index 5fd37b3665..45c6b1cac3 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/event.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/event.ts @@ -15,7 +15,7 @@ import { ComposerContextState } from "../ComposerContext"; // From EditMessageComposer private get events(): MatrixEvent[] export function getEventsFromEditorStateTransfer( editorStateTransfer: EditorStateTransfer, - roomContext: IRoomState, + roomContext: Pick, mxClient: MatrixClient, ): MatrixEvent[] | undefined { const liveTimelineEvents = roomContext.liveTimeline?.getEvents(); @@ -41,7 +41,7 @@ export function getEventsFromEditorStateTransfer( // From SendMessageComposer private onKeyDown = (event: KeyboardEvent): void export function getEventsFromRoom( composerContext: ComposerContextState, - roomContext: IRoomState, + roomContext: Pick, ): MatrixEvent[] | undefined { const isReplyingToThread = composerContext.eventRelation?.key === THREAD_RELATION_TYPE.name; return roomContext.liveTimeline diff --git a/src/components/views/rooms/wysiwyg_composer/utils/message.ts b/src/components/views/rooms/wysiwyg_composer/utils/message.ts index 44e10e3cc5..b7fca8ecb4 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/message.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -39,7 +39,7 @@ export interface SendMessageParams { mxClient: MatrixClient; relation?: IEventRelation; replyToEvent?: MatrixEvent; - roomContext: IRoomState; + roomContext: Pick; } export async function sendMessage( @@ -177,7 +177,7 @@ export async function sendMessage( interface EditMessageParams { mxClient: MatrixClient; - roomContext: IRoomState; + roomContext: Pick; editorStateTransfer: EditorStateTransfer; } diff --git a/src/components/views/settings/CryptographyPanel.tsx b/src/components/views/settings/CryptographyPanel.tsx index b418c0b05d..fbd696f243 100644 --- a/src/components/views/settings/CryptographyPanel.tsx +++ b/src/components/views/settings/CryptographyPanel.tsx @@ -32,7 +32,7 @@ interface IState { export default class CryptographyPanel extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props); diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index 06c67c7d0b..05c7829644 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -118,7 +118,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { this.getUpdatedDiagnostics(); try { const cli = MatrixClientPeg.safeGet(); - const backupInfo = await cli.getKeyBackupVersion(); + const backupInfo = (await cli.getCrypto()?.getKeyBackupInfo()) ?? null; const backupTrustInfo = backupInfo ? await cli.getCrypto()?.isKeyBackupTrusted(backupInfo) : undefined; const activeBackupVersion = (await cli.getCrypto()?.getActiveSessionBackupVersion()) ?? null; @@ -192,12 +192,9 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { if (!proceed) return; this.setState({ loading: true }); const versionToDelete = this.state.backupInfo!.version!; - MatrixClientPeg.safeGet() - .getCrypto() - ?.deleteKeyBackupVersion(versionToDelete) - .then(() => { - this.loadBackupStatus(); - }); + // deleteKeyBackupVersion fires a key backup status event + // which will update the UI + MatrixClientPeg.safeGet().getCrypto()?.deleteKeyBackupVersion(versionToDelete); }, }); }; diff --git a/src/components/views/settings/devices/LoginWithQRSection.tsx b/src/components/views/settings/devices/LoginWithQRSection.tsx index a164ff894b..033aa8e32a 100644 --- a/src/components/views/settings/devices/LoginWithQRSection.tsx +++ b/src/components/views/settings/devices/LoginWithQRSection.tsx @@ -7,13 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { - IServerVersions, - IClientWellKnown, - OidcClientConfig, - MatrixClient, - DEVICE_CODE_SCOPE, -} from "matrix-js-sdk/src/matrix"; +import { IServerVersions, OidcClientConfig, MatrixClient, DEVICE_CODE_SCOPE } from "matrix-js-sdk/src/matrix"; import QrCodeIcon from "@vector-im/compound-design-tokens/assets/web/icons/qr-code"; import { Text } from "@vector-im/compound-web"; @@ -25,7 +19,6 @@ import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext interface IProps { onShowQr: () => void; versions?: IServerVersions; - wellKnown?: IClientWellKnown; oidcClientConfig?: OidcClientConfig; isCrossSigningReady?: boolean; } @@ -35,10 +28,8 @@ export function shouldShowQr( isCrossSigningReady: boolean, oidcClientConfig?: OidcClientConfig, versions?: IServerVersions, - wellKnown?: IClientWellKnown, ): boolean { - const msc4108Supported = - !!versions?.unstable_features?.["org.matrix.msc4108"] || !!wellKnown?.["io.element.rendezvous"]?.server; + const msc4108Supported = !!versions?.unstable_features?.["org.matrix.msc4108"]; const deviceAuthorizationGrantSupported = oidcClientConfig?.metadata?.grant_types_supported.includes(DEVICE_CODE_SCOPE); @@ -51,15 +42,9 @@ export function shouldShowQr( ); } -const LoginWithQRSection: React.FC = ({ - onShowQr, - versions, - wellKnown, - oidcClientConfig, - isCrossSigningReady, -}) => { +const LoginWithQRSection: React.FC = ({ onShowQr, versions, oidcClientConfig, isCrossSigningReady }) => { const cli = useMatrixClientContext(); - const offerShowQr = shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions, wellKnown); + const offerShowQr = shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions); return ( diff --git a/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx b/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx index d29d82853a..0da257607e 100644 --- a/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx @@ -28,7 +28,7 @@ interface IProps { export default class BridgeSettingsTab extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private renderBridgeCard(event: MatrixEvent, room: Room | null): ReactNode { const content = event.getContent(); diff --git a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx index 066dc45366..31c361de1b 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; @@ -33,7 +33,7 @@ interface IState { export default class GeneralRoomSettingsTab extends React.Component { public static contextType = MatrixClientContext; - public declare context: ContextType; + declare public context: ContextType; public constructor(props: IProps, context: ContextType) { super(props, context); diff --git a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx index f668b1ff07..9aabf1edb0 100644 --- a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx @@ -42,7 +42,7 @@ export default class NotificationsSettingsTab extends React.Component(); public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index ec8a2b8718..baf4b41253 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -19,7 +19,6 @@ import ErrorDialog from "../../../dialogs/ErrorDialog"; import PowerSelector from "../../../elements/PowerSelector"; import SettingsFieldset from "../../SettingsFieldset"; import SettingsStore from "../../../../../settings/SettingsStore"; -import { VoiceBroadcastInfoEventType } from "../../../../../voice-broadcast"; import { ElementCall } from "../../../../../models/Call"; import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig"; import { AddPrivilegedUsers } from "../../AddPrivilegedUsers"; @@ -62,7 +61,6 @@ const plEventsToShow: Record = { // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) "im.vector.modular.widgets": { isState: true, hideForSpace: true }, - [VoiceBroadcastInfoEventType]: { isState: true, hideForSpace: true }, }; // parse a string as an integer; if the input is undefined, or cannot be parsed @@ -81,7 +79,7 @@ interface IBannedUserProps { export class BannedUser extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private onUnbanClick = (): void => { this.context.unban(this.props.member.roomId, this.props.member.userId).catch((err) => { @@ -127,12 +125,30 @@ interface IProps { room: Room; } -export default class RolesRoomSettingsTab extends React.Component { - public static contextType = MatrixClientContext; - public declare context: React.ContextType; +interface RolesRoomSettingsTabState { + isRoomEncrypted: boolean; + isReady: boolean; +} - public componentDidMount(): void { +export default class RolesRoomSettingsTab extends React.Component { + public static contextType = MatrixClientContext; + declare public context: React.ContextType; + + 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 { @@ -271,7 +287,6 @@ export default class RolesRoomSettingsTab extends React.Component { // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) "im.vector.modular.widgets": isSpaceRoom ? null : _td("room_settings|permissions|m.widget"), - [VoiceBroadcastInfoEventType]: _td("room_settings|permissions|io.element.voice_broadcast_info"), }; // MSC3401: Native Group VoIP signaling @@ -416,7 +431,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 +473,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/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx index 1ade503bb2..ece6a7deaf 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -17,6 +17,7 @@ import { EventType, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; +import { InlineSpinner } from "@vector-im/compound-web"; import { Icon as WarningIcon } from "../../../../../../res/img/warning.svg"; import { _t } from "../../../../../languageHandler"; @@ -53,13 +54,13 @@ interface IState { guestAccess: GuestAccess; history: HistoryVisibility; hasAliases: boolean; - encrypted: boolean; + encrypted: boolean | null; showAdvancedSection: boolean; } export default class SecurityRoomSettingsTab extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); @@ -78,7 +79,7 @@ export default class SecurityRoomSettingsTab extends React.Component - - {isEncryptionForceDisabled && !isEncrypted && ( - {_t("room_settings|security|encryption_forced")} + {isEncryptionLoading ? ( + + ) : ( + <> + + {isEncryptionForceDisabled && !isEncrypted && ( + {_t("room_settings|security|encryption_forced")} + )} + {encryptionSettings} + )} - {encryptionSettings} - {this.renderJoinRule()} {historySection} diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx index 7866131a01..f19343be20 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx @@ -32,7 +32,7 @@ interface IState { export default class HelpUserSettingsTab extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 2e16f45762..5e9445bb99 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.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, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import React, { lazy, Suspense, useCallback, useContext, useEffect, useRef, useState } from "react"; import { discoverAndValidateOIDCIssuerWellKnown, MatrixClient } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { defer } from "matrix-js-sdk/src/utils"; @@ -184,7 +184,6 @@ const SessionManagerTab: React.FC<{ const userId = matrixClient?.getUserId(); const currentUserMember = (userId && matrixClient?.getUser(userId)) || undefined; const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]); - const wellKnown = useMemo(() => matrixClient?.getClientWellKnown(), [matrixClient]); const oidcClientConfig = useAsyncMemo(async () => { try { const authIssuer = await matrixClient?.getAuthIssuer(); @@ -305,7 +304,6 @@ const SessionManagerTab: React.FC<{ diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx index 36d336faa3..9711159a10 100644 --- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx @@ -51,7 +51,7 @@ const mapDeviceKindToHandlerValue = (deviceKind: MediaDeviceKindEnum): string | export default class VoiceUserSettingsTab extends React.Component<{}, IState> { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: {}, context: React.ContextType) { super(props, context); diff --git a/src/components/views/toasts/VerificationRequestToast.tsx b/src/components/views/toasts/VerificationRequestToast.tsx index 54932a12ed..7d31aa6764 100644 --- a/src/components/views/toasts/VerificationRequestToast.tsx +++ b/src/components/views/toasts/VerificationRequestToast.tsx @@ -117,7 +117,7 @@ export default class VerificationRequestToast extends React.PureComponent this.state = { pending: false, }; - - // As this component is also used before login (during complete security), - // also make sure we have a working emoji font to display the SAS emojis here. - // This is also done from LoggedInView. - fixupColorFonts(); } private onMatchClick = (): void => { diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index acd4bfa6f4..4303c46a34 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -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 { createContext, useContext } from "react"; +import { createContext } from "react"; import { IRoomState } from "../components/structures/RoomView"; import { Layout } from "../settings/enums/Layout"; @@ -75,9 +75,7 @@ const RoomContext = createContext< canAskToJoin: false, promptAskToJoin: false, viewRoomOpts: { buttons: [] }, + isRoomEncrypted: null, }); RoomContext.displayName = "RoomContext"; export default RoomContext; -export function useRoomContext(): IRoomState { - return useContext(RoomContext); -} diff --git a/src/contexts/SDKContext.ts b/src/contexts/SDKContext.ts index 28e7e3aadb..fe73661554 100644 --- a/src/contexts/SDKContext.ts +++ b/src/contexts/SDKContext.ts @@ -25,11 +25,6 @@ import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import { WidgetPermissionStore } from "../stores/widgets/WidgetPermissionStore"; import { OidcClientStore } from "../stores/oidc/OidcClientStore"; import WidgetStore from "../stores/WidgetStore"; -import { - VoiceBroadcastPlaybacksStore, - VoiceBroadcastPreRecordingStore, - VoiceBroadcastRecordingsStore, -} from "../voice-broadcast"; // This context is available to components under MatrixChat, // the context must not be used by components outside a SdkContextClass tree. @@ -68,9 +63,6 @@ export class SdkContextClass { protected _SpaceStore?: SpaceStoreClass; protected _LegacyCallHandler?: LegacyCallHandler; protected _TypingStore?: TypingStore; - protected _VoiceBroadcastRecordingsStore?: VoiceBroadcastRecordingsStore; - protected _VoiceBroadcastPreRecordingStore?: VoiceBroadcastPreRecordingStore; - protected _VoiceBroadcastPlaybacksStore?: VoiceBroadcastPlaybacksStore; protected _AccountPasswordStore?: AccountPasswordStore; protected _UserProfilesStore?: UserProfilesStore; protected _OidcClientStore?: OidcClientStore; @@ -157,27 +149,6 @@ export class SdkContextClass { return this._TypingStore; } - public get voiceBroadcastRecordingsStore(): VoiceBroadcastRecordingsStore { - if (!this._VoiceBroadcastRecordingsStore) { - this._VoiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore(); - } - return this._VoiceBroadcastRecordingsStore; - } - - public get voiceBroadcastPreRecordingStore(): VoiceBroadcastPreRecordingStore { - if (!this._VoiceBroadcastPreRecordingStore) { - this._VoiceBroadcastPreRecordingStore = new VoiceBroadcastPreRecordingStore(); - } - return this._VoiceBroadcastPreRecordingStore; - } - - public get voiceBroadcastPlaybacksStore(): VoiceBroadcastPlaybacksStore { - if (!this._VoiceBroadcastPlaybacksStore) { - this._VoiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore(this.voiceBroadcastRecordingsStore); - } - return this._VoiceBroadcastPlaybacksStore; - } - public get accountPasswordStore(): AccountPasswordStore { if (!this._AccountPasswordStore) { this._AccountPasswordStore = new AccountPasswordStore(); diff --git a/src/contexts/ScopedRoomContext.tsx b/src/contexts/ScopedRoomContext.tsx new file mode 100644 index 0000000000..1222443d29 --- /dev/null +++ b/src/contexts/ScopedRoomContext.tsx @@ -0,0 +1,78 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2019 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; +import React, { ContextType, createContext, memo, ReactNode, useContext, useEffect, useRef, useState } from "react"; + +import { objectKeyChanges } from "../utils/objects.ts"; +import { useTypedEventEmitter } from "../hooks/useEventEmitter.ts"; +import RoomContext from "./RoomContext.ts"; + +// React Contexts with frequently changing values (like State where the object reference is changed on every update) +// cause performance issues by triggering a re-render on every component subscribed to that context. +// With ScopedRoomContext we're effectively setting up virtual contexts which are a subset of the overall context object +// and subscribers specify which fields they care about, and they will only be awoken on updates to those specific fields. + +type ContextValue = ContextType; + +export enum NotificationStateEvents { + Update = "update", +} + +type EventHandlerMap> = { + [NotificationStateEvents.Update]: (keys: Array) => void; +}; + +class EfficientContext> extends TypedEventEmitter< + NotificationStateEvents, + EventHandlerMap +> { + public constructor(public state: C) { + super(); + } + + public setState(state: C): void { + const changedKeys = objectKeyChanges(this.state ?? ({} as C), state); + this.state = state; + this.emit(NotificationStateEvents.Update, changedKeys); + } +} + +const ScopedRoomContext = createContext | undefined>(undefined); + +// Uses react memo and leverages splatting the value to ensure that the context is only updated when the state changes (shallow compare) +export const ScopedRoomContextProvider = memo( + ({ children, ...state }: { children: ReactNode } & ContextValue): JSX.Element => { + const contextRef = useRef(new EfficientContext(state)); + useEffect(() => { + contextRef.current.setState(state); + }, [state]); + + // Includes the legacy RoomContext provider for backwards compatibility with class components + return ( + + {children} + + ); + }, +); + +type ScopedRoomContext> = { [key in K[number]]: ContextValue[key] }; + +export function useScopedRoomContext>(...keys: K): ScopedRoomContext { + const context = useContext(ScopedRoomContext); + const [state, setState] = useState>(context?.state ?? ({} as ScopedRoomContext)); + + useTypedEventEmitter(context, NotificationStateEvents.Update, (updatedKeys: K): void => { + if (context?.state && updatedKeys.some((updatedKey) => keys.includes(updatedKey))) { + setState(context.state); + } + }); + + return state; +} diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index ff771b0467..ec1651872a 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -41,13 +41,7 @@ import { getMessageModerationState, MessageModerationState } from "../utils/Even import HiddenBody from "../components/views/messages/HiddenBody"; import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; -import { shouldDisplayAsVoiceBroadcastTile } from "../voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile"; import { ElementCall } from "../models/Call"; -import { - isRelatedToVoiceBroadcast, - shouldDisplayAsVoiceBroadcastStoppedText, - VoiceBroadcastChunkEventType, -} from "../voice-broadcast"; // Subset of EventTile's IProps plus some mixins export interface EventTileTypeProps @@ -223,12 +217,6 @@ export function pickFactory( return MessageEventFactory; } - if (shouldDisplayAsVoiceBroadcastTile(mxEvent)) { - return MessageEventFactory; - } else if (shouldDisplayAsVoiceBroadcastStoppedText(mxEvent)) { - return TextualEventFactory; - } - if (SINGULAR_STATE_EVENTS.has(evType) && mxEvent.getStateKey() !== "") { return noEventFactoryFactory(); // improper event type to render } @@ -249,16 +237,6 @@ export function pickFactory( return noEventFactoryFactory(); } - if (mxEvent.getContent()[VoiceBroadcastChunkEventType]) { - // hide voice broadcast chunks - return noEventFactoryFactory(); - } - - if (!showHiddenEvents && mxEvent.isDecryptionFailure() && isRelatedToVoiceBroadcast(mxEvent, cli)) { - // hide utd events related to a broadcast - return noEventFactoryFactory(); - } - return EVENT_TILE_TYPES.get(evType) ?? noEventFactoryFactory(); } diff --git a/src/events/forward/getForwardableEvent.ts b/src/events/forward/getForwardableEvent.ts index 000a50f4ee..2d37ebf6e9 100644 --- a/src/events/forward/getForwardableEvent.ts +++ b/src/events/forward/getForwardableEvent.ts @@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details. import { M_POLL_END, M_POLL_START, M_BEACON_INFO, MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; import { getShareableLocationEventForBeacon } from "../../utils/beacon/getShareableLocation"; -import { VoiceBroadcastInfoEventType } from "../../voice-broadcast/types"; /** * Get forwardable event for a given event @@ -20,8 +19,6 @@ export const getForwardableEvent = (event: MatrixEvent, cli: MatrixClient): Matr return null; } - if (event.getType() === VoiceBroadcastInfoEventType) return null; - // Live location beacons should forward their latest location as a static pin location // If the beacon is not live, or doesn't have a location forwarding is not allowed if (M_BEACON_INFO.matches(event.getType())) { diff --git a/src/hooks/room/useRoomMemberProfile.ts b/src/hooks/room/useRoomMemberProfile.ts index 57f72a722e..b8bb44c50d 100644 --- a/src/hooks/room/useRoomMemberProfile.ts +++ b/src/hooks/room/useRoomMemberProfile.ts @@ -7,10 +7,11 @@ Please see LICENSE files in the repository root for full details. */ import { RoomMember } from "matrix-js-sdk/src/matrix"; -import { useContext, useMemo } from "react"; +import { useMemo } from "react"; -import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; +import { TimelineRenderingType } from "../../contexts/RoomContext"; import { useSettingValue } from "../useSettings"; +import { useScopedRoomContext } from "../../contexts/ScopedRoomContext.tsx"; export function useRoomMemberProfile({ userId = "", @@ -21,7 +22,7 @@ export function useRoomMemberProfile({ member?: RoomMember | null; forceHistorical?: boolean; }): RoomMember | undefined | null { - const context = useContext(RoomContext); + const context = useScopedRoomContext("room", "timelineRenderingType"); const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); const member = useMemo(() => { diff --git a/src/hooks/useAudioDeviceSelection.ts b/src/hooks/useAudioDeviceSelection.ts deleted file mode 100644 index 504eb10ea6..0000000000 --- a/src/hooks/useAudioDeviceSelection.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { useRef, useState } from "react"; - -import { _t } from "../languageHandler"; -import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler"; -import { requestMediaPermissions } from "../utils/media/requestMediaPermissions"; - -interface State { - devices: MediaDeviceInfo[]; - device: MediaDeviceInfo | null; -} - -export const useAudioDeviceSelection = ( - onDeviceChanged?: (device: MediaDeviceInfo) => void, -): { - currentDevice: MediaDeviceInfo | null; - currentDeviceLabel: string; - devices: MediaDeviceInfo[]; - setDevice(device: MediaDeviceInfo): void; -} => { - const shouldRequestPermissionsRef = useRef(true); - const [state, setState] = useState({ - devices: [], - device: null, - }); - - if (shouldRequestPermissionsRef.current) { - shouldRequestPermissionsRef.current = false; - requestMediaPermissions(false).then((stream: MediaStream | undefined) => { - MediaDeviceHandler.getDevices().then((devices) => { - if (!devices) return; - const { audioinput } = devices; - MediaDeviceHandler.getDefaultDevice(audioinput); - const deviceFromSettings = MediaDeviceHandler.getAudioInput(); - const device = - audioinput.find((d) => { - return d.deviceId === deviceFromSettings; - }) || audioinput[0]; - setState({ - ...state, - devices: audioinput, - device, - }); - stream?.getTracks().forEach((t) => t.stop()); - }); - }); - } - - const setDevice = (device: MediaDeviceInfo): void => { - const shouldNotify = device.deviceId !== state.device?.deviceId; - MediaDeviceHandler.instance.setDevice(device.deviceId, MediaDeviceKindEnum.AudioInput); - - setState({ - ...state, - device, - }); - - if (shouldNotify) { - onDeviceChanged?.(device); - } - }; - - return { - currentDevice: state.device, - currentDeviceLabel: state.device?.label || _t("voip|default_device"), - devices: state.devices, - setDevice, - }; -}; diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index abe4566f8c..65cb1d8660 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -2,6 +2,7 @@ "a11y": { "emoji_picker": "Emoji-Auswahl", "jump_first_invite": "Zur ersten Einladung springen.", + "message_composer": "Nachrichteneingabe-Feld", "n_unread_messages": { "other": "%(count)s ungelesene Nachrichten.", "one": "1 ungelesene Nachricht." @@ -10,11 +11,13 @@ "other": "%(count)s ungelesene Nachrichten einschließlich Erwähnungen.", "one": "1 ungelesene Erwähnung." }, + "recent_rooms": "Kürzlich besuchte Chatrooms", "room_name": "Raum %(name)s", + "room_status_bar": "Statusleiste des Chatrooms", + "seek_bar_label": "Audio-Suchleiste", "unread_messages": "Ungelesene Nachrichten.", "user_menu": "Benutzermenü" }, - "a11y_jump_first_unread_room": "Zum ersten ungelesenen Raum springen.", "action": { "accept": "Annehmen", "add": "Hinzufügen", @@ -106,6 +109,7 @@ "save": "Speichern", "search": "Suchen", "send_report": "Bericht senden", + "set_avatar": "Profilbild festlegen", "share": "Teilen", "show": "Zeigen", "show_advanced": "Erweiterte Einstellungen", @@ -123,12 +127,13 @@ "trust": "Vertrauen", "try_again": "Erneut versuchen", "unban": "Verbannung aufheben", - "unignore": "Nicht mehr blockieren", + "unignore": "Freigeben", "unpin": "Nicht mehr anheften", "unsubscribe": "Deabonnieren", "update": "Aktualisieren", "upgrade": "Aktualisieren", "upload": "Hochladen", + "upload_file": "Datei hochladen", "verify": "Verifizieren", "view": "Ansicht", "view_all": "Alles anzeigen", @@ -143,7 +148,7 @@ "accept_button": "Das ist okay", "bullet_1": "Wir erfassen und analysieren keine Kontodaten", "bullet_2": "Wir teilen keine Informationen mit Dritten", - "consent_migration": "Sie haben zuvor zugestimmt, anonymisierte Nutzungsdaten mit uns zu teilen. Wir aktualisieren, wie das funktioniert.", + "consent_migration": "Du hast zugestimmt, anonymisierte Nutzungsdaten mit uns zu teilen. Wir aktualisieren die Funktionsweise.", "disable_prompt": "Du kannst dies jederzeit in den Einstellungen deaktivieren", "enable_prompt": "Hilf mit, %(analyticsOwner)s zu verbessern", "learn_more": "Teile Daten anonymisiert um uns zu helfen Probleme zu identifizieren. Nichts persönliches. Keine Dritten. Mehr dazu hier", @@ -204,11 +209,11 @@ "failed_soft_logout_auth": "Erneute Authentifizierung fehlgeschlagen", "failed_soft_logout_homeserver": "Erneute Authentifizierung aufgrund eines Problems des Heim-Servers fehlgeschlagen", "forgot_password_email_invalid": "E-Mail-Adresse scheint ungültig zu sein.", - "forgot_password_email_required": "Es muss die mit dem Benutzerkonto verbundene E-Mail-Adresse eingegeben werden.", + "forgot_password_email_required": "Es muss die mit dem Konto verbundene E-Mail-Adresse eingegeben werden.", "forgot_password_prompt": "Passwort vergessen?", "forgot_password_send_email": "E-Mail senden", "identifier_label": "Anmelden mit", - "incorrect_credentials": "Inkorrekter Nutzername und/oder Passwort.", + "incorrect_credentials": "Benutzername und/oder Passwort falsch.", "incorrect_credentials_detail": "Bitte beachte, dass du dich gerade auf %(hs)s anmeldest, nicht matrix.org.", "incorrect_password": "Ungültiges Passwort", "log_in_new_account": "Mit deinem neuen Konto anmelden.", @@ -223,6 +228,7 @@ }, "misconfigured_body": "Wende dich an deinen %(brand)s-Admin um deine Konfiguration auf ungültige oder doppelte Einträge zu überprüfen.", "misconfigured_title": "Dein %(brand)s ist falsch konfiguriert", + "mobile_create_account_title": "Du bist dabei, auf %(hsName)s ein Konto anzulegen", "msisdn_field_description": "Andere Personen können dich mit deinen Kontaktdaten in Räume einladen", "msisdn_field_label": "Telefon", "msisdn_field_number_invalid": "Diese Telefonummer sieht nicht ganz richtig aus. Bitte überprüfe deine Eingabe und versuche es erneut", @@ -241,12 +247,36 @@ "phone_label": "Telefon", "phone_optional_label": "Telefon (optional)", "qr_code_login": { + "check_code_explainer": "Hierdurch wird überprüft, ob die Verbindung zu Ihrem anderen Gerät sicher ist.", + "check_code_heading": "Gib die Nummer ein, die am anderen Gerät angezeigt wird", + "check_code_input_label": "zweistelliger Code", + "check_code_mismatch": "Die Zahlen stimmen nicht überein", "completing_setup": "Schließe Anmeldung deines neuen Gerätes ab", + "error_etag_missing": "Ein unerwarteter Fehler ist aufgetreten. Dies kann an einer Browsererweiterung, einem Proxyserver oder einer fehlerhaften Serverkonfiguration liegen.", + "error_expired": "Die Anmeldung ist abgelaufen. Bitte versuchen Sie es erneut.", + "error_expired_title": "Die Anmeldung wurde nicht rechtzeitig abgeschlossen", + "error_insecure_channel_detected": "Eine sichere Verbindung zum neuen Gerät konnte nicht hergestellt werden. Ihre vorhandenen Geräte sind weiterhin sicher und Sie müssen sich keine Sorgen um sie machen.", + "error_insecure_channel_detected_instructions": "Was jetzt?", + "error_insecure_channel_detected_instructions_1": "Versuchen Sie erneut, sich mit einem QR-Code auf dem anderen Gerät anzumelden, falls dies ein Netzwerkproblem war", + "error_insecure_channel_detected_instructions_2": "Falls das gleiche Problem auftritt, probiere es mit einem anderen WLAN-Netzwerk oder verwende deine Mobilen Daten.", + "error_insecure_channel_detected_instructions_3": "Falls das nicht funktioniert melde dich manuell an.", + "error_insecure_channel_detected_title": "Verbindung ist nicht sicher", + "error_other_device_already_signed_in": "Sie brauchen nichts weiter zu tun.", + "error_other_device_already_signed_in_title": "Ihr anderes Gerät ist bereits angemeldet", "error_rate_limited": "Zu viele Versuche in zu kurzer Zeit. Warte ein wenig, bevor du es erneut versuchst.", - "error_unexpected": "Ein unerwarteter Fehler ist aufgetreten.", - "scan_code_instruction": "Lese den folgenden QR-Code mit deinem nicht angemeldeten Gerät ein.", - "scan_qr_code": "QR-Code einlesen", - "select_qr_code": "Wähle „%(scanQRCode)s“", + "error_unsupported_protocol": "Dieses Gerät unterstützt die Anmeldung auf einem anderen Gerät mit einem QR-Code nicht.", + "error_user_cancelled": "Der Anmeldevorgang wurde am anderen Gerät abgebrochen.", + "error_user_cancelled_title": "Anmeldungsanfrage abgebrochen", + "error_user_declined": "Du oder der Kontoanbieter haben die Anmeldeanfrage abgelehnt.", + "error_user_declined_title": "Anmeldung abgelehnt", + "point_the_camera": "Scanne den hier angezeigten QR-Code", + "scan_code_instruction": "Scanne den QR-Code mit einem weiteren Gerät.", + "scan_qr_code": "Anmeldung mit QR-Code", + "security_code": "Sicherheitscode", + "security_code_prompt": "Wenn Sie dazu aufgefordert werden, geben Sie den folgenden Code auf Ihrem anderen Gerät ein.", + "select_qr_code": "Wähle \"%(scanQRCode)s\"", + "unsupported_explainer": "Dein Kontoanbieter unterstützt keine Anmeldung bei einem neuen Gerät per QR-Code.", + "unsupported_heading": "QR-Code nicht unterstützt", "waiting_for_device": "Warte auf Anmeldung des Gerätes" }, "register_action": "Konto erstellen", @@ -258,8 +288,6 @@ "registration_disabled": "Registrierungen wurden auf diesem Heim-Server deaktiviert.", "registration_msisdn_field_required_invalid": "Telefonnummer eingeben (auf diesem Heim-Server erforderlich)", "registration_successful": "Registrierung erfolgreich", - "registration_username_in_use": "Jemand anderes nutzt diesen Benutzernamen schon. Probier einen anderen oder wenn du es bist, melde dich unten an.", - "registration_username_unable_check": "Es kann nicht überprüft werden, ob der Nutzername bereits vergeben ist. Bitte versuche es später erneut.", "registration_username_validation": "Verwende nur Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche", "reset_password": { "confirm_new_password": "Neues Passwort bestätigen", @@ -335,6 +363,8 @@ "email_resend_prompt": "Nicht angekommen? Erneut senden", "email_resent": "Verschickt!", "fallback_button": "Authentifizierung beginnen", + "mas_cross_signing_reset_cta": "Gehe zu deinem Konto", + "mas_cross_signing_reset_description": "Lasse deine Identität durch deinen Kontoanbieter zurücksetzen. Kehre dann zurück und klicke auf \"Erneut versuchen\".", "msisdn": "Eine Textnachricht wurde an %(msisdn)s gesendet", "msisdn_token_incorrect": "Token fehlerhaft", "msisdn_token_prompt": "Bitte gib den darin enthaltenen Code ein:", @@ -355,7 +385,6 @@ "unsupported_auth_email": "Dieser Heim-Server unterstützt die Anmeldung per E-Mail-Adresse nicht.", "unsupported_auth_msisdn": "Dieser Server unterstützt keine Authentifizierung per Telefonnummer.", "username_field_required_invalid": "Benutzername eingeben", - "username_in_use": "Dieser Benutzername wird bereits genutzt, bitte versuche es mit einem anderen.", "verify_email_explainer": "Wir müssen wissen, dass du es auch wirklich bist, bevor wir dein Passwort zurücksetzen. Klicke auf den Link in der E-Mail, die wir gerade an %(email)s gesendet haben", "verify_email_heading": "Verifiziere deine E-Mail, um fortzufahren" }, @@ -365,7 +394,7 @@ "collecting_information": "App-Versionsinformationen werden abgerufen", "collecting_logs": "Protokolle werden abgerufen", "create_new_issue": "Bitte erstelle ein neues Issue auf GitHub damit wir diesen Fehler untersuchen können.", - "description": "Fehlerberichte enthalten Nutzungsdaten wie Nutzernamen von dir und anderen Personen, Raum-IDs deiner beigetretenen Räume sowie mit welchen Elementen der Oberfläche du kürzlich interagiert hast. Sie enthalten keine Nachrichten.", + "description": "Fehlerberichte enthalten Applikationsnutzungsdaten wie Ihren Benutzernamen and Ihre Pseudonyme oder jene Ihrer Chatpartner, die IDs und Namen von Chaträumen, in denen Sie Mitglied sind und Elementen der Benutzeroberfläche mit denen Sie kürzlich interagiert haben. Fehlerberichte enthalten keine Nachrichten.", "download_logs": "Protokolle herunterladen", "downloading_logs": "Lade Protokolle herunter", "error_empty": "Bitte teile uns mit, was schief lief - oder besser, beschreibe das Problem auf GitHub in einem \"Issue\".", @@ -425,6 +454,7 @@ "beta": "Beta", "camera": "Kamera", "cameras": "Kameras", + "cancel": "Abbrechen", "capabilities": "Funktionen", "copied": "Kopiert!", "credits": "Danksagungen", @@ -460,11 +490,13 @@ "legal": "Rechtliches", "light": "Hell", "loading": "Lade …", + "lobby": "Lobby", "location": "Standort", "low_priority": "Niedrige Priorität", "matrix": "Matrix", "message": "Nachricht", "message_layout": "Nachrichtenlayout", + "message_timestamp_invalid": "Ungültiger Zeitstempel", "microphone": "Mikrofon", "model": "Modell", "modern": "Modern", @@ -488,7 +520,7 @@ "orphan_rooms": "Andere Räume", "password": "Passwort", "people": "Personen", - "preferences": "Einstellungen", + "preferences": "Präferenzen", "presence": "Anwesenheit", "preview_message": "Hey du. Du bist großartig!", "privacy": "Privatsphäre", @@ -506,6 +538,8 @@ "room": "Raum", "room_name": "Raumname", "rooms": "Räume", + "save": "Speichern", + "saved": "Gespeichert", "saving": "Speichere …", "secure_backup": "Verschlüsselte Sicherung", "security": "Sicherheit", @@ -516,7 +550,7 @@ "show_more": "Mehr zeigen", "someone": "Jemand", "space": "Raum", - "spaces": "Räume", + "spaces": "Spaces", "sticker": "Sticker", "stickerpack": "Sticker-Paket", "success": "Erfolg", @@ -534,6 +568,7 @@ "unnamed_room": "Unbenannter Raum", "unnamed_space": "Unbenannter Space", "unverified": "Nicht verifiziert", + "updating": "Aktualisieren...", "user": "Benutzer", "user_avatar": "Profilbild", "username": "Benutzername", @@ -661,7 +696,7 @@ "private_personal_heading": "Für wen ist dieser Space gedacht?", "private_space": "Für mich und meine Kollegen", "private_space_description": "Ein privater Space für dich und deine Kollegen", - "public_description": "Öffne den Space für alle - am besten für Communities", + "public_description": "Öffne den Space für alle - am besten für Communitys", "public_heading": "Dein öffentlicher Space", "search_public_button": "Öffentliche Spaces suchen", "setup_rooms_community_description": "Lass uns für jedes einen Raum erstellen.", @@ -687,6 +722,7 @@ "twemoji": "Die Twemoji-Emojis sind © Twitter, Inc und weitere Mitwirkende und wird unter den Bedingungen von CC-BY 4.0 verwendet.", "twemoji_colr": "Die Schriftart twemoji-colr ist © Mozilla Foundation und wird unter den Bedingungen von Apache 2.0 verwendet." }, + "desktop_default_device_name": "%(brand)s Desktop: %(platformName)s", "devtools": { "active_widgets": "Aktive Widgets", "category_other": "Sonstiges", @@ -732,6 +768,7 @@ "room_notifications_type": "Typ: ", "room_status": "Raumstatus", "room_unread_status_count": { + "one": "Ungelesen Status des ChatRooms: %(status)s, Anzahl: %(count)s", "other": "Ungelesen-Status im Raum: %(status)s, Anzahl: %(count)s" }, "save_setting_values": "Einstellungswerte speichern", @@ -761,7 +798,7 @@ "toolbox": "Werkzeugkasten", "use_at_own_risk": "Diese Benutzeroberfläche prüft nicht auf richtige Datentypen. Benutzung auf eigene Gefahr.", "user_read_up_to": "Der Benutzer hat gelesen bis: ", - "user_read_up_to_ignore_synthetic": "Der Benutzer hat gelesen bis (ignoreSynthetic): ", + "user_read_up_to_ignore_synthetic": "Der Benutzer hat bis (ignoreSynthetic) gelesen: ", "user_read_up_to_private": "Benutzer las bis (m.read.private): ", "user_read_up_to_private_ignore_synthetic": "Benutzer las bis (m.read.private;ignoreSynthetic): ", "value": "Wert", @@ -860,6 +897,8 @@ "warning": "Wenn du die neue Wiederherstellungsmethode nicht festgelegt hast, versucht ein Angreifer möglicherweise, auf dein Konto zuzugreifen. Ändere dein Kontopasswort und lege sofort eine neue Wiederherstellungsmethode in den Einstellungen fest." }, "not_supported": "", + "pinned_identity_changed": "Die Identität von %(displayName)s (%(userId)s)) hat sich geändert. Mehr erfahren", + "pinned_identity_changed_no_displayname": "Die Identität von %(userId)s hat sich geändert. Mehr erfahren", "recovery_method_removed": { "description_1": "In dieser Sitzung wurde festgestellt, dass deine Sicherheitsphrase und dein Schlüssel für sichere Nachrichten entfernt wurden.", "description_2": "Wenn du dies versehentlich getan hast, kannst du in dieser Sitzung \"sichere Nachrichten\" einrichten, die den Nachrichtenverlauf dieser Sitzung mit einer neuen Wiederherstellungsmethode erneut verschlüsseln.", @@ -929,7 +968,8 @@ "qr_reciprocate_same_shield_device": "Fast geschafft! Zeigen beide Geräte das selbe Wappen an?", "qr_reciprocate_same_shield_user": "Fast geschafft! Wird bei %(displayName)s das gleiche Schild angezeigt?", "request_toast_accept": "Sitzung verifizieren", - "request_toast_decline_counter": "Ignorieren (%(counter)s)", + "request_toast_accept_user": "Benutzer verifizieren", + "request_toast_decline_counter": "Blockiert (%(counter)s)", "request_toast_detail": "%(deviceId)s von %(ip)s", "reset_proceed_prompt": "Mit Zurücksetzen fortfahren", "sas_caption_self": "Verifiziere dieses Gerät, indem du überprüfst, dass die folgende Zahl auf dem Bildschirm erscheint.", @@ -954,7 +994,6 @@ "unverified_sessions_toast_description": "Überprüfe sie, um ein sicheres Konto gewährleisten zu können", "unverified_sessions_toast_reject": "Später", "unverified_sessions_toast_title": "Du hast nicht verifizierte Sitzungen", - "verification_description": "Verifiziere diese Anmeldung, um auf verschlüsselte Nachrichten zuzugreifen und dich anderen gegenüber zu identifizieren.", "verification_dialog_title_device": "Anderes Gerät verifizieren", "verification_dialog_title_user": "Verifizierungsanfrage", "verification_skip_warning": "Ohne dich zu verifizieren wirst du keinen Zugriff auf alle deine Nachrichten haben und könntest für andere als nicht vertrauenswürdig erscheinen.", @@ -1020,6 +1059,10 @@ "error_app_open_in_another_tab": "Wechsle zu einem anderen Tab um mit %(brand)s zu verbinden. Dieser Tab kann jetzt geschlossen werden.", "error_app_open_in_another_tab_title": "%(brand)s läuft bereits in einem anderen Tab.", "error_app_opened_in_another_window": "%(brand)s läuft bereit in einem anderen Fenster. Klicke \"%(label)s um %(brand)s hier zu nutzen und beende das andere Fenster.", + "error_database_closed_description": { + "for_desktop": "Deine Festplatte scheint voll zu sein. Mache Speicherplatz frei und lade erneut.", + "for_web": "Diese Meldung wird erwartet, wenn Sie die Browserdaten gelöscht haben. %(brand)s ist möglicherweise auch in einem anderen Tab geöffnet oder Ihre Festplatte ist voll. Bitte machen Sie etwas Speicherplatz frei und laden Sie die Seite neu." + }, "error_database_closed_title": "%(brand)s funktioniert nicht mehr", "error_dialog": { "copy_room_link_failed": { @@ -1060,7 +1103,15 @@ "you": "Du reagiertest mit %(reaction)s auf %(message)s" }, "m.sticker": "%(senderName)s: %(stickerName)s", - "m.text": "%(senderName)s: %(message)s" + "m.text": "%(senderName)s: %(message)s", + "prefix": { + "audio": "Audio", + "file": "Datei", + "image": "Bild", + "poll": "Umfrage", + "video": "Video" + }, + "preview": "%(prefix)s%(preview)s" }, "export_chat": { "cancelled": "Exportieren abgebrochen", @@ -1183,7 +1234,17 @@ "other": "In %(spaceName)s und %(count)s weiteren Spaces." }, "incompatible_browser": { - "title": "Nicht unterstützter Browser" + "continue": "Trotzdem fortfahren", + "description": "%(brand)s verwendet einige Browser-Funktionen, die von deinem aktuellen Browser nicht unterstützt werden. %(detail)s", + "learn_more": "Mehr erfahren", + "linux": "Linux", + "macos": "Mac", + "supported_browsers": "Verwenden Sie Chrome, Firefox, Edge oder Safari, um das beste Erlebnis zu erzielen.", + "title": "Nicht unterstützter Browser", + "use_desktop_heading": "Verwende stattdessen %(brand)s Desktop", + "use_mobile_heading": "Stattdessen %(brand)s am Smartphone benutzen", + "use_mobile_heading_after_desktop": "Oder verwende die mobile App", + "windows": "Windows (%(bits)s-bit)" }, "info_tooltip_title": "Information", "integration_manager": { @@ -1307,12 +1368,14 @@ "navigate_next_message_edit": "Nächste Nachricht bearbeiten", "navigate_prev_history": "Vorheriger kürzlich besuchter Raum oder Space", "navigate_prev_message_edit": "Vorherige Nachricht bearbeiten", + "next_landmark": "Zur nächsten Landmark springen", "next_room": "Nächste Unterhaltung", "next_unread_room": "Nächste ungelesene Nachricht", "number": "[Nummer]", "open_user_settings": "Benutzereinstellungen öffnen", "page_down": "Bild runter", "page_up": "Bild hoch", + "prev_landmark": "Zur vorherigen Landmark springen", "prev_room": "Vorherige Unterhaltung", "prev_unread_room": "Vorherige ungelesene Nachricht", "room_list_collapse_section": "Raumliste einklappen", @@ -1357,8 +1420,11 @@ "dynamic_room_predecessors": "Veränderbare Raumvorgänger", "dynamic_room_predecessors_description": "MSC3946 aktivieren (zur Verknüpfung von Raumarchiven nach der Raumerstellung)", "element_call_video_rooms": "Element Call-Videoräume", + "exclude_insecure_devices": "Unsichere Geräte ausschließen beim senden/empfangen von Nachrichten", + "exclude_insecure_devices_description": "Bei Aktivierung dieses Modus werden verschlüsselte Nachrichten nicht mehr mit unverifizierten Geräten geteilt und Nachrichten von unverifizierten Geräten werden als Fehler angezeigt. Beachte, dass wenn du den Modus aktivierst, eine Kommunikation mit Benutzern, die keine verifizierten Geräte haben, nicht möglich ist.", "experimental_description": "Experimentierfreudig? Probiere unsere neuesten, sich in Entwicklung befindlichen Ideen aus. Diese Funktionen sind nicht final; Sie könnten instabil sein, sich verändern oder sogar ganz entfernt werden. Erfahre mehr.", "experimental_section": "Frühe Vorschauen", + "extended_profiles_msc_support": "Erfordert die Unterstützung von MSC4133 durch den Server", "feature_disable_call_per_sender_encryption": "Verschlüsselung per-sender für Element Anruf abschalten", "feature_wysiwyg_composer_description": "Verwende Textverarbeitung (Rich-Text) statt Markdown im Eingabefeld.", "group_calls": "Neue Gruppenanruf-Erfahrung", @@ -1369,9 +1435,10 @@ "group_moderation": "Moderation", "group_profile": "Profil", "group_rooms": "Räume", - "group_spaces": "Räume", + "group_spaces": "Spaces", "group_themes": "Themen", "group_threads": "Themen", + "group_ui": "Benutzeroberfläche", "group_voip": "Anrufe", "group_widgets": "Widgets", "hidebold": "Benachrichtigungspunkt ausblenden (nur Zähler zeigen)", @@ -1400,7 +1467,6 @@ "sliding_sync": "Sliding-Sync-Modus", "sliding_sync_description": "In aktiver Entwicklung, kann nicht deaktiviert werden.", "sliding_sync_disabled_notice": "Zum Deaktivieren, melde dich ab und erneut an", - "sliding_sync_server_no_support": "Dein Server unterstützt dies nicht nativ", "under_active_development": "In aktiver Entwicklung.", "unrealiable_e2e": "Nicht zuverlässig in verschlüsselten Räumen", "video_rooms": "Videoräume", @@ -1518,6 +1584,7 @@ }, "member_list_back_action_label": "Raummitglieder", "message_edit_dialog_title": "Nachrichtenänderungen", + "migrating_crypto": "Bleib dran. Wir aktualisieren%(brand)s, um die Verschlüsselung schneller und zuverlässiger zu machen.", "mobile_guide": { "toast_accept": "App verwenden", "toast_description": "%(brand)s ist in mobilen Browsern experimentell. Für eine bessere Erfahrung nutze unsere App.", @@ -1543,8 +1610,10 @@ "keyword": "Schlüsselwort", "keyword_new": "Neues Schlüsselwort", "level_activity": "Aktivität", + "level_highlight": "Hervorhebung", "level_muted": "Stumm", "level_none": "Nichts", + "level_notification": "Benachrichtigung", "level_unsent": "Nicht gesendet", "mark_all_read": "Alle als gelesen markieren", "mentions_and_keywords": "@Erwähnungen und Schlüsselwörter", @@ -1572,8 +1641,8 @@ "download_brand_desktop": "%(brand)s Desktop herunterladen", "download_f_droid": "In F-Droid erhältlich", "download_google_play": "In Google Play erhältlich", - "enable_notifications": "Benachrichtigungen einschalten", - "enable_notifications_action": "Benachrichtigungen aktivieren", + "enable_notifications": "Desktopbenachrichtigungen einschalten", + "enable_notifications_action": "Einstellungen öffnen", "enable_notifications_description": "Verpasse keine Antworten oder wichtigen Nachrichten", "explore_rooms": "Öffentliche Räume erkunden", "find_community_members": "Finde deine Community-Mitglieder und lade sie ein", @@ -1711,7 +1780,7 @@ "disagree": "Ablehnen", "error_create_room_moderation_bot": "Erstellen des Raums mit Moderations-Bot nicht möglich", "hide_messages_from_user": "Prüfe, ob du alle aktuellen und zukünftigen Nachrichten dieses Nutzers verstecken willst.", - "ignore_user": "Nutzer ignorieren", + "ignore_user": "Benutzer blockieren", "illegal_content": "Illegale Inhalte", "missing_reason": "Bitte gib an, weshalb du einen Fehler meldest.", "nature": "Bitte wähle eine Kategorie aus und beschreibe, was die Nachricht missbräuchlich macht.", @@ -1752,14 +1821,32 @@ "restore_failed_error": "Konnte Schlüsselsicherung nicht wiederherstellen" }, "right_panel": { - "add_integrations": "Widgets, Brücken und Bots hinzufügen", + "add_integrations": "Erweiterungen hinzufügen", + "add_topic": "Thema hinzufügen", + "extensions_button": "Erweiterungen", + "extensions_empty_description": "Wählen Sie \"%(addIntegrations)s\" um Erweiterungen zu suchen und diesem Raum hinzuzufügen", "files_button": "Dateien", "pinned_messages": { + "empty_title": "Hefte wichtige Nachrichten an, damit sie leicht gefunden werden können.", + "header": { + "one": "1 angeheftete Nachricht", + "other": "%(count)s angeheftete Nachrichten" + }, "limits": { "other": "Du kannst nur %(count)s Widgets anheften" - } + }, + "menu": "Menü öffnen", + "release_announcement": { + "close": "Ok", + "description": "Alle angehefteten Nachrichten findest du hier. Bewege den Mauszeiger über eine beliebige Nachricht und wählen anheften, um sie hinzuzufügen." + }, + "reply_thread": "Auf Nachricht im Thread antworten", + "unpin_all": { + "button": "Alle Nachrichten lösen", + "title": "Alle Nachrichten lösen?" + }, + "view": "Im Nachrichtenverlauf ansehen" }, - "pinned_messages_button": "Angeheftet", "poll": { "active_heading": "Aktive Umfragen", "empty_active": "In diesem Raum gibt es keine aktiven Umfragen", @@ -1780,11 +1867,11 @@ }, "load_more": "Weitere Umfragen laden", "loading": "Lade Umfragen", - "past_heading": "Vergangene Umfragen", + "past_heading": "Abgeschlossene Umfragen", "view_in_timeline": "Umfrage im Verlauf anzeigen", "view_poll": "Umfrage ansehen" }, - "polls_button": "Umfrageverlauf", + "polls_button": "Umfragen", "room_summary_card": { "title": "Raum-Info" }, @@ -1813,6 +1900,7 @@ "forget": "Raum vergessen", "low_priority": "Niedrige Priorität", "mark_read": "Als gelesen markieren", + "mark_unread": "Als ungelesen markieren", "notifications_default": "Standardeinstellung verwenden", "notifications_mute": "Raum stumm stellen", "title": "Raumoptionen", @@ -1861,6 +1949,8 @@ }, "room_is_public": "Dieser Raum ist öffentlich" }, + "header_avatar_open_settings_label": "Raumeinstellungen öffnen", + "header_face_pile_tooltip": "Personen", "header_untrusted_label": "Nicht vertrauenswürdig", "inaccessible": "Dieser Raum oder Space ist im Moment nicht zugänglich.", "inaccessible_name": "Auf %(roomName)s kann momentan nicht zugegriffen werden.", @@ -1884,10 +1974,10 @@ "you_created": "Du hast diesen Raum erstellt." }, "invite_email_mismatch_suggestion": "Teile diese E-Mail-Adresse in den Einstellungen, um Einladungen direkt in %(brand)s zu erhalten.", - "invite_reject_ignore": "Ablehnen und Nutzer blockieren", + "invite_reject_ignore": "Ablehnen und Benutzer blockieren", "invite_sent_to_email": "Einladung an %(email)s gesendet", "invite_sent_to_email_room": "Diese Einladung zu %(roomName)s wurde an %(email)s gesendet", - "invite_subtitle": " hat dich eingeladen", + "invite_subtitle": "Eingeladen von ", "invite_this_room": "In diesen Raum einladen", "invite_title": "Möchtest du %(roomName)s betreten?", "inviter_unknown": "Unbekannt", @@ -1930,11 +2020,24 @@ "not_found_title": "Dieser Raum oder Space existiert nicht.", "not_found_title_name": "%(roomName)s existiert nicht.", "peek_join_prompt": "Du erkundest den Raum %(roomName)s. Willst du ihn betreten?", + "pinned_message_badge": "Fixierte Nachrichten", + "pinned_message_banner": { + "button_close_list": "Liste schließen", + "button_view_all": "Alle anzeigen", + "description": "Dieser Raum hat fixierte Nachrichten. Klicke zum Anzeigen.", + "go_to_message": "Fixierte Nachrichten im Nachrichtenverlauf anzeigen.", + "title": "%(index)s of %(length)s Angeheftete Nachrichten" + }, "read_topic": "Klicke, um das Thema zu lesen", "rejecting": "Lehne Einladung ab …", "rejoin_button": "Erneut betreten", "search": { "all_rooms_button": "Alle Räume durchsuchen", + "placeholder": "Nachrichten durchsuchen...", + "summary": { + "one": "1 Ergebnis für \"\" gefunden", + "other": "%(count)s Ergebnisse für \"\" gefunden" + }, "this_room_button": "Diesen Raum durchsuchen" }, "status_bar": { @@ -2070,6 +2173,8 @@ "error_deleting_alias_description": "Beim Entfernen dieser Adresse ist ein Fehler aufgetreten. Vielleicht existiert sie nicht mehr oder es kam zu einem temporären Fehler.", "error_deleting_alias_description_forbidden": "Du hast nicht die Berechtigung, die Adresse zu löschen.", "error_deleting_alias_title": "Fehler beim Löschen der Adresse", + "error_publishing": "Raum konnte nicht auf öffentlich gestellt werden", + "error_publishing_detail": "Bei der Freigabe des Raumes hat es einen Fehler gegeben", "error_save_space_settings": "Spaceeinstellungen konnten nicht gespeichert werden.", "error_updating_alias_description": "Es gab einen Fehler beim Ändern des Raumalias. Entweder erlaubt es der Server nicht oder es gab ein temporäres Problem.", "error_updating_canonical_alias_description": "Es gab ein Problem beim Aktualisieren der Raum-Hauptadresse. Es kann sein, dass der Server dies verbietet oder ein temporäres Problem aufgetreten ist.", @@ -2140,7 +2245,7 @@ "m.room.history_visibility": "Sichtbarkeit des Verlaufs ändern", "m.room.name": "Raumname ändern", "m.room.name_space": "Name des Space ändern", - "m.room.pinned_events": "Angeheftete Ereignisse verwalten", + "m.room.pinned_events": "Angeheftete Nachrichten verwalten", "m.room.power_levels": "Berechtigungen ändern", "m.room.redaction": "Vom mir gesendete Nachrichten löschen", "m.room.server_acl": "Server-ACLs bearbeiten", @@ -2297,25 +2402,37 @@ "brand_version": "Version von %(brand)s:", "clear_cache_reload": "Zwischenspeicher löschen und neu laden", "crypto_version": "Krypto-Version:", + "dialog_title": "Einstellungen: Hilfe & Info", "help_link": "Um Hilfe zur Benutzung von %(brand)s zu erhalten, klicke hier.", "homeserver": "Heim-Server ist %(homeserverUrl)s", "identity_server": "Identitäts-Server ist %(identityServerUrl)s", - "title": "Hilfe und Info", + "title": "Hilfe & Info", "versions": "Versionen" } }, "settings": { + "account": { + "dialog_title": "Einstellunge: Konto", + "title": "Konto" + }, "all_rooms_home": "Alle Räume auf Startseite anzeigen", "all_rooms_home_description": "Alle Räume, denen du beigetreten bist, werden auf der Startseite erscheinen.", "always_show_message_timestamps": "Nachrichtenzeitstempel immer anzeigen", "appearance": { + "compact_layout": "Kompakten Text und Nachrichten anzeigen", + "compact_layout_description": "Um diese Funktion nutzen zu können, muss das moderne Nachrichtenlayout ausgewählt sein.", "custom_font": "Systemschriftart verwenden", "custom_font_description": "Setze den Schriftnamen auf eine in deinem System installierte Schriftart und %(brand)s wird versuchen, sie zu verwenden.", "custom_font_name": "Systemschriftart", "custom_font_size": "Andere Schriftgröße verwenden", - "custom_theme_error_downloading": "Fehler beim herunterladen des Themas.", + "custom_theme_add": "Benutzerdefiniertes Design hinzufügen", + "custom_theme_downloading": "Benutzerdefiniertes Design wird heruntergeladen...", + "custom_theme_help": "Gib die URL des einzustellenden benutzerdefinierten Designs ein.", "custom_theme_invalid": "Ungültiges Designschema.", + "dialog_title": "Einstellungen: Erscheinungsbild", "font_size": "Schriftgröße", + "font_size_default": "%(fontSize)s (Standard)", + "high_contrast": "Hochkontrast", "image_size_default": "Standard", "image_size_large": "Groß", "layout_bubbles": "Nachrichtenblasen", @@ -2330,6 +2447,9 @@ "code_block_expand_default": "Quelltextblöcke standardmäßig erweitern", "code_block_line_numbers": "Zeilennummern in Quelltextblöcken", "disable_historical_profile": "Aktuelle Profilbilder und Anzeigenamen im Verlauf anzeigen", + "discovery": { + "title": "Wie man Sie findet" + }, "emoji_autocomplete": "Emoji-Vorschläge während Eingabe", "enable_markdown": "Markdown aktivieren", "enable_markdown_description": "Beginne Nachrichten mit /plain, um sie ohne Markdown zu senden.", @@ -2345,6 +2465,13 @@ "add_msisdn_dialog_title": "Telefonnummer hinzufügen", "add_msisdn_instructions": "Gib den per SMS an +%(msisdn)s gesendeten Bestätigungscode ein.", "add_msisdn_misconfigured": "Das MSISDN-Verknüpfungsverfahren ist falsch konfiguriert", + "allow_spellcheck": "Rechtschreibprüfung zulassen", + "application_language_reload_hint": "Nach Änderung der Sprache wird die App neu gestartet", + "avatar_remove_progress": "Bild wird entfernt...", + "avatar_save_progress": "Bild wird hochgeladen...", + "avatar_upload_error_text": "Das Dateiformat wird nicht unterstützt oder das Bild ist größer als %(size)s.", + "avatar_upload_error_text_generic": "Das Dateiformat wird möglicherweise nicht unterstützt.", + "avatar_upload_error_title": "Profilbild konnte nicht hochgeladen werden", "confirm_adding_email_body": "Klicke unten auf den Knopf, um die hinzugefügte E-Mail-Adresse zu bestätigen.", "confirm_adding_email_title": "Hinzugefügte E-Mail-Addresse bestätigen", "deactivate_confirm_body": "Willst du dein Konto wirklich deaktivieren? Du kannst dies nicht rückgängig machen.", @@ -2352,7 +2479,6 @@ "deactivate_confirm_content": "Bestätige, dass du dein Konto deaktivieren möchtest. Wenn du fortfährst, tritt folgendes ein:", "deactivate_confirm_content_1": "Du wirst dein Konto nicht reaktivieren können", "deactivate_confirm_content_2": "Du wirst dich nicht mehr anmelden können", - "deactivate_confirm_content_3": "Niemand wird in der Lage sein deinen Benutzernamen (MXID) wiederzuverwenden, dich eingeschlossen: Der Benutzername wird nicht verfügbar bleiben", "deactivate_confirm_content_4": "Du wirst alle Unterhaltungen verlassen, in denen du dich befindest", "deactivate_confirm_content_5": "Du wirst vom Identitäts-Server entfernt: Deine Freunde werden nicht mehr in der Lage sein, dich über deine E-Mail-Adresse oder Telefonnummer zu finden", "deactivate_confirm_content_6": "Deine alten Nachrichten werden weiterhin für Personen sichtbar bleiben, die sie erhalten haben, so wie es bei E-Mails der Fall ist. Möchtest du deine Nachrichten vor Personen verbergen, die Räume in der Zukunft betreten?", @@ -2360,10 +2486,12 @@ "deactivate_confirm_erase_label": "Meine Nachrichten vor neuen Teilnehmern verstecken", "deactivate_section": "Benutzerkonto deaktivieren", "deactivate_warning": "Die Deaktivierung deines Kontos ist unwiderruflich — sei vorsichtig!", - "discovery_email_empty": "Entdeckungsoptionen werden angezeigt, sobald du eine E-Mail-Adresse hinzugefügt hast.", + "discovery_email_empty": "Optionen zum Entdecken anderer Benutzer werden angezeigt, sobald Sie Ihre E-Mail Adresse zu Ihrem Konto hinzugefügt haben,", "discovery_email_verification_instructions": "Verifiziere den Link in deinem Posteingang", - "discovery_msisdn_empty": "Entdeckungsoptionen werden angezeigt, sobald du eine Telefonnummer hinzugefügt hast.", + "discovery_msisdn_empty": "Optionen zum Entdecken anderer Benutzer werden angezeigt, sobald Sie Ihre Telefonnummer zu Ihrem Konto hinzugefügt haben", "discovery_needs_terms": "Stimme den Nutzungsbedingungen des Identitäts-Servers %(serverName)s zu, um per E-Mail-Adresse oder Telefonnummer auffindbar zu werden.", + "display_name": "Anzeigename", + "display_name_error": "Anzeigename konnte nicht gesetzt werden", "email_address_in_use": "Diese E-Mail-Adresse wird bereits verwendet", "email_address_label": "E-Mail-Adresse", "email_not_verified": "Deine E-Mail-Adresse wurde noch nicht verifiziert", @@ -2388,18 +2516,23 @@ "error_share_msisdn_discovery": "Teilen der Telefonnummer nicht möglich", "identity_server_no_token": "Kein Identitäts-Zugangs-Token gefunden", "identity_server_not_set": "Kein Identitäts-Server festgelegt", - "language_section": "Sprache und Region", + "language_section": "Sprache", "msisdn_in_use": "Diese Telefonnummer wird bereits verwendet", "msisdn_label": "Telefonnummer", "msisdn_verification_field_label": "Bestätigungscode", "msisdn_verification_instructions": "Gib den Bestätigungscode ein, den du empfangen hast.", "msisdns_heading": "Telefonnummern", "oidc_manage_button": "Konto verwalten", - "password_change_section": "Setze neues Kontopasswort …", + "password_change_section": "Passwort des Nutzerkontos ändern...", "password_change_success": "Dein Passwort wurde erfolgreich geändert.", + "personal_info": "Persönliche Daten", + "profile_subtitle": "So sehen dich andere in der App.", + "profile_subtitle_oidc": "Dein Konto wird separat durch einen Identitätsanbieter verwaltet. Einige persönliche Daten können hier nicht geändert werden.", "remove_email_prompt": "%(email)s entfernen?", "remove_msisdn_prompt": "%(phone)s entfernen?", - "spell_check_locale_placeholder": "Wähle ein Gebietsschema" + "spell_check_locale_placeholder": "Wähle ein Gebietsschema", + "unable_to_load_emails": "E-Mail Adresse konnte nicht geladen werden", + "username": "Benutzername" }, "image_thumbnails": "Vorschauen für Bilder", "inline_url_previews_default": "URL-Vorschau standardmäßig aktivieren", @@ -2455,12 +2588,17 @@ "phrase_strong_enough": "Super! Diese Passphrase wirkt stark genug" }, "keyboard": { + "dialog_title": "Einstellungen: Tastatur", "title": "Tastatur" }, + "labs_mjolnir": { + "dialog_title": "Einstellungen: Blockierte Benutzer" + }, "notifications": { "default_setting_description": "Diese Einstellung wird standardmäßig für all deine Räume übernommen.", "default_setting_section": "Ich möchte benachrichtigt werden für (Standardeinstellung)", "desktop_notification_message_preview": "Nachrichtenvorschau in der Desktopbenachrichtigung anzeigen", + "dialog_title": "Einstellungen: Benachrichtigungen", "email_description": "E-Mail-Zusammenfassung für verpasste Benachrichtigungen erhalten", "email_section": "E-Mail-Zusammenfassung", "email_select": "Wähle, an welche E-Mail-Adresse die Zusammenfassungen gesendet werden. Verwalte deine E-Mail-Adressen unter .", @@ -2519,12 +2657,15 @@ "code_blocks_heading": "Quelltextblöcke", "compact_modern": "Modernes kompaktes Layout verwenden", "composer_heading": "Nachrichteneingabe", + "default_timezone": "Browser-Standard (%(timezone)s )", + "dialog_title": "Einstellungen: Präferenzen", "enable_hardware_acceleration": "Aktiviere die Hardwarebeschleunigung", "enable_tray_icon": "Fenster beim Schließen in die Symbolleiste minimieren", "keyboard_heading": "Tastenkombinationen", "keyboard_view_shortcuts_button": "Um alle Tastenkombinationen anzuzeigen, klicke hier.", "media_heading": "Mediendateien", "presence_description": "Teile anderen deine Aktivität und deinen Status mit.", + "publish_timezone": "Zeitzone auf öffentlichem Profil anzeigen lassen", "rm_lifetime": "Gültigkeitsdauer der Gelesen-Markierung (ms)", "rm_lifetime_offscreen": "Gültigkeitsdauer der Gelesen-Markierung außerhalb des Bildschirms (ms)", "room_directory_heading": "Raumverzeichnis", @@ -2533,7 +2674,8 @@ "show_checklist_shortcuts": "Verknüpfung zu ersten Schritten (Willkommen) anzeigen", "show_polls_button": "Zeige Pol button", "surround_text": "Sonderzeichen automatisch vor und hinter Textauswahl setzen", - "time_heading": "Zeitanzeige" + "time_heading": "Zeitanzeige", + "user_timezone": "Zeitzone festlegen" }, "prompt_invite": "Warnen, bevor du Einladungen zu ungültigen Matrix-IDs sendest", "replace_plain_emoji": "Klartext-Emoji automatisch ersetzen", @@ -2564,14 +2706,16 @@ "cross_signing_self_signing_private_key": "Selbst signierter privater Schlüssel:", "cross_signing_user_signing_private_key": "Privater Benutzerschlüssel:", "cryptography_section": "Verschlüsselung", + "dehydrated_device_description": "Die Offline-Gerätefunktion ermöglicht es Ihnen, verschlüsselte Nachrichten zu empfangen, auch wenn Sie an keinem Gerät angemeldet sind", + "dehydrated_device_enabled": "Offline-Gerät aktiviert", "delete_backup": "Lösche Sicherung", "delete_backup_confirm_description": "Bist du sicher? Du wirst alle deine verschlüsselten Nachrichten verlieren, wenn deine Schlüssel nicht gut gesichert sind.", + "dialog_title": "Einstellungen Sicherheit & Datenschutz", "e2ee_default_disabled_warning": "Deine Server-Administration hat die Ende-zu-Ende-Verschlüsselung für private Räume und Direktnachrichten standardmäßig deaktiviert.", "enable_message_search": "Nachrichtensuche in verschlüsselten Räumen aktivieren", "encryption_section": "Verschlüsselung", "error_loading_key_backup_status": "Konnte Status der Schlüsselsicherung nicht laden", "export_megolm_keys": "E2E-Raumschlüssel exportieren", - "ignore_users_empty": "Du ignorierst keine Benutzer.", "ignore_users_section": "Blockierte Benutzer", "import_megolm_keys": "E2E-Raumschlüssel importieren", "key_backup_active": "Diese Sitzung sichert deine Schlüssel.", @@ -2682,9 +2826,9 @@ "security_recommendations_description": "Verbessere deine Kontosicherheit, indem du diese Empfehlungen beherzigst.", "session_id": "Sitzungs-ID", "show_details": "Details anzeigen", - "sign_in_with_qr": "Mit QR-Code anmelden", + "sign_in_with_qr": "Neues Gerät verknüpfen", "sign_in_with_qr_button": "QR-Code anzeigen", - "sign_in_with_qr_description": "Du kannst dieses Gerät verwenden, um ein neues Gerät per QR-Code anzumelden. Dazu musst du den auf diesem Gerät angezeigten QR-Code mit deinem nicht angemeldeten Gerät einlesen.", + "sign_in_with_qr_unsupported": "Nicht unterstützt von deinem Kontoanbieter", "sign_out": "Von dieser Sitzung abmelden", "sign_out_all_other_sessions": "Von allen anderen Sitzungen abmelden (%(otherSessionsCount)s)", "sign_out_confirm_description": { @@ -2724,7 +2868,9 @@ "show_redaction_placeholder": "Platzhalter für gelöschte Nachrichten", "show_stickers_button": "Sticker-Schaltfläche", "show_typing_notifications": "Tippbenachrichtigungen anzeigen", + "showbold": "Alle Aktivitäten in Raumliste anzeigen (Punkt oder Anzahl ungelesener Nachrichten)", "sidebar": { + "dialog_title": "Einstellungen: Seitenleiste", "metaspaces_favourites_description": "Gruppiere all deine favorisierten Unterhaltungen an einem Ort.", "metaspaces_home_all_rooms": "Alle Räume anzeigen", "metaspaces_home_all_rooms_description": "Alle Räume auf der Startseite anzeigen, auch wenn sie Teil eines Space sind.", @@ -2733,10 +2879,12 @@ "metaspaces_orphans_description": "Gruppiere all deine Räume, die nicht Teil eines Spaces sind, an einem Ort.", "metaspaces_people_description": "Gruppiere all deine Direktnachrichten an einem Ort.", "metaspaces_subsection": "Anzuzeigende Spaces", - "spaces_explainer": "Räume sind Möglichkeiten, Personen zu gruppieren. Neben den Räumen, in denen du dich befindest, kannst du auch einige vorgefertigte verwenden.", + "metaspaces_video_rooms": "Videoräume und -konferenzen", + "metaspaces_video_rooms_description": "Gruppiere alle privaten Videoräume und -konferenzen.", "title": "Seitenleiste" }, "start_automatically": "Nach Systemstart automatisch starten", + "tac_only_notifications": "Benachrichtigungen nur im Thread Aktivitätszentrum anzeigen", "use_12_hour_format": "Uhrzeiten im 12-Stundenformat (z. B. 2:30 p. m.)", "use_command_enter_send_message": "Benutze Betriebssystemtaste + Eingabe um eine Nachricht zu senden", "use_command_f_search": "Nutze Command + F um den Verlauf zu durchsuchen", @@ -2750,6 +2898,7 @@ "audio_output_empty": "Keine Audioausgabe erkannt", "auto_gain_control": "Automatische Lautstärkeregelung", "connection_section": "Verbindung", + "dialog_title": "Einstellungen: Anrufe", "echo_cancellation": "Echounterdrückung", "enable_fallback_ice_server": "Ersatz-Anrufassistenz-Server erlauben (%(server)s)", "enable_fallback_ice_server_description": "Dieser wird nur verwendet, sollte dein Heim-Server keinen bieten. Deine IP-Adresse würde während eines Anrufs geteilt werden.", @@ -2771,6 +2920,9 @@ "link_title": "Link zum Raum", "permalink_message": "Link zur ausgewählten Nachricht", "permalink_most_recent": "Link zur aktuellsten Nachricht", + "share_call": "Konferenzeinladungslink", + "share_call_subtitle": "Link für externe Benutzer, um dem Anruf ohne Matrixkonto beizutreten:", + "title_link": "Link teilen", "title_message": "Raumnachricht teilen", "title_room": "Raum teilen", "title_user": "Teile Benutzer" @@ -2803,7 +2955,7 @@ "help_dialog_title": "Befehl Hilfe", "holdcall": "Den aktuellen Anruf halten", "html": "Sendet eine Nachricht als HTML, ohne sie als Markdown darzustellen", - "ignore": "Nutzer blockieren und dessen Nachrichten ausblenden", + "ignore": "Benutzer blockieren und dessen Nachrichten ausblenden", "ignore_dialog_description": "%(userId)s ist jetzt blockiert", "ignore_dialog_title": "Benutzer blockiert", "invite": "Lädt den Benutzer mit der angegebenen ID in den aktuellen Raum ein", @@ -2846,7 +2998,7 @@ "unban": "Entbannt den Benutzer mit der angegebenen ID", "unflip": "Stellt ┬──┬ ノ( ゜-゜ノ) einer Klartextnachricht voran", "unholdcall": "Beendet das Halten des Anrufs", - "unignore": "Benutzer nicht mehr ignorieren und neue Nachrichten wieder anzeigen", + "unignore": "Benutzer freigeben und ihre neuen Nachrichten wieder anzeigen", "unignore_dialog_description": "%(userId)s wird nicht mehr blockiert", "unignore_dialog_title": "Benutzer nicht mehr blockiert", "unknown_command": "Unbekannter Befehl", @@ -2971,7 +3123,7 @@ "heading_with_query": "Nutze \"%(query)s\" zum Suchen", "heading_without_query": "Suche nach", "join_button_text": "%(roomAddress)s betreten", - "keyboard_scroll_hint": "Benutze zum scrollen", + "keyboard_scroll_hint": "Benutze zum Scrollen", "message_search_section_title": "Andere Suchen", "other_rooms_in_space": "Andere Räume in %(spaceName)s", "public_rooms_label": "Öffentliche Räume", @@ -3019,12 +3171,22 @@ "one": "%(count)s Antwort", "other": "%(count)s Antworten" }, + "empty_description": "Verwende \"%(replyInThread)s\" beim Hovern über eine Nachricht.", + "empty_title": "Threads helfen dir, deine Unterhaltungen zu einem Thema leichter zu verfolgen.", "error_start_thread_existing_relation": "Du kannst keinen Thread in einem Thread starten", + "mark_all_read": "Alle als gelesen markieren", "my_threads": "Meine Threads", "my_threads_description": "Zeigt alle Threads, an denen du teilgenommen hast", "open_thread": "Thread anzeigen", "show_thread_filter": "Zeige:" }, + "threads_activity_centre": { + "header": "Thread-Aktivität", + "no_rooms_with_threads_notifs": "Sie haben noch keine Räume mit Thread-Benachrichtigungen.", + "no_rooms_with_unread_threads": "Sie haben noch keine Räume mit ungelesenen Threads.", + "release_announcement_description": "Die Thread-Benachrichtigungen wurden verschoben. Sie finden sie ab sofort hier.", + "release_announcement_header": "Thread Aktivitätszentrum" + }, "time": { "about_day_ago": "vor etwa einem Tag", "about_hour_ago": "vor etwa einer Stunde", @@ -3066,9 +3228,19 @@ }, "creation_summary_dm": "%(creator)s hat diese Direktnachricht erstellt.", "creation_summary_room": "%(creator)s hat den Raum erstellt und konfiguriert.", + "decryption_failure": { + "historical_event_no_key_backup": "Der historische Nachrichtenverlauf ist auf diesem Gerät nicht verfügbar.", + "historical_event_unverified_device": "Du musst dieses Gerät verifizieren, um auf den Nachrichtenverlauf zugreifen zu können", + "historical_event_user_not_joined": "Du hast keinen Zugriff auf diese Nachricht", + "sender_identity_previously_verified": "Die verifizierte Identität des Absenders hat sich geändert", + "sender_unsigned_device": "Von einem unsicheren Gerät verschickt.", + "unable_to_decrypt": "Entschlüsselung der Nachricht nicht möglich" + }, "disambiguated_profile": "%(displayName)s (%(matrixId)s)", "download_action_decrypting": "Entschlüsseln", "download_action_downloading": "Herunterladen", + "download_failed": "Herunterladen fehlgeschlagen", + "download_failed_description": "Beim Herunterladen dieser Datei ist ein Fehler aufgetreten", "e2e_state": "Status der Ende zu Ende Verschlüssellung", "edits": { "tooltip_label": "Am %(date)s geändert. Klicke, um Änderungen anzuzeigen.", @@ -3127,7 +3299,7 @@ }, "m.file": { "error_decrypting": "Fehler beim Entschlüsseln des Anhangs", - "error_invalid": "Ungültige Datei%(extra)s" + "error_invalid": "Ungültige Datei" }, "m.image": { "error": "Kann Bild aufgrund eines Fehlers nicht anzeigen", @@ -3242,12 +3414,12 @@ "set": "%(senderDisplayName)s hat den Raumnamen geändert zu %(roomName)s." }, "m.room.pinned_events": { - "changed": "%(senderName)s hat die angehefteten Nachrichten für diesen Raum geändert.", - "changed_link": "%(senderName)s hat die angehefteten Nachrichten geändert.", - "pinned": "%(senderName)s hat eine Nachricht angeheftet. Alle angehefteten Nachrichten anzeigen.", - "pinned_link": "%(senderName)s hat eine Nachricht angeheftet. Alle angehefteten Nachrichten anzeigen.", - "unpinned": "%(senderName)s hat eine Nachricht losgelöst. Alle angepinnten Nachrichten anzeigen.", - "unpinned_link": "%(senderName)s hat eine Nachricht losgeheftet. Alle angehefteten Nachrichten anzeigen." + "changed": "%(senderName)s hat die fixierte Nachrichten für diesen Chatroom geändert.", + "changed_link": "%(senderName)s hat die fixierten Nachrichten geändert.", + "pinned": "%(senderName)s hat eine Nachricht fixiert. Alle fixierten Nachrichten anzeigen.", + "pinned_link": "%(senderName)s hat eine Nachricht fixiert. Alle fixierten Nachrichten anzeigen.", + "unpinned": "%(senderName)s hat eine Nachricht losgelöst. Alle fixierten Nachrichten anzeigen.", + "unpinned_link": "%(senderName)s hat eine Nachricht gelöst. Alle fixierten Nachrichten anzeigen." }, "m.room.power_levels": { "changed": "%(senderName)s hat das Berechtigungslevel von %(powerLevelDiffText)s geändert.", @@ -3314,7 +3486,8 @@ "reactions": { "add_reaction_prompt": "Reaktion hinzufügen", "custom_reaction_fallback_label": "Benutzerdefinierte Reaktion", - "label": "%(reactors)s hat mit %(content)s reagiert" + "label": "%(reactors)s hat mit %(content)s reagiert", + "tooltip_caption": "hat reagiert mit %(shortName)s" }, "read_receipt_title": { "one": "Von %(count)s Person gesehen", @@ -3499,6 +3672,9 @@ "truncated_list_n_more": { "other": "Und %(count)s weitere …" }, + "unsupported_browser": { + "description": "Wenn Sie fortfahren, funktionieren einige Funktionen möglicherweise nicht mehr und es besteht das Risiko, dass Sie in Zukunft Daten verlieren. Aktualisieren Sie Ihren Browser, um die Nutzung von %(brand)s fortzusetzen." + }, "unsupported_server_description": "Dieser Server nutzt eine ältere Matrix-Version. Aktualisiere auf Matrix %(version)s, um %(brand)s fehlerfrei nutzen zu können.", "unsupported_server_title": "Dein Server wird nicht unterstützt", "update": { @@ -3516,6 +3692,10 @@ "toast_title": "Aktualisiere %(brand)s", "unavailable": "Nicht verfügbar" }, + "update_room_access_modal": { + "no_change": "Ich möchte die Zugriffsebene nicht ändern.", + "title": "Ändern Sie die Zugriffsebene des Raums" + }, "upload_failed_generic": "Die Datei „%(fileName)s“ konnte nicht hochgeladen werden.", "upload_failed_size": "Die Datei „%(fileName)s“ überschreitet das Hochladelimit deines Heim-Servers", "upload_failed_title": "Hochladen fehlgeschlagen", @@ -3525,6 +3705,7 @@ "error_files_too_large": "Die Datei ist zu groß, um hochgeladen zu werden. Die maximale Dateigröße ist %(limit)s.", "error_some_files_too_large": "Einige Dateien sind zu groß, um hochgeladen zu werden. Die maximale Dateigröße ist %(limit)s.", "error_title": "Fehler beim Hochladen", + "not_image": "Die von dir ausgewählte Datei ist keine gültige Bilddatei.", "title": "Dateien hochladen", "title_progress": "Dateien hochladen (%(current)s von %(total)s)", "upload_all_button": "Alle hochladen", @@ -3551,6 +3732,7 @@ "deactivate_confirm_action": "Konto deaktivieren", "deactivate_confirm_description": "Beim Deaktivieren wirst du abgemeldet und ein erneutes Anmelden verhindert. Zusätzlich wirst du aus allen Räumen entfernt. Diese Aktion kann nicht rückgängig gemacht werden. Bist du sicher, dass du dieses Konto deaktivieren willst?", "deactivate_confirm_title": "Konto deaktivieren?", + "dehydrated_device_enabled": "Offline-Gerät aktiviert", "demote_button": "Zurückstufen", "demote_self_confirm_description_space": "Das Entfernen von Rechten kann nicht rückgängig gemacht werden. Falls sie dir niemand anderer zurückgeben kann, kannst du sie nie wieder erhalten.", "demote_self_confirm_room": "Du wirst nicht in der Lage sein, die Änderung zurückzusetzen, da du dich degradierst. Wenn du der letze Nutzer mit Berechtigungen bist, wird es unmöglich sein die Privilegien zurückzubekommen.", @@ -3567,6 +3749,7 @@ "error_revoke_3pid_invite_title": "Einladung konnte nicht zurückgezogen werden", "hide_sessions": "Sitzungen ausblenden", "hide_verified_sessions": "Verifizierte Sitzungen ausblenden", + "ignore_button": "Blockieren", "ignore_confirm_description": "Alle Nachrichten und Einladungen der Person werden verborgen. Bist du sicher, dass du sie ignorieren möchtest?", "ignore_confirm_title": "%(user)s ignorieren", "invited_by": "%(sender)s eingeladen", @@ -3594,23 +3777,26 @@ "no_recent_messages_description": "Versuche nach oben zu scrollen, um zu sehen ob sich dort frühere Nachrichten befinden.", "no_recent_messages_title": "Keine neuen Nachrichten von %(user)s gefunden" }, - "redact_button": "Kürzlich gesendete Nachrichten entfernen", + "redact_button": "Nachrichten entfernen", "revoke_invite": "Einladung zurückziehen", "room_encrypted": "Nachrichten in diesem Raum sind Ende-zu-Ende verschlüsselt.", "room_encrypted_detail": "Diese Nachricht ist verschlüsselt. Nur Sie und der Empfänger haben den Schlüssel, um die Nachricht zu entschlüsseln.", "room_unencrypted": "Nachrichten in diesem Raum sind nicht Ende-zu-Ende verschlüsselt.", "room_unencrypted_detail": "Nachrichten in verschlüsselten Räumen können nur von dir und vom Empfänger gelesen werden.", - "share_button": "Link zu Benutzer teilen", + "send_message": "Nachricht senden", + "share_button": "Profil teilen", "unban_button_room": "Entbannen", "unban_button_space": "Entbannen", "unban_room_confirm_title": "Von %(roomName)s entbannen", "unban_space_everything": "Überall wo ich die Rechte dazu habe, entbannen", "unban_space_specific": "In ausgewählten Räumen und Spaces entbannen", "unban_space_warning": "Die Person wird keinen Zutritt zu Bereichen haben, in denen du nicht administrierst.", + "unignore_button": "Nicht mehr ignorieren", "verify_button": "Nutzer verifizieren", "verify_explainer": "Für zusätzliche Sicherheit, verifiziere diesen Nutzer, durch Vergleichen eines Einmal-Codes auf euren beiden Geräten." }, "user_menu": { + "link_new_device": "Neues Gerät verknüpfen", "settings": "Alle Einstellungen", "switch_theme_dark": "Zum dunklen Thema wechseln", "switch_theme_light": "Zum hellen Thema wechseln" @@ -3667,9 +3853,9 @@ "camera_enabled": "Deine Kamera ist noch aktiv", "cannot_call_yourself_description": "Du kannst keinen Anruf mit dir selbst starten.", "change_input_device": "Eingabegerät wechseln", + "close_lobby": "Lobby schließen", "connecting": "Verbinden", "connection_lost": "Verbindung zum Server unterbrochen", - "connection_lost_description": "Sie können keine Anrufe starten ohne Verbindung zum Server.", "consulting": "%(transferTarget)s wird angefragt. Übertragung zu %(transferee)s", "default_device": "Standardgerät", "dial": "Wählen", @@ -3680,17 +3866,26 @@ "disabled_no_perms_start_video_call": "Dir fehlt die Berechtigung, um Videoanrufe zu beginnen", "disabled_no_perms_start_voice_call": "Dir fehlt die Berechtigung, um Audioanrufe zu beginnen", "disabled_ongoing_call": "laufender Anruf", + "element_call": "Element Anruf", "enable_camera": "Kamera aktivieren", "enable_microphone": "Mikrofon aktivieren", "expand": "Zurück zum Anruf", "failed_call_live_broadcast_description": "Du kannst keinen Anruf beginnen, da du im Moment eine Sprachübertragung aufzeichnest. Bitte beende deine Sprachübertragung, um ein Gespräch zu beginnen.", "failed_call_live_broadcast_title": "Kann keinen Anruf beginnen", + "get_call_link": "Anruflink teilen", "hangup": "Auflegen", "hide_sidebar_button": "Seitenleiste verbergen", "input_devices": "Eingabegeräte", + "jitsi_call": "Jitsi-Konferenz", "join_button_tooltip_call_full": "Entschuldigung — dieser Anruf ist aktuell besetzt", "join_button_tooltip_connecting": "Verbinden", + "legacy_call": "Legacy-Anruf", "maximise": "Bildschirm füllen", + "maximise_call": "Anruf maximieren", + "metaspace_video_rooms": { + "conference_room_section": "Konferenzen" + }, + "minimise_call": "Anruf minimieren", "misconfigured_server": "Anruf aufgrund eines falsch konfigurierten Servers fehlgeschlagen", "misconfigured_server_description": "Bitte frage die Administration deines Heim-Servers (%(homeserverDomain)s) darum, einen TURN-Server einzurichten, damit Anrufe zuverlässig funktionieren.", "misconfigured_server_fallback": "Alternativ kannst du versuchen, den öffentlichen Server unter zu verwenden. Dieser wird nicht so zuverlässig sein und deine IP-Adresse wird mit ihm geteilt. Du kannst dies auch in den Einstellungen konfigurieren.", @@ -3732,12 +3927,13 @@ "unknown_person": "unbekannte Person", "unsilence": "Ton an", "unsupported": "Anrufe werden nicht unterstützt", - "unsupported_browser": "Sie können in diesem Browser keien Anrufe durchführen.", + "unsupported_browser": "Du kannst in diesem Browser keine Anrufe tätigen.", "user_busy": "Person beschäftigt", "user_busy_description": "Die angerufene Person ist momentan beschäftigt.", "user_is_presenting": "%(sharerName)s präsentiert", "video_call": "Videoanruf", "video_call_started": "Videoanruf hat begonnen", + "video_call_using": "Videoanruf mit:", "voice_call": "Sprachanruf", "you_are_presenting": "Du präsentierst" }, @@ -3846,7 +4042,7 @@ "title": "Erlaube diesem Widget deine Identität zu überprüfen" }, "popout": "Widget in eigenem Fenster öffnen", - "set_room_layout": "Dein Raumlayout für alle setzen", + "set_room_layout": "Layout für alle festlegen", "shared_data_avatar": "Deine Profilbild-URL", "shared_data_device_id": "Deine Geräte-ID", "shared_data_lang": "Deine Sprache", @@ -3872,7 +4068,6 @@ "l33t": "Vorhersagbare Ersetzungen wie „@“ anstelle von „a“ helfen nicht besonders", "longerKeyboardPattern": "Nutze ein längeres Tastaturmuster mit mehr Abwechslung", "noNeed": "Kein Bedarf an Symbolen, Zahlen oder Großbuchstaben", - "pwned": "Wenn Sie dieses Passwort woanders verwenden, sollten Sie es ändern.", "recentYears": "Vermeide die letzten Jahre", "repeated": "Vermeide wiederholte Worte und Zeichen", "reverseWords": "Umgedrehte Worte sind nicht schwerer zu erraten", diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 807264c4d2..20e71d78fb 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.", @@ -1003,7 +1005,7 @@ "unverified_sessions_toast_description": "Review to ensure your account is safe", "unverified_sessions_toast_reject": "Later", "unverified_sessions_toast_title": "You have unverified sessions", - "verification_description": "Verify your identity to access encrypted messages and prove your identity to others.", + "verification_description": "Verify your identity to access encrypted messages and prove your identity to others. If you also use a mobile device, please open the app there before you proceed.", "verification_dialog_title_device": "Verify other device", "verification_dialog_title_user": "Verification Request", "verification_skip_warning": "Without verifying, you won't have access to all your messages and may appear as untrusted to others.", @@ -1088,10 +1090,6 @@ }, "error_user_not_logged_in": "User is not logged in", "event_preview": { - "io.element.voice_broadcast_info": { - "user": "%(senderName)s ended a voice broadcast", - "you": "You ended a voice broadcast" - }, "m.call.answer": { "dm": "Call in progress", "user": "%(senderName)s joined the call", @@ -1492,8 +1490,6 @@ "video_rooms_faq2_answer": "Yes, the chat timeline is displayed alongside the video.", "video_rooms_faq2_question": "Can I use text chat alongside the video call?", "video_rooms_feedbackSubheading": "Thank you for trying the beta, please go into as much detail as you can so we can improve it.", - "voice_broadcast": "Voice broadcast", - "voice_broadcast_force_small_chunks": "Force 15s voice broadcast chunk length", "wysiwyg_composer": "Rich text editor" }, "labs_mjolnir": { @@ -1639,7 +1635,6 @@ "mute_description": "You won't get any notifications" }, "notifier": { - "io.element.voice_broadcast_chunk": "%(senderName)s started a voice broadcast", "m.key.verification.request": "%(name)s is requesting verification" }, "onboarding": { @@ -2254,7 +2249,6 @@ "error_unbanning": "Failed to unban", "events_default": "Send messages", "invite": "Invite users", - "io.element.voice_broadcast_info": "Voice broadcasts", "kick": "Remove users", "m.call": "Start %(brand)s calls", "m.call.member": "Join %(brand)s calls", @@ -3288,10 +3282,6 @@ "error_rendering_message": "Can't load this message", "historical_messages_unavailable": "You can't see earlier messages", "in_room_name": " in %(room)s", - "io.element.voice_broadcast_info": { - "user": "%(senderName)s ended a voice broadcast", - "you": "You ended a voice broadcast" - }, "io.element.widgets.layout": "%(senderName)s has updated the room layout", "late_event_separator": "Originally sent %(dateTime)s", "load_error": { @@ -3841,38 +3831,6 @@ "switch_theme_dark": "Switch to dark mode", "switch_theme_light": "Switch to light mode" }, - "voice_broadcast": { - "30s_backward": "30s backward", - "30s_forward": "30s forward", - "action": "Voice broadcast", - "buffering": "Buffering…", - "confirm_listen_affirm": "Yes, end my recording", - "confirm_listen_description": "If you start listening to this live broadcast, your current live broadcast recording will be ended.", - "confirm_listen_title": "Listen to live broadcast?", - "confirm_stop_affirm": "Yes, stop broadcast", - "confirm_stop_description": "Are you sure you want to stop your live broadcast? This will end the broadcast and the full recording will be available in the room.", - "confirm_stop_title": "Stop live broadcasting?", - "connection_error": "Connection error - Recording paused", - "failed_already_recording_description": "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.", - "failed_already_recording_title": "Can't start a new voice broadcast", - "failed_decrypt": "Unable to decrypt voice broadcast", - "failed_generic": "Unable to play this voice broadcast", - "failed_insufficient_permission_description": "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.", - "failed_insufficient_permission_title": "Can't start a new voice broadcast", - "failed_no_connection_description": "Unfortunately we're unable to start a recording right now. Please try again later.", - "failed_no_connection_title": "Connection error", - "failed_others_already_recording_description": "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.", - "failed_others_already_recording_title": "Can't start a new voice broadcast", - "go_live": "Go live", - "live": "Live", - "pause": "pause voice broadcast", - "play": "play voice broadcast", - "resume": "resume voice broadcast" - }, - "voice_message": { - "cant_start_broadcast_description": "You can't start a voice message as you are currently recording a live broadcast. Please end your live broadcast in order to start recording a voice message.", - "cant_start_broadcast_title": "Can't start voice message" - }, "voip": { "already_in_call": "Already in call", "already_in_call_person": "You're already in a call with this person.", @@ -3892,7 +3850,6 @@ "camera_disabled": "Your camera is turned off", "camera_enabled": "Your camera is still enabled", "cannot_call_yourself_description": "You cannot place a call with yourself.", - "change_input_device": "Change input device", "close_lobby": "Close lobby", "connecting": "Connecting", "connection_lost": "Connectivity to the server has been lost", @@ -3911,8 +3868,6 @@ "enable_camera": "Turn on camera", "enable_microphone": "Unmute microphone", "expand": "Return to call", - "failed_call_live_broadcast_description": "You can’t start a call as you are currently recording a live broadcast. Please end your live broadcast in order to start a call.", - "failed_call_live_broadcast_title": "Can’t start a call", "get_call_link": "Share call link", "hangup": "Hangup", "hide_sidebar_button": "Hide sidebar", diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json index 7d209a8a45..0462e74766 100644 --- a/src/i18n/strings/fr.json +++ b/src/i18n/strings/fr.json @@ -229,6 +229,7 @@ }, "misconfigured_body": "Demandez à votre administrateur %(brand)s de vérifier que votre configuration ne contient pas d’entrées incorrectes ou en double.", "misconfigured_title": "Votre %(brand)s est mal configuré", + "mobile_create_account_title": "Vous êtes sur le point de créer un compte sur %(hsName)s", "msisdn_field_description": "D’autres utilisateurs peuvent vous inviter à des salons grâce à vos informations de contact", "msisdn_field_label": "Numéro de téléphone", "msisdn_field_number_invalid": "Ce numéro de téléphone ne semble pas correct, merci de vérifier et réessayer", @@ -279,6 +280,8 @@ "security_code": "Code de sécurité", "security_code_prompt": "Si vous y êtes invité, saisissez le code ci-dessous sur votre autre appareil.", "select_qr_code": "Sélectionnez « %(scanQRCode)s »", + "unsupported_explainer": "Votre fournisseur de compte ne prend pas en charge la connexion à un nouvel appareil à l’aide d’un code QR.", + "unsupported_heading": "Le code QR n'est pas pris en charge", "waiting_for_device": "En attente de connexion de l’appareil" }, "register_action": "Créer un compte", @@ -367,6 +370,8 @@ "email_resend_prompt": "Vous ne l’avez pas reçu ? Le renvoyer", "email_resent": "Ré-envoyé !", "fallback_button": "Commencer l’authentification", + "mas_cross_signing_reset_cta": "Accédez à votre compte", + "mas_cross_signing_reset_description": "Réinitialisez votre identité par l’intermédiaire de votre fournisseur de compte, puis revenez et cliquez sur « Réessayer ».", "msisdn": "Un message a été envoyé à %(msisdn)s", "msisdn_token_incorrect": "Jeton incorrect", "msisdn_token_prompt": "Merci de saisir le code qu’il contient :", @@ -1227,6 +1232,14 @@ "other": "Dans %(spaceName)s et %(count)s autres espaces." }, "incompatible_browser": { + "continue": "Continuez quand même", + "description": "%(brand)s utilise certaines fonctionnalités du navigateur qui ne sont pas disponibles dans votre navigateur actuel. %(detail)s", + "detail_can_continue": "Si vous continuez, certaines fonctionnalités pourraient cesser de fonctionner et vous risquez de perdre des données à l'avenir.", + "detail_no_continue": "Essayez de mettre à jour ce navigateur si vous n'utilisez pas la dernière version, puis réessayez.", + "learn_more": "En savoir plus", + "linux": "Linux", + "macos": "MAC", + "supported_browsers": "Pour une expérience optimale, utilisez Chrome, Firefox, Edge, ou Safari.", "title": "Navigateur non pris en charge" }, "info_tooltip_title": "Informations", @@ -1351,12 +1364,14 @@ "navigate_next_message_edit": "Aller vers le prochain message à modifier", "navigate_prev_history": "Salon ou espace précédemment visité", "navigate_prev_message_edit": "Allez vers le précédent message à modifier", + "next_landmark": "Aller au prochain point de repère", "next_room": "Prochain salon ou conversation privée", "next_unread_room": "Prochain salon ou conversation privée non lu", "number": "[numéro]", "open_user_settings": "Ouvrir les paramètres de l'utilisateur", "page_down": "Page Bas", "page_up": "Page Haut", + "prev_landmark": "Aller au point de repère précédent", "prev_room": "Précédent salon ou conversation privée", "prev_unread_room": "Précédent salon ou conversation privée non lu", "room_list_collapse_section": "Réduire la section de la liste des salons", @@ -1401,8 +1416,11 @@ "dynamic_room_predecessors": "Prédécesseurs de salon dynamique", "dynamic_room_predecessors_description": "Active MSC3946 (pour prendre en charge les archives de salon après création)", "element_call_video_rooms": "Salons vidéo Element Call", + "exclude_insecure_devices": "Exclure les appareils non sécurisés lors de l'envoi/de la réception de messages", + "exclude_insecure_devices_description": "Lorsque ce mode est activé, les messages chiffrés ne seront pas partagés avec des appareils non vérifiés et les messages provenant d'appareils non vérifiés seront affichés comme une erreur. Notez que si vous activez ce mode, il se peut que vous ne puissiez pas communiquer avec les utilisateurs qui n'ont pas vérifié leurs appareils.", "experimental_description": "Envie d’expériences ? Essayez nos dernières idées en développement. Ces fonctionnalités ne sont pas terminées ; elles peuvent changer, être instables, ou être complètement abandonnées. En savoir plus.", "experimental_section": "Avant-premières", + "extended_profiles_msc_support": "Nécessite que votre serveur prenne en charge MSC4133", "feature_disable_call_per_sender_encryption": "Désactiver le chiffrement de chaque expéditeur pour Element Call", "feature_wysiwyg_composer_description": "Utilise le texte formaté au lieu de Markdown dans le compositeur de message.", "group_calls": "Nouvelle expérience d’appel de groupe", @@ -1805,8 +1823,12 @@ "right_panel": { "add_integrations": "Ajouter des extensions", "add_topic": "Ajouter un sujet", + "extensions_button": "Extensions", + "extensions_empty_description": "Sélectionnez « %(addIntegrations)s » pour explorer et ajouter des extensions à ce salon", + "extensions_empty_title": "Augmentez votre productivité avec plus d’outils, de widgets et de bots", "files_button": "Fichiers", "pinned_messages": { + "empty_description": "Sélectionnez un message et choisissez « %(pinAction)s » pour l'inclure ici.", "empty_title": "Épingler des messages importants afin qu'ils puissent être facilement découverts", "header": { "one": "1 Message épinglé", @@ -1817,11 +1839,17 @@ }, "menu": "Ouvrir le menu", "release_announcement": { + "close": "Ok", + "description": "Retrouvez tous les messages épinglés ici. Survolez n'importe quel message et sélectionnez « Épingler » pour l'ajouter.", "title": "Tous les nouveaux messages épinglés" }, + "reply_thread": "Répondre à un un message de fil de discussion", "unpin_all": { - "button": "Désépingler tous les messages" - } + "button": "Désépingler tous les messages", + "content": "Assurez-vous que vous voulez vraiment supprimer tous les messages épinglés. Cette action ne peut pas être annulée.", + "title": "Désépingler tous les messages ?" + }, + "view": "Voir dans la discussion" }, "pinned_messages_button": "Messages épinglés", "poll": { @@ -1926,6 +1954,7 @@ }, "room_is_public": "Ce salon est public" }, + "header_avatar_open_settings_label": "Ouvrir les paramètres du salon", "header_face_pile_tooltip": "Personnes", "header_untrusted_label": "Non fiable", "inaccessible": "Ce salon ou cet espace n’est pas accessible en ce moment.", @@ -1996,8 +2025,13 @@ "not_found_title": "Ce salon ou cet espace n’existe pas.", "not_found_title_name": "%(roomName)s n’existe pas.", "peek_join_prompt": "Ceci est un aperçu de %(roomName)s. Voulez-vous rejoindre le salon ?", + "pinned_message_badge": "Message épinglé", "pinned_message_banner": { - "description": "Ce salon contient des messages épinglés. Cliquez pour les consulter." + "button_close_list": "Fermer la liste", + "button_view_all": "Voir tout", + "description": "Ce salon contient des messages épinglés. Cliquez pour les consulter.", + "go_to_message": "Afficher le message épinglé dans la discussion.", + "title": "%(index)s de %(length)s messages épinglés" }, "read_topic": "Cliquer pour lire le sujet", "rejecting": "Rejet de l’invitation…", @@ -2005,6 +2039,10 @@ "search": { "all_rooms_button": "Rechercher dans tous les salons", "placeholder": "Rechercher des messages…", + "summary": { + "one": "1 résultat trouvé pour «  »", + "other": "%(count)srésultats trouvés pour «  »" + }, "this_room_button": "Rechercher dans ce salon" }, "status_bar": { @@ -2140,6 +2178,8 @@ "error_deleting_alias_description": "Une erreur est survenue lors de la suppression de cette adresse. Elle n’existe peut-être plus ou une erreur temporaire est survenue.", "error_deleting_alias_description_forbidden": "Vous n’avez pas la permission de supprimer cette adresse.", "error_deleting_alias_title": "Erreur lors de la suppression de l’adresse", + "error_publishing": "Impossible de publier le salon", + "error_publishing_detail": "Une erreur s'est produite lors de la publication du salon", "error_save_space_settings": "Échec de l’enregistrement des paramètres.", "error_updating_alias_description": "Une erreur est survenue lors de la mise à jour des adresses alternatives du salon. Ce n’est peut-être pas permis par le serveur ou une défaillance temporaire est survenue.", "error_updating_canonical_alias_description": "Une erreur est survenue lors de la mise à jour de l’adresse principale de salon. Ce n’est peut-être pas autorisé par le serveur ou une erreur temporaire est survenue.", @@ -2376,16 +2416,25 @@ } }, "settings": { + "account": { + "dialog_title": "Paramètres : Compte", + "title": "Compte" + }, "all_rooms_home": "Afficher tous les salons dans Accueil", "all_rooms_home_description": "Tous les salons dans lesquels vous vous trouvez apparaîtront sur l’Accueil.", "always_show_message_timestamps": "Toujours afficher l’heure des messages", "appearance": { "bundled_emoji_font": "Utilise la police d’émoji interne", + "compact_layout": "Afficher le texte et les messages compacts", + "compact_layout_description": "La mise en page moderne doit être sélectionnée pour utiliser cette fonctionnalité.", "custom_font": "Utiliser une police du système", "custom_font_description": "Définissez le nom d’une police de caractères installée sur votre système et %(brand)s essaiera de l’utiliser.", "custom_font_name": "Nom de la police du système", "custom_font_size": "Utiliser une taille personnalisée", + "custom_theme_add": "Ajouter un thème personnalisé", + "custom_theme_downloading": "Téléchargement du thème personnalisé…", "custom_theme_error_downloading": "Erreur lors du téléchargement du thème", + "custom_theme_help": "Entrez l'URL du thème personnalisé que vous souhaitez appliquer.", "custom_theme_invalid": "Schéma du thème invalide.", "dialog_title": "Paramètres : Apparence", "font_size": "Taille de la police", @@ -2405,6 +2454,9 @@ "code_block_expand_default": "Développer les blocs de code par défaut", "code_block_line_numbers": "Afficher les numéros de ligne dans les blocs de code", "disable_historical_profile": "Afficher l’image de profil et le nom actuels des utilisateurs dans l’historique des messages", + "discovery": { + "title": "Comment vous trouver" + }, "emoji_autocomplete": "Activer la suggestion d’émojis lors de la saisie", "enable_markdown": "Activer Markdown", "enable_markdown_description": "Commencez les messages avec /plain pour les envoyer sans markdown.", @@ -2420,10 +2472,14 @@ "add_msisdn_dialog_title": "Ajouter un numéro de téléphone", "add_msisdn_instructions": "Un SMS a été envoyé à +%(msisdn)s. Saisissez le code de vérification qu’il contient.", "add_msisdn_misconfigured": "L’ajout / liaison avec le flux MSISDN est mal configuré", + "allow_spellcheck": "Autoriser la vérification orthographique", + "application_language": "Langue de l'application", "application_language_reload_hint": "L’application se rechargera après avoir sélectionné une autre langue", "avatar_remove_progress": "Suppression de l'image...", "avatar_save_progress": "Chargement de l'image...", + "avatar_upload_error_text": "Le format de fichier n'est pas pris en charge ou l'image est plus grande que%(size)s.", "avatar_upload_error_text_generic": "Le format de fichier n'est peut-être pas pris en charge.", + "avatar_upload_error_title": "L'image de l'avatar n'a pas pu être téléchargée", "confirm_adding_email_body": "Cliquez sur le bouton ci-dessous pour confirmer l’ajout de l’adresse e-mail.", "confirm_adding_email_title": "Confirmer l’ajout de l’adresse e-mail", "deactivate_confirm_body": "Voulez-vous vraiment désactiver votre compte ? Ceci est irréversible.", @@ -2443,6 +2499,7 @@ "discovery_email_verification_instructions": "Vérifiez le lien dans votre boîte de réception", "discovery_msisdn_empty": "Les options de découverte apparaîtront quand vous aurez ajouté un numéro de téléphone ci-dessus.", "discovery_needs_terms": "Acceptez les conditions de service du serveur d’identité (%(serverName)s) pour vous permettre d’être découvrable par votre adresse e-mail ou votre numéro de téléphone.", + "discovery_needs_terms_title": "Laissez les gens vous trouver", "display_name": "Nom d'affichage", "display_name_error": "Impossible de définir le nom d'affichage", "email_address_in_use": "Cette adresse e-mail est déjà utilisée", @@ -2479,10 +2536,13 @@ "password_change_section": "Définir un nouveau mot de passe de compte…", "password_change_success": "Votre mot de passe a été mis à jour.", "personal_info": "Informations personnelles", + "profile_subtitle": "Voici comment vous apparaissez aux autres utilisateurs de l'application.", "profile_subtitle_oidc": "Votre compte est géré séparément par un fournisseur d'identité et certaines de vos informations personnelles ne peuvent donc pas être modifiées ici.", "remove_email_prompt": "Supprimer %(email)s ?", "remove_msisdn_prompt": "Supprimer %(phone)s ?", "spell_check_locale_placeholder": "Choisir une langue", + "unable_to_load_emails": "Impossible de charger les adresses e-mail", + "unable_to_load_msisdns": "Impossible de charger les numéros de téléphone", "username": "Nom d’utilisateur" }, "image_thumbnails": "Afficher les aperçus/vignettes pour les images", @@ -2611,6 +2671,7 @@ "code_blocks_heading": "Blocs de code", "compact_modern": "Utiliser une mise en page « moderne » plus compacte", "composer_heading": "Compositeur", + "default_timezone": "Navigateur par défaut (%(timezone)s)", "dialog_title": "Paramètres : Préférences", "enable_hardware_acceleration": "Activer l’accélération matérielle", "enable_tray_icon": "Afficher l’icône dans la barre d’état et minimiser la fenêtre lors de la fermeture", @@ -2618,6 +2679,7 @@ "keyboard_view_shortcuts_button": "Pour voir tous les raccourcis claviers, cliquez ici.", "media_heading": "Images, GIF et vidéos", "presence_description": "Partager votre activité et votre statut avec les autres.", + "publish_timezone": "Publier le fuseau horaire sur le profil public", "rm_lifetime": "Durée de vie du repère de lecture (ms)", "rm_lifetime_offscreen": "Durée de vie du repère de lecture en dehors de l’écran (ms)", "room_directory_heading": "Répertoire des salons", @@ -2626,7 +2688,8 @@ "show_checklist_shortcuts": "Afficher le raccourci vers la liste de vérification de bienvenue au-dessus de la liste des salons", "show_polls_button": "Afficher le bouton des sondages", "surround_text": "Entourer le texte sélectionné lors de la saisie de certains caractères", - "time_heading": "Affichage de l’heure" + "time_heading": "Affichage de l’heure", + "user_timezone": "Définir le fuseau horaire" }, "prompt_invite": "Demander avant d’envoyer des invitations à des identifiants matrix potentiellement non valides", "replace_plain_emoji": "Remplacer automatiquement le texte par des émojis", @@ -2782,6 +2845,7 @@ "sign_in_with_qr": "Associer un nouvel appareil", "sign_in_with_qr_button": "Afficher le QR code", "sign_in_with_qr_description": "Utilisez un code QR pour vous connecter à un autre appareil et configurer votre messagerie sécurisée.", + "sign_in_with_qr_unsupported": "Non pris en charge par votre fournisseur de compte", "sign_out": "Se déconnecter de cette session", "sign_out_all_other_sessions": "Déconnecter toutes les autres sessions (%(otherSessionsCount)s)", "sign_out_confirm_description": { @@ -3188,6 +3252,8 @@ "historical_event_no_key_backup": "L'historique des messages n'est pas disponible sur cet appareil", "historical_event_unverified_device": "Vous devez vérifier cet appareil pour accéder à l'historique des messages", "historical_event_user_not_joined": "Vous n'avez pas accès à ce message", + "sender_identity_previously_verified": "L'identité vérifiée de l'expéditeur a changé", + "sender_unsigned_device": "Envoyé depuis un appareil non sécurisé.", "unable_to_decrypt": "Impossible de déchiffrer le message" }, "disambiguated_profile": "%(displayName)s (%(matrixId)s)", @@ -3195,6 +3261,7 @@ "download_action_downloading": "Téléchargement en cours", "download_failed": "Échec du téléchargement", "download_failed_description": "Une erreur s'est produite lors du téléchargement de ce fichier", + "e2e_state": "État du chiffrement de bout en bout", "edits": { "tooltip_label": "Modifié le %(date)s. Cliquer pour voir les modifications.", "tooltip_sub": "Cliquez pour voir les modifications", @@ -3439,7 +3506,8 @@ "reactions": { "add_reaction_prompt": "Ajouter une réaction", "custom_reaction_fallback_label": "Réaction personnalisée", - "label": "%(reactors)s ont réagi avec %(content)s" + "label": "%(reactors)s ont réagi avec %(content)s", + "tooltip_caption": "a réagi avec %(shortName)s" }, "read_receipt_title": { "one": "Vu par %(count)s personne", @@ -3624,6 +3692,10 @@ "truncated_list_n_more": { "other": "Et %(count)s autres…" }, + "unsupported_browser": { + "description": "Si vous continuez, certaines fonctionnalités risquent de cesser de fonctionner et vous risquez de perdre des données à l'avenir. Mettez à jour votre navigateur pour continuer à utiliser%(brand)s .", + "title": "%(brand)sne prend pas en charge ce navigateur" + }, "unsupported_server_description": "Ce serveur utilise une ancienne version de Matrix. Mettez-le à jour vers Matrix %(version)s pour utiliser %(brand)s sans erreurs.", "unsupported_server_title": "Votre serveur n’est pas pris en charge", "update": { @@ -3682,6 +3754,7 @@ "deactivate_confirm_action": "Désactiver l’utilisateur", "deactivate_confirm_description": "Désactiver cet utilisateur le déconnectera et l’empêchera de se reconnecter. De plus, il quittera tous les salons qu’il a rejoints. Cette action ne peut pas être annulée. Voulez-vous vraiment désactiver cet utilisateur ?", "deactivate_confirm_title": "Désactiver l’utilisateur ?", + "dehydrated_device_enabled": "Appareil hors ligne activé", "demote_button": "Rétrograder", "demote_self_confirm_description_space": "Vous ne pourrez pas annuler ce changement puisque vous vous rétrogradez. Si vous êtes le dernier utilisateur a privilèges de cet espace, il deviendra impossible d’en reprendre contrôle.", "demote_self_confirm_room": "Vous ne pourrez pas annuler cette modification car vous vous rétrogradez. Si vous êtes le dernier utilisateur privilégié de ce salon, il sera impossible de récupérer les privilèges.", @@ -3698,6 +3771,7 @@ "error_revoke_3pid_invite_title": "Échec de la révocation de l’invitation", "hide_sessions": "Masquer les sessions", "hide_verified_sessions": "Masquer les sessions vérifiées", + "ignore_button": "Ignorer", "ignore_confirm_description": "Tous les messages et invitations de cette utilisateur seront cachés. Êtes-vous sûr de vouloir les ignorer ?", "ignore_confirm_title": "Ignorer %(user)s", "invited_by": "Invité par %(sender)s", @@ -3731,6 +3805,7 @@ "room_encrypted_detail": "Vos messages sont sécurisés et seuls vous et le destinataire avez les clés uniques pour les déchiffrer.", "room_unencrypted": "Les messages dans ce salon ne sont pas chiffrés de bout en bout.", "room_unencrypted_detail": "Dans les salons chiffrés, vos messages sont sécurisés et seuls vous et le destinataire avez les clés uniques pour les déchiffrer.", + "send_message": "Envoyer un message", "share_button": "Partager le profil", "unban_button_room": "Révoquer le bannissement du salon", "unban_button_space": "Révoquer le bannissement de l’espace", @@ -3738,6 +3813,7 @@ "unban_space_everything": "Annuler le bannissement de partout où j’ai le droit de le faire", "unban_space_specific": "Annuler le bannissement de certains endroits où j’ai le droit de le faire", "unban_space_warning": "Ils ne pourront plus accéder aux endroits dans lesquels vous n’êtes pas administrateur.", + "unignore_button": "Ne plus ignorer", "verify_button": "Vérifier l’utilisateur", "verify_explainer": "Pour une sécurité supplémentaire, vérifiez cet utilisateur en comparant un code à usage unique sur vos deux appareils." }, @@ -3826,6 +3902,7 @@ "jitsi_call": "Conférence Jitsi", "join_button_tooltip_call_full": "Désolé — Cet appel est actuellement complet", "join_button_tooltip_connecting": "Connexion", + "legacy_call": "Appel vidéo", "maximise": "Remplir l’écran", "maximise_call": "Plein écran", "metaspace_video_rooms": { diff --git a/src/indexing/EventIndex.ts b/src/indexing/EventIndex.ts index ec3935cd68..da2eee995c 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"); @@ -820,7 +819,11 @@ export default class EventIndex extends EventEmitter { // Add the events to the timeline of the file panel. matrixEvents.forEach((e) => { if (!timelineSet.eventIdToTimeline(e.getId()!)) { - timelineSet.addEventToTimeline(e, timeline, direction == EventTimeline.BACKWARDS); + timelineSet.addEventToTimeline(e, timeline, { + toStartOfTimeline: direction == EventTimeline.BACKWARDS, + fromCache: false, + addToState: false, + }); } }); diff --git a/src/rageshake/rageshake.ts b/src/rageshake/rageshake.ts index 763df51d95..c68fa8503c 100644 --- a/src/rageshake/rageshake.ts +++ b/src/rageshake/rageshake.ts @@ -97,6 +97,7 @@ export class ConsoleLogger { // run. // Example line: // 2017-01-18T11:23:53.214Z W Failed to set badge count + // eslint-disable-next-line @typescript-eslint/no-base-to-string let line = `${ts} ${level} ${args.join(" ")}\n`; // Do some cleanup line = line.replace(/token=[a-zA-Z0-9-]+/gm, "token=xxxxx"); diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 1c27f03e88..6cd5b15a51 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -85,18 +85,9 @@ export enum LabGroup { } export enum Features { - VoiceBroadcast = "feature_voice_broadcast", - VoiceBroadcastForceSmallChunks = "feature_voice_broadcast_force_small_chunks", NotificationSettings2 = "feature_notification_settings2", OidcNativeFlow = "feature_oidc_native_flow", ReleaseAnnouncement = "feature_release_announcement", - - /** If true, use the Rust crypto implementation. - * - * This is no longer read, but we continue to populate it on all devices, to guard against people rolling back to - * old versions of EW that do not use rust crypto by default. - */ - RustCrypto = "feature_rust_crypto", } export const labGroupNames: Record = { @@ -447,19 +438,6 @@ export const SETTINGS: { [setting: string]: ISetting } = { shouldWarn: true, default: false, }, - [Features.VoiceBroadcast]: { - isFeature: true, - labsGroup: LabGroup.Messaging, - supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, - supportedLevelsAreOrdered: true, - displayName: _td("labs|voice_broadcast"), - default: false, - }, - [Features.VoiceBroadcastForceSmallChunks]: { - supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, - displayName: _td("labs|voice_broadcast_force_small_chunks"), - default: false, - }, [Features.OidcNativeFlow]: { isFeature: true, labsGroup: LabGroup.Developer, @@ -469,10 +447,6 @@ export const SETTINGS: { [setting: string]: ISetting } = { description: _td("labs|oidc_native_flow_description"), default: false, }, - [Features.RustCrypto]: { - supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, - default: true, - }, /** * @deprecated in favor of {@link fontSizeDelta} */ 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/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 53e25736f0..66644c06a1 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -42,15 +42,6 @@ import { UPDATE_EVENT } from "./AsyncStore"; import { SdkContextClass } from "../contexts/SDKContext"; import { CallStore } from "./CallStore"; import { ThreadPayload } from "../dispatcher/payloads/ThreadPayload"; -import { - doClearCurrentVoiceBroadcastPlaybackIfStopped, - doMaybeSetCurrentVoiceBroadcastPlayback, - VoiceBroadcastRecording, - VoiceBroadcastRecordingsStoreEvent, -} from "../voice-broadcast"; -import { IRoomStateEventsActionPayload } from "../actions/MatrixActionCreators"; -import { showCantStartACallDialog } from "../voice-broadcast/utils/showCantStartACallDialog"; -import { pauseNonLiveBroadcastFromOtherRoom } from "../voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom"; import { ActionPayload } from "../dispatcher/payloads"; import { CancelAskToJoinPayload } from "../dispatcher/payloads/CancelAskToJoinPayload"; import { SubmitAskToJoinPayload } from "../dispatcher/payloads/SubmitAskToJoinPayload"; @@ -164,10 +155,6 @@ export class RoomViewStore extends EventEmitter { ) { super(); this.resetDispatcher(dis); - this.stores.voiceBroadcastRecordingsStore.addListener( - VoiceBroadcastRecordingsStoreEvent.CurrentChanged, - this.onCurrentBroadcastRecordingChanged, - ); } public addRoomListener(roomId: string, fn: Listener): void { @@ -182,16 +169,6 @@ export class RoomViewStore extends EventEmitter { this.emit(roomId, isActive); } - private onCurrentBroadcastRecordingChanged = (recording: VoiceBroadcastRecording | null): void => { - if (recording === null) { - const room = this.stores.client?.getRoom(this.state.roomId || undefined); - - if (room) { - this.doMaybeSetCurrentVoiceBroadcastPlayback(room); - } - } - }; - private setState(newState: Partial): void { // If values haven't changed, there's nothing to do. // This only tries a shallow comparison, so unchanged objects will slip @@ -207,16 +184,6 @@ export class RoomViewStore extends EventEmitter { return; } - if (newState.viewingCall) { - // Pause current broadcast, if any - this.stores.voiceBroadcastPlaybacksStore.getCurrent()?.pause(); - - if (this.stores.voiceBroadcastRecordingsStore.getCurrent()) { - showCantStartACallDialog(); - newState.viewingCall = false; - } - } - const lastRoomId = this.state.roomId; this.state = Object.assign(this.state, newState); if (lastRoomId !== this.state.roomId) { @@ -235,29 +202,6 @@ export class RoomViewStore extends EventEmitter { this.emit(UPDATE_EVENT); } - private doMaybeSetCurrentVoiceBroadcastPlayback(room: Room): void { - if (!this.stores.client) return; - doMaybeSetCurrentVoiceBroadcastPlayback( - room, - this.stores.client, - this.stores.voiceBroadcastPlaybacksStore, - this.stores.voiceBroadcastRecordingsStore, - ); - } - - private onRoomStateEvents(event: MatrixEvent): void { - const roomId = event.getRoomId?.(); - - // no room or not current room - if (!roomId || roomId !== this.state.roomId) return; - - const room = this.stores.client?.getRoom(roomId); - - if (room) { - this.doMaybeSetCurrentVoiceBroadcastPlayback(room); - } - } - private onDispatch(payload: ActionPayload): void { // eslint-disable-line @typescript-eslint/naming-convention switch (payload.action) { @@ -283,10 +227,6 @@ export class RoomViewStore extends EventEmitter { wasContextSwitch: false, viewingCall: false, }); - doClearCurrentVoiceBroadcastPlaybackIfStopped(this.stores.voiceBroadcastPlaybacksStore); - break; - case "MatrixActions.RoomState.events": - this.onRoomStateEvents((payload as IRoomStateEventsActionPayload).event); break; case Action.ViewRoomError: this.viewRoomError(payload as ViewRoomErrorPayload); @@ -489,9 +429,6 @@ export class RoomViewStore extends EventEmitter { } if (room) { - pauseNonLiveBroadcastFromOtherRoom(room, this.stores.voiceBroadcastPlaybacksStore); - this.doMaybeSetCurrentVoiceBroadcastPlayback(room); - await setMarkedUnreadState(room, MatrixClientPeg.safeGet(), false); } } else if (payload.room_alias) { diff --git a/src/stores/SetupEncryptionStore.ts b/src/stores/SetupEncryptionStore.ts index 2fb9c6a9ca..70c721b1ca 100644 --- a/src/stores/SetupEncryptionStore.ts +++ b/src/stores/SetupEncryptionStore.ts @@ -125,7 +125,7 @@ export class SetupEncryptionStore extends EventEmitter { this.emit("update"); try { const cli = MatrixClientPeg.safeGet(); - const backupInfo = await cli.getKeyBackupVersion(); + const backupInfo = (await cli.getCrypto()?.getKeyBackupInfo()) ?? null; this.backupInfo = backupInfo; this.emit("update"); diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index 9da06580dc..99b2d7fe50 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -304,15 +304,13 @@ export default class RightPanelStore extends ReadyWatchingStore { logger.warn("removed card from right panel because of missing threadHeadEvent in card state"); } return !!card.state?.threadHeadEvent; - case RightPanelPhases.RoomMemberInfo: - case RightPanelPhases.SpaceMemberInfo: + case RightPanelPhases.MemberInfo: case RightPanelPhases.EncryptionPanel: if (!card.state?.member) { logger.warn("removed card from right panel because of missing member in card state"); } return !!card.state?.member; - case RightPanelPhases.Room3pidMemberInfo: - case RightPanelPhases.Space3pidMemberInfo: + case RightPanelPhases.ThreePidMemberInfo: if (!card.state?.memberInfoEvent) { logger.warn("removed card from right panel because of missing memberInfoEvent in card state"); } @@ -327,7 +325,7 @@ export default class RightPanelStore extends ReadyWatchingStore { } private getVerificationRedirect(card: IRightPanelCard): IRightPanelCard | null { - if (card.phase === RightPanelPhases.RoomMemberInfo && card.state) { + if (card.phase === RightPanelPhases.MemberInfo && card.state) { // RightPanelPhases.RoomMemberInfo -> needs to be changed to RightPanelPhases.EncryptionPanel if there is a pending verification request const { member } = card.state; const pendingRequest = member @@ -385,8 +383,7 @@ export default class RightPanelStore extends ReadyWatchingStore { if (panel?.history) { panel.history = panel.history.filter( (card: IRightPanelCard) => - card.phase != RightPanelPhases.RoomMemberInfo && - card.phase != RightPanelPhases.Room3pidMemberInfo, + card.phase != RightPanelPhases.MemberInfo && card.phase != RightPanelPhases.ThreePidMemberInfo, ); } } diff --git a/src/stores/right-panel/RightPanelStoreIPanelState.ts b/src/stores/right-panel/RightPanelStoreIPanelState.ts index afb7442563..0d205abd2f 100644 --- a/src/stores/right-panel/RightPanelStoreIPanelState.ts +++ b/src/stores/right-panel/RightPanelStoreIPanelState.ts @@ -16,7 +16,6 @@ export interface IRightPanelCardState { verificationRequest?: VerificationRequest; verificationRequestPromise?: Promise; widgetId?: string; - spaceId?: string; // Room3pidMemberInfo, Space3pidMemberInfo, memberInfoEvent?: MatrixEvent; // threads @@ -32,7 +31,6 @@ export interface IRightPanelCardStateStored { memberId?: string; // we do not store the things associated with verification widgetId?: string; - spaceId?: string; // 3pidMemberInfo memberInfoEventId?: string; // threads @@ -80,7 +78,6 @@ export function convertCardToStore(panelState: IRightPanelCard): IRightPanelCard const state = panelState.state ?? {}; const stateStored: IRightPanelCardStateStored = { widgetId: state.widgetId, - spaceId: state.spaceId, isInitialEventHighlighted: state.isInitialEventHighlighted, initialEventScrollIntoView: state.initialEventScrollIntoView, threadHeadEventId: !!state?.threadHeadEvent?.getId() ? state.threadHeadEvent.getId() : undefined, @@ -97,7 +94,6 @@ function convertStoreToCard(panelStateStore: IRightPanelCardStored, room: Room): const stateStored = panelStateStore.state ?? {}; const state: IRightPanelCardState = { widgetId: stateStored.widgetId, - spaceId: stateStored.spaceId, isInitialEventHighlighted: stateStored.isInitialEventHighlighted, initialEventScrollIntoView: stateStored.initialEventScrollIntoView, threadHeadEvent: !!stateStored?.threadHeadEventId diff --git a/src/stores/right-panel/RightPanelStorePhases.ts b/src/stores/right-panel/RightPanelStorePhases.ts index 60b9e50baf..9e7a5697bf 100644 --- a/src/stores/right-panel/RightPanelStorePhases.ts +++ b/src/stores/right-panel/RightPanelStorePhases.ts @@ -10,11 +10,14 @@ import { _t } from "../../languageHandler"; // These are in their own file because of circular imports being a problem. export enum RightPanelPhases { + // Room & Space stuff + MemberList = "MemberList", + MemberInfo = "MemberInfo", + ThreePidMemberInfo = "ThreePidMemberInfo", + // Room stuff - RoomMemberList = "RoomMemberList", FilePanel = "FilePanel", NotificationPanel = "NotificationPanel", - RoomMemberInfo = "RoomMemberInfo", EncryptionPanel = "EncryptionPanel", RoomSummary = "RoomSummary", Widget = "Widget", @@ -22,13 +25,6 @@ export enum RightPanelPhases { Timeline = "Timeline", Extensions = "Extensions", - Room3pidMemberInfo = "Room3pidMemberInfo", - - // Space stuff - SpaceMemberList = "SpaceMemberList", - SpaceMemberInfo = "SpaceMemberInfo", - Space3pidMemberInfo = "Space3pidMemberInfo", - // Thread stuff ThreadView = "ThreadView", ThreadPanel = "ThreadPanel", @@ -42,7 +38,7 @@ export function backLabelForPhase(phase: RightPanelPhases | null): string | null return _t("chat_card_back_action_label"); case RightPanelPhases.RoomSummary: return _t("room_summary_card_back_action_label"); - case RightPanelPhases.RoomMemberList: + case RightPanelPhases.MemberList: return _t("member_list_back_action_label"); case RightPanelPhases.ThreadView: return _t("thread_view_back_action_label"); diff --git a/src/stores/right-panel/action-handlers/View3pidInvite.ts b/src/stores/right-panel/action-handlers/View3pidInvite.ts index e2aa191acb..0f6661819f 100644 --- a/src/stores/right-panel/action-handlers/View3pidInvite.ts +++ b/src/stores/right-panel/action-handlers/View3pidInvite.ts @@ -20,10 +20,10 @@ import { RightPanelPhases } from "../RightPanelStorePhases"; export const onView3pidInvite = (payload: ActionPayload, rightPanelStore: RightPanelStore): void => { if (payload.event) { rightPanelStore.pushCard({ - phase: RightPanelPhases.Room3pidMemberInfo, + phase: RightPanelPhases.ThreePidMemberInfo, state: { memberInfoEvent: payload.event }, }); } else { - rightPanelStore.showOrHidePhase(RightPanelPhases.RoomMemberList); + rightPanelStore.showOrHidePhase(RightPanelPhases.MemberList); } }; diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts index e0e06ec980..2577b2ba23 100644 --- a/src/stores/room-list/MessagePreviewStore.ts +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -22,8 +22,6 @@ import { StickerEventPreview } from "./previews/StickerEventPreview"; import { ReactionEventPreview } from "./previews/ReactionEventPreview"; import { UPDATE_EVENT } from "../AsyncStore"; import { IPreview } from "./previews/IPreview"; -import { VoiceBroadcastInfoEventType } from "../../voice-broadcast"; -import { VoiceBroadcastPreview } from "./previews/VoiceBroadcastPreview"; import shouldHideEvent from "../../shouldHideEvent"; // Emitted event for when a room's preview has changed. First argument will the room for which @@ -69,10 +67,6 @@ const PREVIEWS: Record< isState: false, previewer: new PollStartEventPreview(), }, - [VoiceBroadcastInfoEventType]: { - isState: true, - previewer: new VoiceBroadcastPreview(), - }, }; // The maximum number of events we're willing to look back on to get a preview. diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts index 2873320cf3..20631f1425 100644 --- a/src/stores/room-list/previews/MessageEventPreview.ts +++ b/src/stores/room-list/previews/MessageEventPreview.ts @@ -14,15 +14,11 @@ import { _t, sanitizeForTranslation } from "../../../languageHandler"; import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; import { getHtmlText } from "../../../HtmlUtils"; import { stripHTMLReply, stripPlainReply } from "../../../utils/Reply"; -import { VoiceBroadcastChunkEventType } from "../../../voice-broadcast/types"; export class MessageEventPreview implements IPreview { public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null { let eventContent = event.getContent(); - // no preview for broadcast chunks - if (eventContent[VoiceBroadcastChunkEventType]) return null; - if (event.isRelation(RelationType.Replace)) { // It's an edit, generate the preview on the new text eventContent = event.getContent()["m.new_content"]; diff --git a/src/stores/room-list/previews/PollStartEventPreview.ts b/src/stores/room-list/previews/PollStartEventPreview.ts index bb005f4a94..7548cf12f7 100644 --- a/src/stores/room-list/previews/PollStartEventPreview.ts +++ b/src/stores/room-list/previews/PollStartEventPreview.ts @@ -18,7 +18,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; export class PollStartEventPreview implements IPreview { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null { let eventContent = event.getContent(); diff --git a/src/stores/room-list/previews/VoiceBroadcastPreview.ts b/src/stores/room-list/previews/VoiceBroadcastPreview.ts deleted file mode 100644 index 94116692a6..0000000000 --- a/src/stores/room-list/previews/VoiceBroadcastPreview.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoState } from "../../../voice-broadcast/types"; -import { textForVoiceBroadcastStoppedEventWithoutLink } from "../../../voice-broadcast/utils/textForVoiceBroadcastStoppedEventWithoutLink"; -import { IPreview } from "./IPreview"; - -export class VoiceBroadcastPreview implements IPreview { - public getTextFor(event: MatrixEvent, tagId?: string, isThread?: boolean): string | null { - if (!event.isRedacted() && event.getContent()?.state === VoiceBroadcastInfoState.Stopped) { - return textForVoiceBroadcastStoppedEventWithoutLink(event); - } - - return null; - } -} diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 8362f1048a..0472b1664b 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -284,10 +284,6 @@ export class StopGapWidget extends EventEmitter { }); this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); - this.messaging.on(`action:${ElementWidgetActions.JoinCall}`, () => { - // pause voice broadcast recording when any widget sends a "join" - SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent()?.pause(); - }); // Always attach a handler for ViewRoom, but permission check it internally this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent) => { diff --git a/src/utils/EventRenderingUtils.ts b/src/utils/EventRenderingUtils.ts index 099bf768d8..ed8d4af101 100644 --- a/src/utils/EventRenderingUtils.ts +++ b/src/utils/EventRenderingUtils.ts @@ -21,7 +21,6 @@ import SettingsStore from "../settings/SettingsStore"; import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory"; import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils"; import { ElementCall } from "../models/Call"; -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../voice-broadcast"; const calcIsInfoMessage = ( eventType: EventType | string, @@ -38,8 +37,7 @@ const calcIsInfoMessage = ( eventType !== EventType.RoomCreate && !M_POLL_START.matches(eventType) && !M_POLL_END.matches(eventType) && - !M_BEACON_INFO.matches(eventType) && - !(eventType === VoiceBroadcastInfoEventType && content?.state === VoiceBroadcastInfoState.Started) + !M_BEACON_INFO.matches(eventType) ); }; @@ -91,8 +89,7 @@ export function getEventDisplayInfo( (eventType === EventType.RoomMessage && msgtype === MsgType.Emote) || M_POLL_START.matches(eventType) || M_BEACON_INFO.matches(eventType) || - isLocationEvent(mxEvent) || - eventType === VoiceBroadcastInfoEventType; + isLocationEvent(mxEvent); // If we're showing hidden events in the timeline, we should use the // source tile when there's no regular tile for an event and also for diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index 7c5b80697b..d57cefa1b5 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -30,7 +30,6 @@ import { TimelineRenderingType } from "../contexts/RoomContext"; import { launchPollEditor } from "../components/views/messages/MPollBody"; import { Action } from "../dispatcher/actions"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../voice-broadcast/types"; /** * Returns whether an event should allow actions like reply, reactions, edit, etc. @@ -56,9 +55,7 @@ export function isContentActionable(mxEvent: MatrixEvent): boolean { mxEvent.getType() === "m.sticker" || M_POLL_START.matches(mxEvent.getType()) || M_POLL_END.matches(mxEvent.getType()) || - M_BEACON_INFO.matches(mxEvent.getType()) || - (mxEvent.getType() === VoiceBroadcastInfoEventType && - mxEvent.getContent()?.state === VoiceBroadcastInfoState.Started) + M_BEACON_INFO.matches(mxEvent.getType()) ) { return true; } diff --git a/src/utils/FontManager.ts b/src/utils/FontManager.ts deleted file mode 100644 index 1ffa653a27..0000000000 --- a/src/utils/FontManager.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019-2021 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -/* - * Based on... - * ChromaCheck 1.16 - * author Roel Nieskens, https://pixelambacht.nl - * MIT license - */ -import { logger } from "matrix-js-sdk/src/logger"; - -function safariVersionCheck(ua: string): boolean { - logger.log("Browser is Safari - checking version for COLR support"); - try { - const safariVersionMatch = ua.match(/Mac OS X ([\d|_]+).*Version\/([\d|.]+).*Safari/); - if (safariVersionMatch) { - const macOSVersionStr = safariVersionMatch[1]; - const safariVersionStr = safariVersionMatch[2]; - const macOSVersion = macOSVersionStr.split("_").map((n) => parseInt(n, 10)); - const safariVersion = safariVersionStr.split(".").map((n) => parseInt(n, 10)); - const colrFontSupported = - macOSVersion[0] >= 10 && macOSVersion[1] >= 14 && safariVersion[0] >= 12 && safariVersion[0] < 17; - // https://www.colorfonts.wtf/ states Safari supports COLR fonts from this version on but Safari 17 breaks it - logger.log( - `COLR support on Safari requires macOS 10.14 and Safari 12-16, ` + - `detected Safari ${safariVersionStr} on macOS ${macOSVersionStr}, ` + - `COLR supported: ${colrFontSupported}`, - ); - return colrFontSupported; - } - } catch (err) { - logger.error("Error in Safari COLR version check", err); - } - logger.warn("Couldn't determine Safari version to check COLR font support, assuming no."); - return false; -} - -async function isColrFontSupported(): Promise { - logger.log("Checking for COLR support"); - - const { userAgent } = navigator; - // Firefox has supported COLR fonts since version 26 - // but doesn't support the check below without - // "Extract canvas data" permissions - // when content blocking is enabled. - if (userAgent.includes("Firefox")) { - logger.log("Browser is Firefox - assuming COLR is supported"); - return true; - } - // Safari doesn't wait for the font to load (if it doesn't have it in cache) - // to emit the load event on the image, so there is no way to not make the check - // reliable. Instead sniff the version. - // Excluding "Chrome", as it's user agent unhelpfully also contains Safari... - if (!userAgent.includes("Chrome") && userAgent.includes("Safari")) { - return safariVersionCheck(userAgent); - } - - try { - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d")!; - const img = new Image(); - // eslint-disable-next-line - const fontCOLR = - "d09GRgABAAAAAAKAAAwAAAAAAowAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABDT0xSAAACVAAAABYAAAAYAAIAJUNQQUwAAAJsAAAAEgAAABLJAAAQT1MvMgAAAYAAAAA6AAAAYBfxJ0pjbWFwAAABxAAAACcAAAAsAAzpM2dseWYAAAH0AAAAGgAAABoNIh0kaGVhZAAAARwAAAAvAAAANgxLumdoaGVhAAABTAAAABUAAAAkCAEEAmhtdHgAAAG8AAAABgAAAAYEAAAAbG9jYQAAAewAAAAGAAAABgANAABtYXhwAAABZAAAABsAAAAgAg4AHW5hbWUAAAIQAAAAOAAAAD4C5wsecG9zdAAAAkgAAAAMAAAAIAADAAB4AWNgZGAAYQ5+qdB4fpuvDNIsDCBwaQGTAIi+VlscBaJZGMDiHAxMIAoAtjIF/QB4AWNgZGBgYQACOAkUQQWMAAGRABAAAAB4AWNgZGBgYGJgAdMMUJILJMQgAWICAAH3AC4AeAFjYGFhYJzAwMrAwDST6QwDA0M/hGZ8zWDMyMmAChgFkDgKQMBw4CXDSwYWEBdIYgAFBgYA/8sIdAAABAAAAAAAAAB4AWNgYGBkYAZiBgYeBhYGBSDNAoRA/kuG//8hpDgjWJ4BAFVMBiYAAAAAAAANAAAAAQAAAAAEAAQAAAMAABEhESEEAPwABAD8AAAAeAEtxgUNgAAAAMHHIQTShTlOAty9/4bf7AARCwlBNhBw4L/43qXjYGUmf19TMuLcj/BJL3XfBg54AWNgZsALAAB9AAR4AWNgYGAEYj4gFgGygGwICQACOwAoAAAAAAABAAEAAQAAAA4AAAAAyP8AAA=="; - const svg = ` - - - - - - `; - canvas.width = 20; - canvas.height = 100; - - img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg); - - logger.log("Waiting for COLR SVG to load"); - await new Promise((resolve) => (img.onload = resolve)); - logger.log("Drawing canvas to detect COLR support"); - context.drawImage(img, 0, 0); - const colrFontSupported = context.getImageData(10, 10, 1, 1).data[0] === 200; - logger.log("Canvas check revealed COLR is supported? " + colrFontSupported); - return colrFontSupported; - } catch (e) { - logger.error("Couldn't load COLR font", e); - return false; - } -} - -let colrFontCheckStarted = false; -export async function fixupColorFonts(): Promise { - if (colrFontCheckStarted) { - return; - } - colrFontCheckStarted = true; - - if (await isColrFontSupported()) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const path = `url('${require("../../res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2")}')`; - document.fonts.add(new FontFace("Twemoji", path, {})); - // For at least Chrome on Windows 10, we have to explictly add extra - // weights for the emoji to appear in bold messages, etc. - document.fonts.add(new FontFace("Twemoji", path, { weight: "600" })); - document.fonts.add(new FontFace("Twemoji", path, { weight: "700" })); - } else { - // fall back to SBIX, generated via https://github.com/matrix-org/twemoji-colr/tree/matthew/sbix - // eslint-disable-next-line @typescript-eslint/no-require-imports - const path = `url('${require("../../res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2")}')`; - document.fonts.add(new FontFace("Twemoji", path, {})); - document.fonts.add(new FontFace("Twemoji", path, { weight: "600" })); - document.fonts.add(new FontFace("Twemoji", path, { weight: "700" })); - } - // ...and if SBIX is not supported, the browser will fall back to one of the native fonts specified. -} diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index d1a35f2950..da8157adce 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -350,6 +350,17 @@ export async function asyncSomeParallel( } } +/** + * 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/dm/createDmLocalRoom.ts b/src/utils/dm/createDmLocalRoom.ts index 6d6cf0712b..0a3d312368 100644 --- a/src/utils/dm/createDmLocalRoom.ts +++ b/src/utils/dm/createDmLocalRoom.ts @@ -109,7 +109,7 @@ export async function createDmLocalRoom(client: MatrixClient, targets: Member[]) localRoom.targets = targets; localRoom.updateMyMembership(KnownMembership.Join); - localRoom.addLiveEvents(events); + localRoom.addLiveEvents(events, { addToState: true }); localRoom.currentState.setStateEvents(events); localRoom.name = localRoom.getDefaultRoomName(client.getUserId()!); client.store.storeRoom(localRoom); diff --git a/src/utils/image-media.ts b/src/utils/image-media.ts index 5e0fb07678..5c013c7b1a 100644 --- a/src/utils/image-media.ts +++ b/src/utils/image-media.ts @@ -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 { EncryptedFile } from "matrix-js-sdk/src/types"; +import { ImageInfo } from "matrix-js-sdk/src/types"; import { BlurhashEncoder } from "../BlurhashEncoder"; @@ -15,19 +15,7 @@ type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; export const BLURHASH_FIELD = "xyz.amorgan.blurhash"; // MSC2448 interface IThumbnail { - info: { - thumbnail_info?: { - w: number; - h: number; - mimetype: string; - size: number; - }; - w: number; - h: number; - [BLURHASH_FIELD]?: string; - thumbnail_url?: string; - thumbnail_file?: EncryptedFile; - }; + info: ImageInfo; thumbnail: Blob; } diff --git a/src/utils/react.tsx b/src/utils/react.tsx index 164d704d91..b78f574fa9 100644 --- a/src/utils/react.tsx +++ b/src/utils/react.tsx @@ -15,23 +15,38 @@ import { createRoot, Root } from "react-dom/client"; export class ReactRootManager { private roots: Root[] = []; private rootElements: Element[] = []; + private revertElements: Array = []; public get elements(): Element[] { return this.rootElements; } - public render(children: ReactNode, element: Element): void { - const root = createRoot(element); + /** + * Render a React component into a new root based on the given root element + * @param children the React component to render + * @param rootElement the root element to render the component into + * @param revertElement the element to replace the root element with when unmounting + */ + public render(children: ReactNode, rootElement: Element, revertElement?: Element): void { + const root = createRoot(rootElement); this.roots.push(root); - this.rootElements.push(element); + this.rootElements.push(rootElement); + this.revertElements.push(revertElement ?? null); root.render(children); } + /** + * Unmount all roots and revert the elements they were rendered into + */ public unmount(): void { while (this.roots.length) { const root = this.roots.pop()!; - this.rootElements.pop(); + const rootElement = this.rootElements.pop(); + const revertElement = this.revertElements.pop(); root.unmount(); + if (revertElement) { + rootElement?.replaceWith(revertElement); + } } } } diff --git a/src/vector/index.ts b/src/vector/index.ts index 60f0868eeb..42b69af70e 100644 --- a/src/vector/index.ts +++ b/src/vector/index.ts @@ -67,6 +67,10 @@ function checkBrowserFeatures(): boolean { // although this would start to make (more) assumptions about how rust-crypto loads its wasm. window.Modernizr.addTest("wasm", () => typeof WebAssembly === "object" && typeof WebAssembly.Module === "function"); + // Check that the session is in a secure context otherwise most Crypto & WebRTC APIs will be unavailable + // https://developer.mozilla.org/en-US/docs/Web/API/Window/isSecureContext + window.Modernizr.addTest("securecontext", () => window.isSecureContext); + const featureList = Object.keys(window.Modernizr) as Array; let featureComplete = true; 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/src/verification.ts b/src/verification.ts index e446186f80..9c14a64c51 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -81,7 +81,7 @@ function setRightPanel(state: IRightPanelCardState): void { } else { RightPanelStore.instance.setCards([ { phase: RightPanelPhases.RoomSummary }, - { phase: RightPanelPhases.RoomMemberInfo, state: { member: state.member } }, + { phase: RightPanelPhases.MemberInfo, state: { member: state.member } }, { phase: RightPanelPhases.EncryptionPanel, state }, ]); } diff --git a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts deleted file mode 100644 index 8a6e17a1a5..0000000000 --- a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts +++ /dev/null @@ -1,181 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { isEqual } from "lodash"; -import { Optional } from "matrix-events-sdk"; -import { logger } from "matrix-js-sdk/src/logger"; -import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; - -import { getChunkLength } from ".."; -import { IRecordingUpdate, VoiceRecording } from "../../audio/VoiceRecording"; -import { concat } from "../../utils/arrays"; -import { IDestroyable } from "../../utils/IDestroyable"; -import { Singleflight } from "../../utils/Singleflight"; - -export enum VoiceBroadcastRecorderEvent { - ChunkRecorded = "chunk_recorded", - CurrentChunkLengthUpdated = "current_chunk_length_updated", -} - -interface EventMap { - [VoiceBroadcastRecorderEvent.ChunkRecorded]: (chunk: ChunkRecordedPayload) => void; - [VoiceBroadcastRecorderEvent.CurrentChunkLengthUpdated]: (length: number) => void; -} - -export interface ChunkRecordedPayload { - buffer: Uint8Array; - length: number; -} - -// char sequence of "OpusHead" -const OpusHead = [79, 112, 117, 115, 72, 101, 97, 100]; - -// char sequence of "OpusTags" -const OpusTags = [79, 112, 117, 115, 84, 97, 103, 115]; - -/** - * This class provides the function to seamlessly record fixed length chunks. - * Subscribe with on(VoiceBroadcastRecordingEvents.ChunkRecorded, (payload: ChunkRecordedPayload) => {}) - * to retrieve chunks while recording. - */ -export class VoiceBroadcastRecorder - extends TypedEventEmitter - implements IDestroyable -{ - private opusHead?: Uint8Array; - private opusTags?: Uint8Array; - private chunkBuffer = new Uint8Array(0); - // position of the previous chunk in seconds - private previousChunkEndTimePosition = 0; - // current chunk length in seconds - private currentChunkLength = 0; - - public constructor( - private voiceRecording: VoiceRecording, - public readonly targetChunkLength: number, - ) { - super(); - this.voiceRecording.onDataAvailable = this.onDataAvailable; - } - - public async start(): Promise { - await this.voiceRecording.start(); - this.voiceRecording.liveData.onUpdate((data: IRecordingUpdate) => { - this.setCurrentChunkLength(data.timeSeconds - this.previousChunkEndTimePosition); - }); - } - - /** - * Stops the recording and returns the remaining chunk (if any). - */ - public async stop(): Promise> { - try { - await this.voiceRecording.stop(); - } catch { - // Ignore if the recording raises any error. - } - - // forget about that call, so that we can stop it again later - Singleflight.forgetAllFor(this.voiceRecording); - const chunk = this.extractChunk(); - this.currentChunkLength = 0; - this.previousChunkEndTimePosition = 0; - return chunk; - } - - public get contentType(): string { - return this.voiceRecording.contentType; - } - - private setCurrentChunkLength(currentChunkLength: number): void { - if (this.currentChunkLength === currentChunkLength) return; - - this.currentChunkLength = currentChunkLength; - this.emit(VoiceBroadcastRecorderEvent.CurrentChunkLengthUpdated, currentChunkLength); - } - - public getCurrentChunkLength(): number { - return this.currentChunkLength; - } - - private onDataAvailable = (data: ArrayBuffer): void => { - const dataArray = new Uint8Array(data); - - // extract the part, that contains the header type info - const headerType = Array.from(dataArray.slice(28, 36)); - - if (isEqual(OpusHead, headerType)) { - // data seems to be an "OpusHead" header - this.opusHead = dataArray; - return; - } - - if (isEqual(OpusTags, headerType)) { - // data seems to be an "OpusTags" header - this.opusTags = dataArray; - return; - } - - this.setCurrentChunkLength(this.voiceRecording.recorderSeconds! - this.previousChunkEndTimePosition); - this.handleData(dataArray); - }; - - private handleData(data: Uint8Array): void { - this.chunkBuffer = concat(this.chunkBuffer, data); - this.emitChunkIfTargetLengthReached(); - } - - private emitChunkIfTargetLengthReached(): void { - if (this.getCurrentChunkLength() >= this.targetChunkLength) { - this.emitAndResetChunk(); - } - } - - /** - * Extracts the current chunk and resets the buffer. - */ - private extractChunk(): Optional { - if (this.chunkBuffer.length === 0) { - return null; - } - - if (!this.opusHead || !this.opusTags) { - logger.warn("Broadcast chunk cannot be extracted. OpusHead or OpusTags is missing."); - return null; - } - - const currentRecorderTime = this.voiceRecording.recorderSeconds!; - const payload: ChunkRecordedPayload = { - buffer: concat(this.opusHead!, this.opusTags!, this.chunkBuffer), - length: this.getCurrentChunkLength(), - }; - this.chunkBuffer = new Uint8Array(0); - this.setCurrentChunkLength(0); - this.previousChunkEndTimePosition = currentRecorderTime; - return payload; - } - - private emitAndResetChunk(): void { - if (this.chunkBuffer.length === 0) { - return; - } - - this.emit(VoiceBroadcastRecorderEvent.ChunkRecorded, this.extractChunk()!); - } - - public destroy(): void { - this.removeAllListeners(); - this.voiceRecording.destroy(); - } -} - -export const createVoiceBroadcastRecorder = (): VoiceBroadcastRecorder => { - const voiceRecording = new VoiceRecording(); - voiceRecording.disableMaxLength(); - return new VoiceBroadcastRecorder(voiceRecording, getChunkLength()); -}; diff --git a/src/voice-broadcast/components/VoiceBroadcastBody.tsx b/src/voice-broadcast/components/VoiceBroadcastBody.tsx deleted file mode 100644 index 916ee9f907..0000000000 --- a/src/voice-broadcast/components/VoiceBroadcastBody.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, { useContext, useEffect, useState } from "react"; -import { MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; - -import { - VoiceBroadcastRecordingBody, - shouldDisplayAsVoiceBroadcastRecordingTile, - VoiceBroadcastInfoEventType, - VoiceBroadcastPlaybackBody, - VoiceBroadcastInfoState, -} from ".."; -import { IBodyProps } from "../../components/views/messages/IBodyProps"; -import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; -import { SDKContext } from "../../contexts/SDKContext"; -import { useMatrixClientContext } from "../../contexts/MatrixClientContext"; - -export const VoiceBroadcastBody: React.FC = ({ mxEvent }) => { - const sdkContext = useContext(SDKContext); - const client = useMatrixClientContext(); - const [infoState, setInfoState] = useState(mxEvent.getContent()?.state || VoiceBroadcastInfoState.Stopped); - - useEffect(() => { - const onInfoEvent = (event: MatrixEvent): void => { - if (event.getContent()?.state === VoiceBroadcastInfoState.Stopped) { - // only a stopped event can change the tile state - setInfoState(VoiceBroadcastInfoState.Stopped); - } - }; - - const relationsHelper = new RelationsHelper( - mxEvent, - RelationType.Reference, - VoiceBroadcastInfoEventType, - client, - ); - relationsHelper.on(RelationsHelperEvent.Add, onInfoEvent); - relationsHelper.emitCurrent(); - - return () => { - relationsHelper.destroy(); - }; - }); - - if (shouldDisplayAsVoiceBroadcastRecordingTile(infoState, client, mxEvent)) { - const recording = sdkContext.voiceBroadcastRecordingsStore.getByInfoEvent(mxEvent, client); - return ; - } - - const playback = sdkContext.voiceBroadcastPlaybacksStore.getByInfoEvent(mxEvent, client); - return ; -}; diff --git a/src/voice-broadcast/components/atoms/LiveBadge.tsx b/src/voice-broadcast/components/atoms/LiveBadge.tsx deleted file mode 100644 index 2591fee435..0000000000 --- a/src/voice-broadcast/components/atoms/LiveBadge.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import classNames from "classnames"; -import React from "react"; - -import { Icon as LiveIcon } from "../../../../res/img/compound/live-16px.svg"; -import { _t } from "../../../languageHandler"; - -interface Props { - grey?: boolean; -} - -export const LiveBadge: React.FC = ({ grey = false }) => { - const liveBadgeClasses = classNames("mx_LiveBadge", { - "mx_LiveBadge--grey": grey, - }); - - return ( -
- - {_t("voice_broadcast|live")} -
- ); -}; diff --git a/src/voice-broadcast/components/atoms/SeekButton.tsx b/src/voice-broadcast/components/atoms/SeekButton.tsx deleted file mode 100644 index 5ee0826488..0000000000 --- a/src/voice-broadcast/components/atoms/SeekButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 AccessibleButton from "../../../components/views/elements/AccessibleButton"; - -interface Props { - icon: React.FC>; - label: string; - onClick: () => void; -} - -export const SeekButton: React.FC = ({ onClick, icon: Icon, label }) => { - return ( - - - - ); -}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx deleted file mode 100644 index 177b8fd732..0000000000 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import classNames from "classnames"; -import React, { ReactElement } from "react"; - -import AccessibleButton from "../../../components/views/elements/AccessibleButton"; - -interface Props { - className?: string; - icon: ReactElement; - label: string; - onClick: () => void; -} - -export const VoiceBroadcastControl: React.FC = ({ className = "", icon, label, onClick }) => { - return ( - - {icon} - - ); -}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastError.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastError.tsx deleted file mode 100644 index d326853f4e..0000000000 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastError.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 The Matrix.org Foundation C.I.C. - -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 { WarningIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -interface Props { - message: string; -} - -export const VoiceBroadcastError: React.FC = ({ message }) => { - return ( -
- - {message} -
- ); -}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx deleted file mode 100644 index 52c0251c5e..0000000000 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 { Room } from "matrix-js-sdk/src/matrix"; -import classNames from "classnames"; -import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; -import MicrophoneIcon from "@vector-im/compound-design-tokens/assets/web/icons/mic-on-solid"; - -import { LiveBadge, VoiceBroadcastLiveness } from "../.."; -import { Icon as LiveIcon } from "../../../../res/img/compound/live-16px.svg"; -import { Icon as TimerIcon } from "../../../../res/img/compound/timer-16px.svg"; -import { _t } from "../../../languageHandler"; -import RoomAvatar from "../../../components/views/avatars/RoomAvatar"; -import AccessibleButton, { ButtonEvent } from "../../../components/views/elements/AccessibleButton"; -import Clock from "../../../components/views/audio_messages/Clock"; -import { formatTimeLeft } from "../../../DateUtils"; -import Spinner from "../../../components/views/elements/Spinner"; -import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { Action } from "../../../dispatcher/actions"; -import dis from "../../../dispatcher/dispatcher"; - -interface VoiceBroadcastHeaderProps { - linkToRoom?: boolean; - live?: VoiceBroadcastLiveness; - liveBadgePosition?: "middle" | "right"; - onCloseClick?: () => void; - onMicrophoneLineClick?: ((e: ButtonEvent) => void | Promise) | null; - room: Room; - microphoneLabel?: string; - showBroadcast?: boolean; - showBuffering?: boolean; - bufferingPosition?: "line" | "title"; - timeLeft?: number; - showClose?: boolean; -} - -export const VoiceBroadcastHeader: React.FC = ({ - linkToRoom = false, - live = "not-live", - liveBadgePosition = "right", - onCloseClick = (): void => {}, - onMicrophoneLineClick = null, - room, - microphoneLabel, - showBroadcast = false, - showBuffering = false, - bufferingPosition = "line", - showClose = false, - timeLeft, -}) => { - const broadcast = showBroadcast && ( -
- - {_t("voice_broadcast|action")} -
- ); - - const liveBadge = live !== "not-live" && ; - - const closeButton = showClose && ( - - - - ); - - const timeLeftLine = timeLeft && ( -
- - -
- ); - - const bufferingLine = showBuffering && bufferingPosition === "line" && ( -
- - {_t("voice_broadcast|buffering")} -
- ); - - const microphoneLineClasses = classNames({ - mx_VoiceBroadcastHeader_line: true, - ["mx_VoiceBroadcastHeader_mic--clickable"]: onMicrophoneLineClick, - }); - - const microphoneLine = microphoneLabel && ( - - - {microphoneLabel} - - ); - - const onRoomAvatarOrNameClick = (): void => { - dis.dispatch({ - action: Action.ViewRoom, - room_id: room.roomId, - metricsTrigger: undefined, // other - }); - }; - - let roomAvatar = ; - let roomName = ( -
-
{room.name}
- {showBuffering && bufferingPosition === "title" && } -
- ); - - if (linkToRoom) { - roomAvatar = {roomAvatar}; - - roomName = {roomName}; - } - - return ( -
- {roomAvatar} -
- {roomName} - {microphoneLine} - {timeLeftLine} - {broadcast} - {bufferingLine} - {liveBadgePosition === "middle" && liveBadge} -
- {liveBadgePosition === "right" && liveBadge} - {closeButton} -
- ); -}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastPlaybackControl.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastPlaybackControl.tsx deleted file mode 100644 index 08531b8afd..0000000000 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastPlaybackControl.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022, 2023 The Matrix.org Foundation C.I.C. - -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, { ReactElement } from "react"; -import PauseIcon from "@vector-im/compound-design-tokens/assets/web/icons/pause-solid"; -import PlayIcon from "@vector-im/compound-design-tokens/assets/web/icons/play-solid"; - -import { _t } from "../../../languageHandler"; -import { VoiceBroadcastControl, VoiceBroadcastPlaybackState } from "../.."; - -interface Props { - onClick: () => void; - state: VoiceBroadcastPlaybackState; -} - -export const VoiceBroadcastPlaybackControl: React.FC = ({ onClick, state }) => { - let controlIcon: ReactElement | null = null; - let controlLabel: string | null = null; - let className = ""; - - switch (state) { - case VoiceBroadcastPlaybackState.Stopped: - controlIcon = ; - className = "mx_VoiceBroadcastControl-play"; - controlLabel = _t("voice_broadcast|play"); - break; - case VoiceBroadcastPlaybackState.Paused: - controlIcon = ; - className = "mx_VoiceBroadcastControl-play"; - controlLabel = _t("voice_broadcast|resume"); - break; - case VoiceBroadcastPlaybackState.Buffering: - case VoiceBroadcastPlaybackState.Playing: - controlIcon = ; - controlLabel = _t("voice_broadcast|pause"); - break; - } - - if (controlIcon && controlLabel) { - return ( - - ); - } - - return null; -}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastRecordingConnectionError.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastRecordingConnectionError.tsx deleted file mode 100644 index 250d71f2f3..0000000000 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastRecordingConnectionError.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 The Matrix.org Foundation C.I.C. - -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 { WarningIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { _t } from "../../../languageHandler"; - -export const VoiceBroadcastRecordingConnectionError: React.FC = () => { - return ( -
- - {_t("voice_broadcast|connection_error")} -
- ); -}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.tsx deleted file mode 100644 index 20b7379789..0000000000 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 { Icon as LiveIcon } from "../../../../res/img/compound/live-16px.svg"; -import { _t } from "../../../languageHandler"; - -export const VoiceBroadcastRoomSubtitle: React.FC = () => { - return ( -
- - {_t("voice_broadcast|live")} -
- ); -}; diff --git a/src/voice-broadcast/components/molecules/ConfirmListenBroadcastStopCurrent.tsx b/src/voice-broadcast/components/molecules/ConfirmListenBroadcastStopCurrent.tsx deleted file mode 100644 index 3dadfeba60..0000000000 --- a/src/voice-broadcast/components/molecules/ConfirmListenBroadcastStopCurrent.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 BaseDialog from "../../../components/views/dialogs/BaseDialog"; -import DialogButtons from "../../../components/views/elements/DialogButtons"; -import { _t } from "../../../languageHandler"; -import Modal from "../../../Modal"; - -interface Props { - onFinished: (confirmed?: boolean) => void; -} - -export const ConfirmListenBroadcastStopCurrentDialog: React.FC = ({ onFinished }) => { - return ( - -

{_t("voice_broadcast|confirm_listen_description")}

- onFinished(true)} - primaryButton={_t("voice_broadcast|confirm_listen_affirm")} - cancelButton={_t("action|no")} - onCancel={() => onFinished(false)} - /> -
- ); -}; - -export const showConfirmListenBroadcastStopCurrentDialog = async (): Promise => { - const { finished } = Modal.createDialog(ConfirmListenBroadcastStopCurrentDialog); - const [confirmed] = await finished; - return !!confirmed; -}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx deleted file mode 100644 index 913b144960..0000000000 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, { ReactElement } from "react"; -import classNames from "classnames"; - -import { - VoiceBroadcastError, - VoiceBroadcastHeader, - VoiceBroadcastPlayback, - VoiceBroadcastPlaybackControl, - VoiceBroadcastPlaybackState, -} from "../.."; -import { useVoiceBroadcastPlayback } from "../../hooks/useVoiceBroadcastPlayback"; -import { Icon as Back30sIcon } from "../../../../res/img/compound/back-30s-24px.svg"; -import { Icon as Forward30sIcon } from "../../../../res/img/compound/forward-30s-24px.svg"; -import { _t } from "../../../languageHandler"; -import Clock from "../../../components/views/audio_messages/Clock"; -import SeekBar from "../../../components/views/audio_messages/SeekBar"; -import { SeekButton } from "../atoms/SeekButton"; - -const SEEK_TIME = 30; - -interface VoiceBroadcastPlaybackBodyProps { - pip?: boolean; - playback: VoiceBroadcastPlayback; -} - -export const VoiceBroadcastPlaybackBody: React.FC = ({ pip = false, playback }) => { - const { times, liveness, playbackState, room, sender, toggle } = useVoiceBroadcastPlayback(playback); - - let seekBackwardButton: ReactElement | null = null; - let seekForwardButton: ReactElement | null = null; - - if (playbackState !== VoiceBroadcastPlaybackState.Stopped) { - const onSeekBackwardButtonClick = (): void => { - playback.skipTo(Math.max(0, times.position - SEEK_TIME)); - }; - - seekBackwardButton = ( - - ); - - const onSeekForwardButtonClick = (): void => { - playback.skipTo(Math.min(times.duration, times.position + SEEK_TIME)); - }; - - seekForwardButton = ( - - ); - } - - const classes = classNames({ - mx_VoiceBroadcastBody: true, - ["mx_VoiceBroadcastBody--pip"]: pip, - }); - - const content = - playbackState === VoiceBroadcastPlaybackState.Error ? ( - - ) : ( - <> -
- {seekBackwardButton} - - {seekForwardButton} -
- -
- - -
- - ); - - return ( -
- - {content} -
- ); -}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx deleted file mode 100644 index ac742e0fd8..0000000000 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, { useRef, useState } from "react"; - -import { VoiceBroadcastHeader } from "../.."; -import AccessibleButton from "../../../components/views/elements/AccessibleButton"; -import { VoiceBroadcastPreRecording } from "../../models/VoiceBroadcastPreRecording"; -import { Icon as LiveIcon } from "../../../../res/img/compound/live-16px.svg"; -import { _t } from "../../../languageHandler"; -import { useAudioDeviceSelection } from "../../../hooks/useAudioDeviceSelection"; -import { DevicesContextMenu } from "../../../components/views/audio_messages/DevicesContextMenu"; - -interface Props { - voiceBroadcastPreRecording: VoiceBroadcastPreRecording; -} - -interface State { - showDeviceSelect: boolean; - disableStartButton: boolean; -} - -export const VoiceBroadcastPreRecordingPip: React.FC = ({ voiceBroadcastPreRecording }) => { - const pipRef = useRef(null); - const { currentDevice, currentDeviceLabel, devices, setDevice } = useAudioDeviceSelection(); - const [state, setState] = useState({ - showDeviceSelect: false, - disableStartButton: false, - }); - - const onDeviceSelect = (device: MediaDeviceInfo): void => { - setState((state) => ({ - ...state, - showDeviceSelect: false, - })); - setDevice(device); - }; - - const onStartBroadcastClick = (): void => { - setState((state) => ({ - ...state, - disableStartButton: true, - })); - - voiceBroadcastPreRecording.start(); - }; - - return ( -
- setState({ ...state, showDeviceSelect: true })} - room={voiceBroadcastPreRecording.room} - microphoneLabel={currentDeviceLabel} - showClose={true} - /> - - - {_t("voice_broadcast|go_live")} - - {state.showDeviceSelect && ( - - )} -
- ); -}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx deleted file mode 100644 index 15547792db..0000000000 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 { - useVoiceBroadcastRecording, - VoiceBroadcastHeader, - VoiceBroadcastRecording, - VoiceBroadcastRecordingConnectionError, -} from "../.."; - -interface VoiceBroadcastRecordingBodyProps { - recording: VoiceBroadcastRecording; -} - -export const VoiceBroadcastRecordingBody: React.FC = ({ recording }) => { - const { live, room, sender, recordingState } = useVoiceBroadcastRecording(recording); - - return ( -
- - {recordingState === "connection_error" && } -
- ); -}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx deleted file mode 100644 index d04132b220..0000000000 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, { useRef, useState } from "react"; -import PauseIcon from "@vector-im/compound-design-tokens/assets/web/icons/pause-solid"; -import MicrophoneIcon from "@vector-im/compound-design-tokens/assets/web/icons/mic-on-solid"; - -import { - VoiceBroadcastControl, - VoiceBroadcastInfoState, - VoiceBroadcastRecording, - VoiceBroadcastRecordingConnectionError, - VoiceBroadcastRecordingState, -} from "../.."; -import { useVoiceBroadcastRecording } from "../../hooks/useVoiceBroadcastRecording"; -import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader"; -import { Icon as StopIcon } from "../../../../res/img/compound/stop-16.svg"; -import { Icon as RecordIcon } from "../../../../res/img/compound/record-10px.svg"; -import { _t } from "../../../languageHandler"; -import { useAudioDeviceSelection } from "../../../hooks/useAudioDeviceSelection"; -import { DevicesContextMenu } from "../../../components/views/audio_messages/DevicesContextMenu"; -import AccessibleButton from "../../../components/views/elements/AccessibleButton"; - -interface VoiceBroadcastRecordingPipProps { - recording: VoiceBroadcastRecording; -} - -export const VoiceBroadcastRecordingPip: React.FC = ({ recording }) => { - const pipRef = useRef(null); - const { live, timeLeft, recordingState, room, stopRecording, toggleRecording } = - useVoiceBroadcastRecording(recording); - const { currentDevice, devices, setDevice } = useAudioDeviceSelection(); - - const onDeviceSelect = async (device: MediaDeviceInfo): Promise => { - setShowDeviceSelect(false); - - if (currentDevice?.deviceId === device.deviceId) { - // device unchanged - return; - } - - setDevice(device); - - if ( - ( - [VoiceBroadcastInfoState.Paused, VoiceBroadcastInfoState.Stopped] as VoiceBroadcastRecordingState[] - ).includes(recordingState) - ) { - // Nothing to do in these cases. Resume will use the selected device. - return; - } - - // pause and resume to switch the input device - await recording.pause(); - await recording.resume(); - }; - - const [showDeviceSelect, setShowDeviceSelect] = useState(false); - - const toggleControl = - recordingState === VoiceBroadcastInfoState.Paused ? ( - } - label={_t("voice_broadcast|resume")} - /> - ) : ( - } - label={_t("voice_broadcast|pause")} - /> - ); - - const controls = - recordingState === "connection_error" ? ( - - ) : ( -
- {toggleControl} - setShowDeviceSelect(true)} - title={_t("voip|change_input_device")} - > - - - } - label="Stop Recording" - onClick={stopRecording} - /> -
- ); - - return ( -
- -
- {controls} - {showDeviceSelect && ( - - )} -
- ); -}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastSmallPlaybackBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastSmallPlaybackBody.tsx deleted file mode 100644 index a791ac75d7..0000000000 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastSmallPlaybackBody.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; - -import { - VoiceBroadcastHeader, - VoiceBroadcastPlayback, - VoiceBroadcastPlaybackControl, - VoiceBroadcastPlaybackState, -} from "../.."; -import AccessibleButton from "../../../components/views/elements/AccessibleButton"; -import { useVoiceBroadcastPlayback } from "../../hooks/useVoiceBroadcastPlayback"; - -interface VoiceBroadcastSmallPlaybackBodyProps { - playback: VoiceBroadcastPlayback; -} - -export const VoiceBroadcastSmallPlaybackBody: React.FC = ({ playback }) => { - const { liveness, playbackState, room, sender, toggle } = useVoiceBroadcastPlayback(playback); - return ( -
- - - playback.stop()}> - - -
- ); -}; diff --git a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback.ts b/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback.ts deleted file mode 100644 index 3ff4081a9f..0000000000 --- a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; -import { VoiceBroadcastPlayback } from "../models/VoiceBroadcastPlayback"; -import { - VoiceBroadcastPlaybacksStore, - VoiceBroadcastPlaybacksStoreEvent, -} from "../stores/VoiceBroadcastPlaybacksStore"; - -export const useCurrentVoiceBroadcastPlayback = ( - voiceBroadcastPlaybackStore: VoiceBroadcastPlaybacksStore, -): { - currentVoiceBroadcastPlayback: VoiceBroadcastPlayback | null; -} => { - const currentVoiceBroadcastPlayback = useTypedEventEmitterState( - voiceBroadcastPlaybackStore, - VoiceBroadcastPlaybacksStoreEvent.CurrentChanged, - (playback?: VoiceBroadcastPlayback) => { - return playback ?? voiceBroadcastPlaybackStore.getCurrent(); - }, - ); - - return { - currentVoiceBroadcastPlayback, - }; -}; diff --git a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPreRecording.ts b/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPreRecording.ts deleted file mode 100644 index bb14e38640..0000000000 --- a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPreRecording.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; -import { VoiceBroadcastPreRecordingStore } from "../stores/VoiceBroadcastPreRecordingStore"; -import { VoiceBroadcastPreRecording } from "../models/VoiceBroadcastPreRecording"; - -export const useCurrentVoiceBroadcastPreRecording = ( - voiceBroadcastPreRecordingStore: VoiceBroadcastPreRecordingStore, -): { - currentVoiceBroadcastPreRecording: VoiceBroadcastPreRecording | null; -} => { - const currentVoiceBroadcastPreRecording = useTypedEventEmitterState( - voiceBroadcastPreRecordingStore, - "changed", - (preRecording?: VoiceBroadcastPreRecording) => { - return preRecording ?? voiceBroadcastPreRecordingStore.getCurrent(); - }, - ); - - return { - currentVoiceBroadcastPreRecording, - }; -}; diff --git a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastRecording.ts b/src/voice-broadcast/hooks/useCurrentVoiceBroadcastRecording.ts deleted file mode 100644 index 1d4abe3f10..0000000000 --- a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastRecording.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { VoiceBroadcastRecording, VoiceBroadcastRecordingsStore, VoiceBroadcastRecordingsStoreEvent } from ".."; -import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; - -export const useCurrentVoiceBroadcastRecording = ( - voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore, -): { - currentVoiceBroadcastRecording: VoiceBroadcastRecording | null; -} => { - const currentVoiceBroadcastRecording = useTypedEventEmitterState( - voiceBroadcastRecordingsStore, - VoiceBroadcastRecordingsStoreEvent.CurrentChanged, - (recording?: VoiceBroadcastRecording) => { - return recording ?? voiceBroadcastRecordingsStore.getCurrent(); - }, - ); - - return { - currentVoiceBroadcastRecording, - }; -}; diff --git a/src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts b/src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts deleted file mode 100644 index a298f4dc83..0000000000 --- a/src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { useContext, useEffect, useMemo, useState } from "react"; -import { Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; - -import { hasRoomLiveVoiceBroadcast } from "../utils/hasRoomLiveVoiceBroadcast"; -import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; -import { SDKContext } from "../../contexts/SDKContext"; - -export const useHasRoomLiveVoiceBroadcast = (room: Room): boolean => { - const sdkContext = useContext(SDKContext); - const [hasLiveVoiceBroadcast, setHasLiveVoiceBroadcast] = useState(false); - - const update = useMemo(() => { - return sdkContext?.client - ? () => { - hasRoomLiveVoiceBroadcast(sdkContext.client!, room).then( - ({ hasBroadcast }) => { - setHasLiveVoiceBroadcast(hasBroadcast); - }, - () => {}, // no update on error - ); - } - : () => {}; // noop without client - }, [room, sdkContext, setHasLiveVoiceBroadcast]); - - useEffect(() => { - update(); - }, [update]); - - useTypedEventEmitter(room.currentState, RoomStateEvent.Update, () => update()); - return hasLiveVoiceBroadcast; -}; diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts b/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts deleted file mode 100644 index eb50b0de08..0000000000 --- a/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022, 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; - -import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import { - VoiceBroadcastLiveness, - VoiceBroadcastPlayback, - VoiceBroadcastPlaybackEvent, - VoiceBroadcastPlaybackState, - VoiceBroadcastPlaybackTimes, -} from ".."; - -export const useVoiceBroadcastPlayback = ( - playback: VoiceBroadcastPlayback, -): { - times: { - duration: number; - position: number; - timeLeft: number; - }; - sender: RoomMember | null; - liveness: VoiceBroadcastLiveness; - playbackState: VoiceBroadcastPlaybackState; - toggle(): void; - room: Room; -} => { - const client = MatrixClientPeg.safeGet(); - const room = client.getRoom(playback.infoEvent.getRoomId()); - - if (!room) { - throw new Error(`Voice Broadcast room not found (event ${playback.infoEvent.getId()})`); - } - - const sender = playback.infoEvent.sender; - - if (!sender) { - throw new Error(`Voice Broadcast sender not found (event ${playback.infoEvent.getId()})`); - } - - const playbackToggle = (): void => { - playback.toggle(); - }; - - const playbackState = useTypedEventEmitterState( - playback, - VoiceBroadcastPlaybackEvent.StateChanged, - (state?: VoiceBroadcastPlaybackState) => { - return state ?? playback.getState(); - }, - ); - - const times = useTypedEventEmitterState( - playback, - VoiceBroadcastPlaybackEvent.TimesChanged, - (t?: VoiceBroadcastPlaybackTimes) => { - return ( - t ?? { - duration: playback.durationSeconds, - position: playback.timeSeconds, - timeLeft: playback.timeLeftSeconds, - } - ); - }, - ); - - const liveness = useTypedEventEmitterState( - playback, - VoiceBroadcastPlaybackEvent.LivenessChanged, - (l?: VoiceBroadcastLiveness) => { - return l ?? playback.getLiveness(); - }, - ); - - return { - times, - liveness: liveness, - playbackState, - room: room, - sender, - toggle: playbackToggle, - }; -}; diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx deleted file mode 100644 index fa3c635bc9..0000000000 --- a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022, 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; -import React from "react"; - -import { - VoiceBroadcastInfoState, - VoiceBroadcastRecording, - VoiceBroadcastRecordingEvent, - VoiceBroadcastRecordingState, -} from ".."; -import QuestionDialog from "../../components/views/dialogs/QuestionDialog"; -import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; -import { _t } from "../../languageHandler"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import Modal from "../../Modal"; - -const showStopBroadcastingDialog = async (): Promise => { - const { finished } = Modal.createDialog(QuestionDialog, { - title: _t("voice_broadcast|confirm_stop_title"), - description:

{_t("voice_broadcast|confirm_stop_description")}

, - button: _t("voice_broadcast|confirm_stop_affirm"), - }); - const [confirmed] = await finished; - return !!confirmed; -}; - -export const useVoiceBroadcastRecording = ( - recording: VoiceBroadcastRecording, -): { - live: boolean; - timeLeft: number; - recordingState: VoiceBroadcastRecordingState; - room: Room; - sender: RoomMember | null; - stopRecording(): void; - toggleRecording(): void; -} => { - const client = MatrixClientPeg.safeGet(); - const roomId = recording.infoEvent.getRoomId(); - const room = client.getRoom(roomId); - - if (!room) { - throw new Error("Unable to find voice broadcast room with Id: " + roomId); - } - - const sender = recording.infoEvent.sender; - - if (!sender) { - throw new Error(`Voice Broadcast sender not found (event ${recording.infoEvent.getId()})`); - } - - const stopRecording = async (): Promise => { - const confirmed = await showStopBroadcastingDialog(); - - if (confirmed) { - await recording.stop(); - } - }; - - const recordingState = useTypedEventEmitterState( - recording, - VoiceBroadcastRecordingEvent.StateChanged, - (state?: VoiceBroadcastRecordingState) => { - return state ?? recording.getState(); - }, - ); - - const timeLeft = useTypedEventEmitterState( - recording, - VoiceBroadcastRecordingEvent.TimeLeftChanged, - (t?: number) => { - return t ?? recording.getTimeLeft(); - }, - ); - - const live = ( - [VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed] as VoiceBroadcastRecordingState[] - ).includes(recordingState); - - return { - live, - timeLeft, - recordingState, - room, - sender, - stopRecording, - toggleRecording: recording.toggle, - }; -}; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts deleted file mode 100644 index 712c25fdc2..0000000000 --- a/src/voice-broadcast/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -/** - * Voice Broadcast module - * {@link https://github.com/vector-im/element-meta/discussions/632} - */ - -export * from "./types"; -export * from "./models/VoiceBroadcastPlayback"; -export * from "./models/VoiceBroadcastPreRecording"; -export * from "./models/VoiceBroadcastRecording"; -export * from "./audio/VoiceBroadcastRecorder"; -export * from "./components/VoiceBroadcastBody"; -export * from "./components/atoms/LiveBadge"; -export * from "./components/atoms/VoiceBroadcastControl"; -export * from "./components/atoms/VoiceBroadcastError"; -export * from "./components/atoms/VoiceBroadcastHeader"; -export * from "./components/atoms/VoiceBroadcastPlaybackControl"; -export * from "./components/atoms/VoiceBroadcastRecordingConnectionError"; -export * from "./components/atoms/VoiceBroadcastRoomSubtitle"; -export * from "./components/molecules/ConfirmListenBroadcastStopCurrent"; -export * from "./components/molecules/VoiceBroadcastPlaybackBody"; -export * from "./components/molecules/VoiceBroadcastSmallPlaybackBody"; -export * from "./components/molecules/VoiceBroadcastPreRecordingPip"; -export * from "./components/molecules/VoiceBroadcastRecordingBody"; -export * from "./components/molecules/VoiceBroadcastRecordingPip"; -export * from "./hooks/useCurrentVoiceBroadcastPreRecording"; -export * from "./hooks/useCurrentVoiceBroadcastRecording"; -export * from "./hooks/useHasRoomLiveVoiceBroadcast"; -export * from "./hooks/useVoiceBroadcastRecording"; -export * from "./stores/VoiceBroadcastPlaybacksStore"; -export * from "./stores/VoiceBroadcastPreRecordingStore"; -export * from "./stores/VoiceBroadcastRecordingsStore"; -export * from "./utils/checkVoiceBroadcastPreConditions"; -export * from "./utils/cleanUpBroadcasts"; -export * from "./utils/doClearCurrentVoiceBroadcastPlaybackIfStopped"; -export * from "./utils/doMaybeSetCurrentVoiceBroadcastPlayback"; -export * from "./utils/getChunkLength"; -export * from "./utils/getMaxBroadcastLength"; -export * from "./utils/hasRoomLiveVoiceBroadcast"; -export * from "./utils/isRelatedToVoiceBroadcast"; -export * from "./utils/isVoiceBroadcastStartedEvent"; -export * from "./utils/findRoomLiveVoiceBroadcastFromUserAndDevice"; -export * from "./utils/retrieveStartedInfoEvent"; -export * from "./utils/shouldDisplayAsVoiceBroadcastRecordingTile"; -export * from "./utils/shouldDisplayAsVoiceBroadcastTile"; -export * from "./utils/shouldDisplayAsVoiceBroadcastStoppedText"; -export * from "./utils/startNewVoiceBroadcastRecording"; -export * from "./utils/textForVoiceBroadcastStoppedEvent"; -export * from "./utils/textForVoiceBroadcastStoppedEventWithoutLink"; -export * from "./utils/VoiceBroadcastResumer"; diff --git a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts deleted file mode 100644 index ce6215312f..0000000000 --- a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts +++ /dev/null @@ -1,651 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { - EventType, - MatrixClient, - MatrixEvent, - MatrixEventEvent, - MsgType, - RelationType, - TypedEventEmitter, -} from "matrix-js-sdk/src/matrix"; -import { SimpleObservable } from "matrix-widget-api"; -import { logger } from "matrix-js-sdk/src/logger"; -import { defer, IDeferred } from "matrix-js-sdk/src/utils"; - -import { Playback, PlaybackInterface, PlaybackState } from "../../audio/Playback"; -import { PlaybackManager } from "../../audio/PlaybackManager"; -import { UPDATE_EVENT } from "../../stores/AsyncStore"; -import { MediaEventHelper } from "../../utils/MediaEventHelper"; -import { IDestroyable } from "../../utils/IDestroyable"; -import { - VoiceBroadcastLiveness, - VoiceBroadcastInfoEventType, - VoiceBroadcastInfoState, - VoiceBroadcastInfoEventContent, - VoiceBroadcastRecordingsStore, - showConfirmListenBroadcastStopCurrentDialog, -} from ".."; -import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; -import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents"; -import { determineVoiceBroadcastLiveness } from "../utils/determineVoiceBroadcastLiveness"; -import { _t } from "../../languageHandler"; - -export enum VoiceBroadcastPlaybackState { - Paused = "pause", - Playing = "playing", - Stopped = "stopped", - Buffering = "buffering", - Error = "error", -} - -export enum VoiceBroadcastPlaybackEvent { - TimesChanged = "times_changed", - LivenessChanged = "liveness_changed", - StateChanged = "state_changed", - InfoStateChanged = "info_state_changed", -} - -export type VoiceBroadcastPlaybackTimes = { - duration: number; - position: number; - timeLeft: number; -}; - -interface EventMap { - [VoiceBroadcastPlaybackEvent.TimesChanged]: (times: VoiceBroadcastPlaybackTimes) => void; - [VoiceBroadcastPlaybackEvent.LivenessChanged]: (liveness: VoiceBroadcastLiveness) => void; - [VoiceBroadcastPlaybackEvent.StateChanged]: ( - state: VoiceBroadcastPlaybackState, - playback: VoiceBroadcastPlayback, - ) => void; - [VoiceBroadcastPlaybackEvent.InfoStateChanged]: (state: VoiceBroadcastInfoState) => void; -} - -export class VoiceBroadcastPlayback - extends TypedEventEmitter - implements IDestroyable, PlaybackInterface -{ - private state = VoiceBroadcastPlaybackState.Stopped; - private chunkEvents = new VoiceBroadcastChunkEvents(); - /** @var Map: event Id → undecryptable event */ - private utdChunkEvents: Map = new Map(); - private playbacks = new Map(); - private currentlyPlaying: MatrixEvent | null = null; - /** @var total duration of all chunks in milliseconds */ - private duration = 0; - /** @var current playback position in milliseconds */ - private position = 0; - public readonly liveData = new SimpleObservable(); - private liveness: VoiceBroadcastLiveness = "not-live"; - - // set via addInfoEvent() in constructor - private infoState!: VoiceBroadcastInfoState; - private lastInfoEvent!: MatrixEvent; - - // set via setUpRelationsHelper() in constructor - private chunkRelationHelper!: RelationsHelper; - private infoRelationHelper!: RelationsHelper; - - private skipToNext?: number; - private skipToDeferred?: IDeferred; - - public constructor( - public readonly infoEvent: MatrixEvent, - private client: MatrixClient, - private recordings: VoiceBroadcastRecordingsStore, - ) { - super(); - this.addInfoEvent(this.infoEvent); - this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - this.setUpRelationsHelper(); - } - - private async setUpRelationsHelper(): Promise { - this.infoRelationHelper = new RelationsHelper( - this.infoEvent, - RelationType.Reference, - VoiceBroadcastInfoEventType, - this.client, - ); - this.infoRelationHelper.getCurrent().forEach(this.addInfoEvent); - - if (this.infoState !== VoiceBroadcastInfoState.Stopped) { - // Only required if not stopped. Stopped is the final state. - this.infoRelationHelper.on(RelationsHelperEvent.Add, this.addInfoEvent); - - try { - await this.infoRelationHelper.emitFetchCurrent(); - } catch (err) { - logger.warn("error fetching server side relation for voice broadcast info", err); - // fall back to local events - this.infoRelationHelper.emitCurrent(); - } - } - - this.chunkRelationHelper = new RelationsHelper( - this.infoEvent, - RelationType.Reference, - EventType.RoomMessage, - this.client, - ); - this.chunkRelationHelper.on(RelationsHelperEvent.Add, this.addChunkEvent); - - try { - // TODO Michael W: only fetch events if needed, blocked by PSF-1708 - await this.chunkRelationHelper.emitFetchCurrent(); - } catch (err) { - logger.warn("error fetching server side relation for voice broadcast chunks", err); - // fall back to local events - this.chunkRelationHelper.emitCurrent(); - } - } - - private addChunkEvent = async (event: MatrixEvent): Promise => { - if (!event.getId() && !event.getTxnId()) { - // skip events without id and txn id - return false; - } - - if (event.isDecryptionFailure()) { - this.onChunkEventDecryptionFailure(event); - return false; - } - - if (event.getContent()?.msgtype !== MsgType.Audio) { - // skip non-audio event - return false; - } - - this.chunkEvents.addEvent(event); - this.setDuration(this.chunkEvents.getLength()); - - if (this.getState() === VoiceBroadcastPlaybackState.Buffering) { - await this.startOrPlayNext(); - } - - return true; - }; - - private onChunkEventDecryptionFailure = (event: MatrixEvent): void => { - const eventId = event.getId(); - - if (!eventId) { - // This should not happen, as the existence of the Id is checked before the call. - // Log anyway and return. - logger.warn("Broadcast chunk decryption failure for event without Id", { - broadcast: this.infoEvent.getId(), - }); - return; - } - - if (!this.utdChunkEvents.has(eventId)) { - event.once(MatrixEventEvent.Decrypted, this.onChunkEventDecrypted); - } - - this.utdChunkEvents.set(eventId, event); - this.setError(); - }; - - private onChunkEventDecrypted = async (event: MatrixEvent): Promise => { - const eventId = event.getId(); - - if (!eventId) { - // This should not happen, as the existence of the Id is checked before the call. - // Log anyway and return. - logger.warn("Broadcast chunk decrypted for event without Id", { broadcast: this.infoEvent.getId() }); - return; - } - - this.utdChunkEvents.delete(eventId); - await this.addChunkEvent(event); - - if (this.utdChunkEvents.size === 0) { - // no more UTD events, recover from error to paused - this.setState(VoiceBroadcastPlaybackState.Paused); - } - }; - - private startOrPlayNext = async (): Promise => { - if (this.currentlyPlaying) { - return this.playNext(); - } - - return await this.start(); - }; - - private addInfoEvent = (event: MatrixEvent): void => { - if (this.lastInfoEvent && this.lastInfoEvent.getTs() >= event.getTs()) { - // Only handle newer events - return; - } - - const state = event.getContent()?.state; - - if (!Object.values(VoiceBroadcastInfoState).includes(state)) { - // Do not handle unknown voice broadcast states - return; - } - - this.lastInfoEvent = event; - this.setInfoState(state); - }; - - private onBeforeRedaction = (): void => { - if (this.getState() !== VoiceBroadcastPlaybackState.Stopped) { - this.stop(); - // destroy cleans up everything - this.destroy(); - } - }; - - private async tryLoadPlayback(chunkEvent: MatrixEvent): Promise { - try { - return await this.loadPlayback(chunkEvent); - } catch (err: any) { - logger.warn("Unable to load broadcast playback", { - message: err.message, - broadcastId: this.infoEvent.getId(), - chunkId: chunkEvent.getId(), - }); - this.setError(); - } - } - - private async loadPlayback(chunkEvent: MatrixEvent): Promise { - const eventId = chunkEvent.getId(); - - if (!eventId) { - throw new Error("Broadcast chunk event without Id occurred"); - } - - const helper = new MediaEventHelper(chunkEvent); - const blob = await helper.sourceBlob.value; - const buffer = await blob.arrayBuffer(); - const playback = PlaybackManager.instance.createPlaybackInstance(buffer); - await playback.prepare(); - playback.clockInfo.populatePlaceholdersFrom(chunkEvent); - this.playbacks.set(eventId, playback); - playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(chunkEvent, state)); - playback.clockInfo.liveData.onUpdate(([position]) => { - this.onPlaybackPositionUpdate(chunkEvent, position); - }); - } - - private unloadPlayback(event: MatrixEvent): void { - const playback = this.playbacks.get(event.getId()!); - if (!playback) return; - - playback.destroy(); - this.playbacks.delete(event.getId()!); - } - - private onPlaybackPositionUpdate = (event: MatrixEvent, position: number): void => { - if (event !== this.currentlyPlaying) return; - - const newPosition = this.chunkEvents.getLengthTo(event) + position * 1000; // observable sends seconds - - // do not jump backwards - this can happen when transiting from one to another chunk - if (newPosition < this.position) return; - - this.setPosition(newPosition); - }; - - private setDuration(duration: number): void { - if (this.duration === duration) return; - - this.duration = duration; - this.emitTimesChanged(); - this.liveData.update([this.timeSeconds, this.durationSeconds]); - } - - private setPosition(position: number): void { - if (this.position === position) return; - - this.position = position; - this.emitTimesChanged(); - this.liveData.update([this.timeSeconds, this.durationSeconds]); - } - - private emitTimesChanged(): void { - this.emit(VoiceBroadcastPlaybackEvent.TimesChanged, { - duration: this.durationSeconds, - position: this.timeSeconds, - timeLeft: this.timeLeftSeconds, - }); - } - - private onPlaybackStateChange = async (event: MatrixEvent, newState: PlaybackState): Promise => { - if (event !== this.currentlyPlaying) return; - if (newState !== PlaybackState.Stopped) return; - - await this.playNext(); - this.unloadPlayback(event); - }; - - private async playNext(): Promise { - if (!this.currentlyPlaying) return; - - const next = this.chunkEvents.getNext(this.currentlyPlaying); - - if (next) { - return this.playEvent(next); - } - - if ( - this.getInfoState() === VoiceBroadcastInfoState.Stopped && - this.chunkEvents.getSequenceForEvent(this.currentlyPlaying) === this.lastChunkSequence - ) { - this.stop(); - } else { - // No more chunks available, although the broadcast is not finished → enter buffering state. - this.setState(VoiceBroadcastPlaybackState.Buffering); - } - } - - /** - * @returns {number} The last chunk sequence from the latest info event. - * Falls back to the length of received chunks if the info event does not provide the number. - */ - private get lastChunkSequence(): number { - return ( - this.lastInfoEvent.getContent()?.last_chunk_sequence || - this.chunkEvents.getNumberOfEvents() - ); - } - - private async playEvent(event: MatrixEvent): Promise { - this.setState(VoiceBroadcastPlaybackState.Playing); - this.currentlyPlaying = event; - const playback = await this.tryGetOrLoadPlaybackForEvent(event); - playback?.play(); - } - - private async tryGetOrLoadPlaybackForEvent(event: MatrixEvent): Promise { - try { - return await this.getOrLoadPlaybackForEvent(event); - } catch (err: any) { - logger.warn("Unable to load broadcast playback", { - message: err.message, - broadcastId: this.infoEvent.getId(), - chunkId: event.getId(), - }); - this.setError(); - } - } - - private async getOrLoadPlaybackForEvent(event: MatrixEvent): Promise { - const eventId = event.getId(); - - if (!eventId) { - throw new Error("Broadcast chunk event without Id occurred"); - } - - if (!this.playbacks.has(eventId)) { - // set to buffering while loading the chunk data - const currentState = this.getState(); - this.setState(VoiceBroadcastPlaybackState.Buffering); - await this.loadPlayback(event); - this.setState(currentState); - } - - const playback = this.playbacks.get(eventId); - - if (!playback) { - throw new Error(`Unable to find playback for event ${event.getId()}`); - } - - // try to load the playback for the next event for a smooth(er) playback - const nextEvent = this.chunkEvents.getNext(event); - if (nextEvent) this.tryLoadPlayback(nextEvent); - - return playback; - } - - private getCurrentPlayback(): Playback | undefined { - if (!this.currentlyPlaying) return; - return this.playbacks.get(this.currentlyPlaying.getId()!); - } - - public getLiveness(): VoiceBroadcastLiveness { - return this.liveness; - } - - private setLiveness(liveness: VoiceBroadcastLiveness): void { - if (this.liveness === liveness) return; - - this.liveness = liveness; - this.emit(VoiceBroadcastPlaybackEvent.LivenessChanged, liveness); - } - - public get currentState(): PlaybackState { - return PlaybackState.Playing; - } - - public get timeSeconds(): number { - return this.position / 1000; - } - - public get durationSeconds(): number { - return this.duration / 1000; - } - - public get timeLeftSeconds(): number { - // Sometimes the meta data and the audio files are a little bit out of sync. - // Be sure it never returns a negative value. - return Math.max(0, Math.round(this.durationSeconds) - this.timeSeconds); - } - - public async skipTo(timeSeconds: number): Promise { - this.skipToNext = timeSeconds; - - if (this.skipToDeferred) { - // Skip to position is already in progress. Return the promise for that. - return this.skipToDeferred.promise; - } - - this.skipToDeferred = defer(); - - while (this.skipToNext !== undefined) { - // Skip to position until skipToNext is undefined. - // skipToNext can be set if skipTo is called while already skipping. - const skipToNext = this.skipToNext; - this.skipToNext = undefined; - await this.doSkipTo(skipToNext); - } - - this.skipToDeferred.resolve(); - this.skipToDeferred = undefined; - } - - private async doSkipTo(timeSeconds: number): Promise { - const time = timeSeconds * 1000; - const event = this.chunkEvents.findByTime(time); - - if (!event) { - logger.warn("voice broadcast chunk event to skip to not found"); - return; - } - - const currentPlayback = this.getCurrentPlayback(); - const skipToPlayback = await this.tryGetOrLoadPlaybackForEvent(event); - const currentPlaybackEvent = this.currentlyPlaying; - - if (!skipToPlayback) { - logger.warn("voice broadcast chunk to skip to not found", event); - return; - } - - this.currentlyPlaying = event; - - if (currentPlayback && currentPlaybackEvent && currentPlayback !== skipToPlayback) { - // only stop and unload the playback here without triggering other effects, e.g. play next - currentPlayback.off(UPDATE_EVENT, this.onPlaybackStateChange); - await currentPlayback.stop(); - currentPlayback.on(UPDATE_EVENT, this.onPlaybackStateChange); - this.unloadPlayback(currentPlaybackEvent); - } - - const offsetInChunk = time - this.chunkEvents.getLengthTo(event); - await skipToPlayback.skipTo(offsetInChunk / 1000); - - if (this.state === VoiceBroadcastPlaybackState.Playing && !skipToPlayback.isPlaying) { - await skipToPlayback.play(); - } - - this.setPosition(time); - } - - public async start(): Promise { - if (this.state === VoiceBroadcastPlaybackState.Playing) return; - - const currentRecording = this.recordings.getCurrent(); - - if (currentRecording && currentRecording.getState() !== VoiceBroadcastInfoState.Stopped) { - const shouldStopRecording = await showConfirmListenBroadcastStopCurrentDialog(); - - if (!shouldStopRecording) { - // keep recording - return; - } - - await this.recordings.getCurrent()?.stop(); - } - - const chunkEvents = this.chunkEvents.getEvents(); - - const toPlay = - this.getInfoState() === VoiceBroadcastInfoState.Stopped - ? chunkEvents[0] // start at the beginning for an ended voice broadcast - : chunkEvents[chunkEvents.length - 1]; // start at the current chunk for an ongoing voice broadcast - - if (toPlay) { - return this.playEvent(toPlay); - } - - this.setState(VoiceBroadcastPlaybackState.Buffering); - } - - public stop(): void { - // error is a final state - if (this.getState() === VoiceBroadcastPlaybackState.Error) return; - - this.setState(VoiceBroadcastPlaybackState.Stopped); - this.getCurrentPlayback()?.stop(); - this.currentlyPlaying = null; - this.setPosition(0); - } - - public pause(): void { - // error is a final state - if (this.getState() === VoiceBroadcastPlaybackState.Error) return; - - // stopped voice broadcasts cannot be paused - if (this.getState() === VoiceBroadcastPlaybackState.Stopped) return; - - this.setState(VoiceBroadcastPlaybackState.Paused); - this.getCurrentPlayback()?.pause(); - } - - public resume(): void { - // error is a final state - if (this.getState() === VoiceBroadcastPlaybackState.Error) return; - - if (!this.currentlyPlaying) { - // no playback to resume, start from the beginning - this.start(); - return; - } - - this.setState(VoiceBroadcastPlaybackState.Playing); - this.getCurrentPlayback()?.play(); - } - - /** - * Toggles the playback: - * stopped → playing - * playing → paused - * paused → playing - */ - public async toggle(): Promise { - // error is a final state - if (this.getState() === VoiceBroadcastPlaybackState.Error) return; - - if (this.state === VoiceBroadcastPlaybackState.Stopped) { - await this.start(); - return; - } - - if (this.state === VoiceBroadcastPlaybackState.Paused) { - this.resume(); - return; - } - - this.pause(); - } - - public getState(): VoiceBroadcastPlaybackState { - return this.state; - } - - private setState(state: VoiceBroadcastPlaybackState): void { - if (this.state === state) { - return; - } - - this.state = state; - this.emit(VoiceBroadcastPlaybackEvent.StateChanged, state, this); - } - - /** - * Set error state. Stop current playback, if any. - */ - private setError(): void { - this.setState(VoiceBroadcastPlaybackState.Error); - this.getCurrentPlayback()?.stop(); - this.currentlyPlaying = null; - this.setPosition(0); - } - - public getInfoState(): VoiceBroadcastInfoState { - return this.infoState; - } - - private setInfoState(state: VoiceBroadcastInfoState): void { - if (this.infoState === state) { - return; - } - - this.infoState = state; - this.emit(VoiceBroadcastPlaybackEvent.InfoStateChanged, state); - this.setLiveness(determineVoiceBroadcastLiveness(this.infoState)); - } - - public get errorMessage(): string { - if (this.getState() !== VoiceBroadcastPlaybackState.Error) return ""; - if (this.utdChunkEvents.size) return _t("voice_broadcast|failed_decrypt"); - return _t("voice_broadcast|failed_generic"); - } - - public destroy(): void { - for (const [, utdEvent] of this.utdChunkEvents) { - utdEvent.off(MatrixEventEvent.Decrypted, this.onChunkEventDecrypted); - } - - this.utdChunkEvents.clear(); - - this.chunkRelationHelper.destroy(); - this.infoRelationHelper.destroy(); - this.removeAllListeners(); - - this.chunkEvents = new VoiceBroadcastChunkEvents(); - this.playbacks.forEach((p) => p.destroy()); - this.playbacks = new Map(); - } -} diff --git a/src/voice-broadcast/models/VoiceBroadcastPreRecording.ts b/src/voice-broadcast/models/VoiceBroadcastPreRecording.ts deleted file mode 100644 index 0cf47c6f21..0000000000 --- a/src/voice-broadcast/models/VoiceBroadcastPreRecording.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, RoomMember, TypedEventEmitter } from "matrix-js-sdk/src/matrix"; - -import { IDestroyable } from "../../utils/IDestroyable"; -import { VoiceBroadcastPlaybacksStore } from "../stores/VoiceBroadcastPlaybacksStore"; -import { VoiceBroadcastRecordingsStore } from "../stores/VoiceBroadcastRecordingsStore"; -import { startNewVoiceBroadcastRecording } from "../utils/startNewVoiceBroadcastRecording"; - -type VoiceBroadcastPreRecordingEvent = "dismiss"; - -interface EventMap { - dismiss: (voiceBroadcastPreRecording: VoiceBroadcastPreRecording) => void; -} - -export class VoiceBroadcastPreRecording - extends TypedEventEmitter - implements IDestroyable -{ - public constructor( - public room: Room, - public sender: RoomMember, - private client: MatrixClient, - private playbacksStore: VoiceBroadcastPlaybacksStore, - private recordingsStore: VoiceBroadcastRecordingsStore, - ) { - super(); - } - - public start = async (): Promise => { - await startNewVoiceBroadcastRecording(this.room, this.client, this.playbacksStore, this.recordingsStore); - this.emit("dismiss", this); - }; - - public cancel = (): void => { - this.emit("dismiss", this); - }; - - public destroy(): void { - this.removeAllListeners(); - } -} diff --git a/src/voice-broadcast/models/VoiceBroadcastRecording.ts b/src/voice-broadcast/models/VoiceBroadcastRecording.ts deleted file mode 100644 index ebf8ee697f..0000000000 --- a/src/voice-broadcast/models/VoiceBroadcastRecording.ts +++ /dev/null @@ -1,441 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { logger } from "matrix-js-sdk/src/logger"; -import { - ClientEvent, - ClientEventHandlerMap, - EventType, - MatrixClient, - MatrixEvent, - MatrixEventEvent, - MsgType, - RelationType, - TypedEventEmitter, -} from "matrix-js-sdk/src/matrix"; -import { AudioContent, EncryptedFile } from "matrix-js-sdk/src/types"; - -import { - ChunkRecordedPayload, - createVoiceBroadcastRecorder, - getMaxBroadcastLength, - VoiceBroadcastInfoEventContent, - VoiceBroadcastInfoEventType, - VoiceBroadcastInfoState, - VoiceBroadcastRecorder, - VoiceBroadcastRecorderEvent, -} from ".."; -import { uploadFile } from "../../ContentMessages"; -import { createVoiceMessageContent } from "../../utils/createVoiceMessageContent"; -import { IDestroyable } from "../../utils/IDestroyable"; -import dis from "../../dispatcher/dispatcher"; -import { ActionPayload } from "../../dispatcher/payloads"; -import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents"; -import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; -import { createReconnectedListener } from "../../utils/connection"; -import { localNotificationsAreSilenced } from "../../utils/notifications"; -import { BackgroundAudio } from "../../audio/BackgroundAudio"; - -export enum VoiceBroadcastRecordingEvent { - StateChanged = "liveness_changed", - TimeLeftChanged = "time_left_changed", -} - -export type VoiceBroadcastRecordingState = VoiceBroadcastInfoState | "connection_error"; - -interface EventMap { - [VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastRecordingState) => void; - [VoiceBroadcastRecordingEvent.TimeLeftChanged]: (timeLeft: number) => void; -} - -export class VoiceBroadcastRecording - extends TypedEventEmitter - implements IDestroyable -{ - private state: VoiceBroadcastRecordingState; - private recorder: VoiceBroadcastRecorder | null = null; - private dispatcherRef: string; - private chunkEvents = new VoiceBroadcastChunkEvents(); - private chunkRelationHelper: RelationsHelper; - private maxLength: number; - private timeLeft: number; - private toRetry: Array<() => Promise> = []; - private reconnectedListener: ClientEventHandlerMap[ClientEvent.Sync]; - private roomId: string; - private infoEventId: string; - private backgroundAudio = new BackgroundAudio(); - - /** - * Broadcast chunks have a sequence number to bring them in the correct order and to know if a message is missing. - * This variable holds the last sequence number. - * Starts with 0 because there is no chunk at the beginning of a broadcast. - * Will be incremented when a chunk message is created. - */ - private sequence = 0; - - public constructor( - public readonly infoEvent: MatrixEvent, - private client: MatrixClient, - initialState?: VoiceBroadcastInfoState, - ) { - super(); - this.maxLength = getMaxBroadcastLength(); - this.timeLeft = this.maxLength; - this.infoEventId = this.determineEventIdFromInfoEvent(); - this.roomId = this.determineRoomIdFromInfoEvent(); - - if (initialState) { - this.state = initialState; - } else { - this.state = this.determineInitialStateFromInfoEvent(); - } - - // TODO Michael W: listen for state updates - - this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - this.dispatcherRef = dis.register(this.onAction); - this.chunkRelationHelper = this.initialiseChunkEventRelation(); - this.reconnectedListener = createReconnectedListener(this.onReconnect); - this.client.on(ClientEvent.Sync, this.reconnectedListener); - } - - private initialiseChunkEventRelation(): RelationsHelper { - const relationsHelper = new RelationsHelper( - this.infoEvent, - RelationType.Reference, - EventType.RoomMessage, - this.client, - ); - relationsHelper.on(RelationsHelperEvent.Add, this.onChunkEvent); - - relationsHelper.emitFetchCurrent().catch((err) => { - logger.warn("error fetching server side relation for voice broadcast chunks", err); - // fall back to local events - relationsHelper.emitCurrent(); - }); - - return relationsHelper; - } - - private onChunkEvent = (event: MatrixEvent): void => { - if ( - (!event.getId() && !event.getTxnId()) || - event.getContent()?.msgtype !== MsgType.Audio // don't add non-audio event - ) { - return; - } - - this.chunkEvents.addEvent(event); - }; - - private determineEventIdFromInfoEvent(): string { - const infoEventId = this.infoEvent.getId(); - - if (!infoEventId) { - throw new Error("Cannot create broadcast for info event without Id."); - } - - return infoEventId; - } - - private determineRoomIdFromInfoEvent(): string { - const roomId = this.infoEvent.getRoomId(); - - if (!roomId) { - throw new Error(`Cannot create broadcast for unknown room (info event ${this.infoEventId})`); - } - - return roomId; - } - - /** - * Determines the initial broadcast state. - * Checks all related events. If one has the "stopped" state → stopped, else started. - */ - private determineInitialStateFromInfoEvent(): VoiceBroadcastRecordingState { - const room = this.client.getRoom(this.roomId); - const relations = room - ?.getUnfilteredTimelineSet() - ?.relations?.getChildEventsForEvent(this.infoEventId, RelationType.Reference, VoiceBroadcastInfoEventType); - const relatedEvents = relations?.getRelations(); - return !relatedEvents?.find((event: MatrixEvent) => { - return event.getContent()?.state === VoiceBroadcastInfoState.Stopped; - }) - ? VoiceBroadcastInfoState.Started - : VoiceBroadcastInfoState.Stopped; - } - - public getTimeLeft(): number { - return this.timeLeft; - } - - /** - * Retries failed actions on reconnect. - */ - private onReconnect = async (): Promise => { - // Do nothing if not in connection_error state. - if (this.state !== "connection_error") return; - - // Copy the array, so that it is possible to remove elements from it while iterating over the original. - const toRetryCopy = [...this.toRetry]; - - for (const retryFn of this.toRetry) { - try { - await retryFn(); - // Successfully retried. Remove from array copy. - toRetryCopy.splice(toRetryCopy.indexOf(retryFn), 1); - } catch { - // The current retry callback failed. Stop the loop. - break; - } - } - - this.toRetry = toRetryCopy; - - if (this.toRetry.length === 0) { - // Everything has been successfully retried. Recover from error state to paused. - await this.pause(); - } - }; - - private async setTimeLeft(timeLeft: number): Promise { - if (timeLeft <= 0) { - // time is up - stop the recording - return await this.stop(); - } - - // do never increase time left; no action if equals - if (timeLeft >= this.timeLeft) return; - - this.timeLeft = timeLeft; - this.emit(VoiceBroadcastRecordingEvent.TimeLeftChanged, timeLeft); - } - - public async start(): Promise { - return this.getRecorder().start(); - } - - public async stop(): Promise { - if (this.state === VoiceBroadcastInfoState.Stopped) return; - - this.setState(VoiceBroadcastInfoState.Stopped); - await this.stopRecorder(); - await this.sendInfoStateEvent(VoiceBroadcastInfoState.Stopped); - } - - public async pause(): Promise { - // stopped or already paused recordings cannot be paused - if ( - ( - [VoiceBroadcastInfoState.Stopped, VoiceBroadcastInfoState.Paused] as VoiceBroadcastRecordingState[] - ).includes(this.state) - ) - return; - - this.setState(VoiceBroadcastInfoState.Paused); - await this.stopRecorder(); - await this.sendInfoStateEvent(VoiceBroadcastInfoState.Paused); - } - - public async resume(): Promise { - if (this.state !== VoiceBroadcastInfoState.Paused) return; - - this.setState(VoiceBroadcastInfoState.Resumed); - await this.getRecorder().start(); - await this.sendInfoStateEvent(VoiceBroadcastInfoState.Resumed); - } - - public toggle = async (): Promise => { - if (this.getState() === VoiceBroadcastInfoState.Paused) return this.resume(); - - if ( - ( - [VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed] as VoiceBroadcastRecordingState[] - ).includes(this.getState()) - ) { - return this.pause(); - } - }; - - public getState(): VoiceBroadcastRecordingState { - return this.state; - } - - private getRecorder(): VoiceBroadcastRecorder { - if (!this.recorder) { - this.recorder = createVoiceBroadcastRecorder(); - this.recorder.on(VoiceBroadcastRecorderEvent.ChunkRecorded, this.onChunkRecorded); - this.recorder.on(VoiceBroadcastRecorderEvent.CurrentChunkLengthUpdated, this.onCurrentChunkLengthUpdated); - } - - return this.recorder; - } - - public async destroy(): Promise { - if (this.recorder) { - this.recorder.stop(); - this.recorder.destroy(); - } - - this.infoEvent.off(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - this.removeAllListeners(); - dis.unregister(this.dispatcherRef); - this.chunkEvents = new VoiceBroadcastChunkEvents(); - this.chunkRelationHelper.destroy(); - this.client.off(ClientEvent.Sync, this.reconnectedListener); - } - - private onBeforeRedaction = (): void => { - if (this.getState() !== VoiceBroadcastInfoState.Stopped) { - this.setState(VoiceBroadcastInfoState.Stopped); - // destroy cleans up everything - this.destroy(); - } - }; - - private onAction = (payload: ActionPayload): void => { - if (payload.action !== "call_state") return; - - // pause on any call action - this.pause(); - }; - - private setState(state: VoiceBroadcastRecordingState): void { - this.state = state; - this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state); - } - - private onCurrentChunkLengthUpdated = (currentChunkLength: number): void => { - this.setTimeLeft(this.maxLength - this.chunkEvents.getLengthSeconds() - currentChunkLength); - }; - - private onChunkRecorded = async (chunk: ChunkRecordedPayload): Promise => { - const uploadAndSendFn = async (): Promise => { - const { url, file } = await this.uploadFile(chunk); - await this.sendVoiceMessage(chunk, url, file); - }; - - await this.callWithRetry(uploadAndSendFn); - }; - - /** - * This function is called on connection errors. - * It sets the connection error state and stops the recorder. - */ - private async onConnectionError(): Promise { - this.playConnectionErrorAudioNotification().catch(() => { - // Error logged in playConnectionErrorAudioNotification(). - }); - await this.stopRecorder(false); - this.setState("connection_error"); - } - - private async playConnectionErrorAudioNotification(): Promise { - if (localNotificationsAreSilenced(this.client)) { - return; - } - - await this.backgroundAudio.pickFormatAndPlay("./media/error", ["mp3", "ogg"]); - } - - private async uploadFile(chunk: ChunkRecordedPayload): ReturnType { - return uploadFile( - this.client, - this.roomId, - new Blob([chunk.buffer], { - type: this.getRecorder().contentType, - }), - ); - } - - private async sendVoiceMessage(chunk: ChunkRecordedPayload, url?: string, file?: EncryptedFile): Promise { - /** - * Increment the last sequence number and use it for this message. - * Done outside of the sendMessageFn to get a scoped value. - * Also see {@link VoiceBroadcastRecording.sequence}. - */ - const sequence = ++this.sequence; - - const sendMessageFn = async (): Promise => { - const content = createVoiceMessageContent( - url, - this.getRecorder().contentType, - Math.round(chunk.length * 1000), - chunk.buffer.length, - file, - ); - content["m.relates_to"] = { - rel_type: RelationType.Reference, - event_id: this.infoEventId, - }; - (content)["io.element.voice_broadcast_chunk"] = { - sequence, - }; - - await this.client.sendMessage(this.roomId, content); - }; - - await this.callWithRetry(sendMessageFn); - } - - /** - * Sends an info state event with given state. - * On error stores a resend function and setState(state) in {@link toRetry} and - * sets the broadcast state to connection_error. - */ - private async sendInfoStateEvent(state: VoiceBroadcastInfoState): Promise { - const sendEventFn = async (): Promise => { - await this.client.sendStateEvent( - this.roomId, - VoiceBroadcastInfoEventType, - { - device_id: this.client.getDeviceId(), - state, - last_chunk_sequence: this.sequence, - ["m.relates_to"]: { - rel_type: RelationType.Reference, - event_id: this.infoEventId, - }, - } as VoiceBroadcastInfoEventContent, - this.client.getSafeUserId(), - ); - }; - - await this.callWithRetry(sendEventFn); - } - - /** - * Calls the function. - * On failure adds it to the retry list and triggers connection error. - * {@link toRetry} - * {@link onConnectionError} - */ - private async callWithRetry(retryAbleFn: () => Promise): Promise { - try { - await retryAbleFn(); - } catch { - this.toRetry.push(retryAbleFn); - this.onConnectionError(); - } - } - - private async stopRecorder(emit = true): Promise { - if (!this.recorder) { - return; - } - - try { - const lastChunk = await this.recorder.stop(); - if (lastChunk && emit) { - await this.onChunkRecorded(lastChunk); - } - } catch (err) { - logger.warn("error stopping voice broadcast recorder", err); - } - } -} diff --git a/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts b/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts deleted file mode 100644 index 69b8c21d90..0000000000 --- a/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, MatrixEvent, TypedEventEmitter } from "matrix-js-sdk/src/matrix"; - -import { - VoiceBroadcastPlayback, - VoiceBroadcastPlaybackEvent, - VoiceBroadcastPlaybackState, - VoiceBroadcastRecordingsStore, -} from ".."; -import { IDestroyable } from "../../utils/IDestroyable"; - -export enum VoiceBroadcastPlaybacksStoreEvent { - CurrentChanged = "current_changed", -} - -interface EventMap { - [VoiceBroadcastPlaybacksStoreEvent.CurrentChanged]: (recording: VoiceBroadcastPlayback | null) => void; -} - -/** - * This store manages VoiceBroadcastPlaybacks: - * - access the currently playing voice broadcast - * - ensures that only once broadcast is playing at a time - */ -export class VoiceBroadcastPlaybacksStore - extends TypedEventEmitter - implements IDestroyable -{ - private current: VoiceBroadcastPlayback | null = null; - - /** Playbacks indexed by their info event id. */ - private playbacks = new Map(); - - public constructor(private recordings: VoiceBroadcastRecordingsStore) { - super(); - } - - public setCurrent(current: VoiceBroadcastPlayback): void { - if (this.current === current) return; - - this.current = current; - this.addPlayback(current); - this.emit(VoiceBroadcastPlaybacksStoreEvent.CurrentChanged, current); - } - - public clearCurrent(): void { - if (this.current === null) return; - - this.current = null; - this.emit(VoiceBroadcastPlaybacksStoreEvent.CurrentChanged, null); - } - - public getCurrent(): VoiceBroadcastPlayback | null { - return this.current; - } - - public getByInfoEvent(infoEvent: MatrixEvent, client: MatrixClient): VoiceBroadcastPlayback { - const infoEventId = infoEvent.getId()!; - - if (!this.playbacks.has(infoEventId)) { - this.addPlayback(new VoiceBroadcastPlayback(infoEvent, client, this.recordings)); - } - - return this.playbacks.get(infoEventId)!; - } - - private addPlayback(playback: VoiceBroadcastPlayback): void { - const infoEventId = playback.infoEvent.getId()!; - - if (this.playbacks.has(infoEventId)) return; - - this.playbacks.set(infoEventId, playback); - playback.on(VoiceBroadcastPlaybackEvent.StateChanged, this.onPlaybackStateChanged); - } - - private onPlaybackStateChanged = (state: VoiceBroadcastPlaybackState, playback: VoiceBroadcastPlayback): void => { - switch (state) { - case VoiceBroadcastPlaybackState.Buffering: - case VoiceBroadcastPlaybackState.Playing: - this.pauseExcept(playback); - this.setCurrent(playback); - break; - case VoiceBroadcastPlaybackState.Stopped: - this.clearCurrent(); - break; - } - }; - - private pauseExcept(playbackNotToPause: VoiceBroadcastPlayback): void { - for (const playback of this.playbacks.values()) { - if (playback !== playbackNotToPause) { - playback.pause(); - } - } - } - - public destroy(): void { - this.removeAllListeners(); - - for (const playback of this.playbacks.values()) { - playback.off(VoiceBroadcastPlaybackEvent.StateChanged, this.onPlaybackStateChanged); - } - - this.playbacks = new Map(); - } -} diff --git a/src/voice-broadcast/stores/VoiceBroadcastPreRecordingStore.ts b/src/voice-broadcast/stores/VoiceBroadcastPreRecordingStore.ts deleted file mode 100644 index 3552930687..0000000000 --- a/src/voice-broadcast/stores/VoiceBroadcastPreRecordingStore.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastPreRecording } from ".."; -import { IDestroyable } from "../../utils/IDestroyable"; - -export type VoiceBroadcastPreRecordingEvent = "changed"; - -interface EventMap { - changed: (preRecording: VoiceBroadcastPreRecording | null) => void; -} - -export class VoiceBroadcastPreRecordingStore - extends TypedEventEmitter - implements IDestroyable -{ - private current: VoiceBroadcastPreRecording | null = null; - - public setCurrent(current: VoiceBroadcastPreRecording): void { - if (this.current === current) return; - - if (this.current) { - this.current.off("dismiss", this.onCancel); - } - - this.current = current; - current.on("dismiss", this.onCancel); - this.emit("changed", current); - } - - public clearCurrent(): void { - if (this.current === null) return; - - this.current.off("dismiss", this.onCancel); - this.current = null; - this.emit("changed", null); - } - - public getCurrent(): VoiceBroadcastPreRecording | null { - return this.current; - } - - public destroy(): void { - this.removeAllListeners(); - - if (this.current) { - this.current.off("dismiss", this.onCancel); - } - } - - private onCancel = (voiceBroadcastPreRecording: VoiceBroadcastPreRecording): void => { - if (this.current === voiceBroadcastPreRecording) { - this.clearCurrent(); - } - }; -} diff --git a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts deleted file mode 100644 index ff0f67b910..0000000000 --- a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022, 2023 The Matrix.org Foundation C.I.C. - -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, MatrixEvent, TypedEventEmitter } from "matrix-js-sdk/src/matrix"; - -import { - VoiceBroadcastInfoState, - VoiceBroadcastRecording, - VoiceBroadcastRecordingEvent, - VoiceBroadcastRecordingState, -} from ".."; - -export enum VoiceBroadcastRecordingsStoreEvent { - CurrentChanged = "current_changed", -} - -interface EventMap { - [VoiceBroadcastRecordingsStoreEvent.CurrentChanged]: (recording: VoiceBroadcastRecording | null) => void; -} - -/** - * This store provides access to the current and specific Voice Broadcast recordings. - */ -export class VoiceBroadcastRecordingsStore extends TypedEventEmitter { - private current: VoiceBroadcastRecording | null = null; - private recordings = new Map(); - - public constructor() { - super(); - } - - public setCurrent(current: VoiceBroadcastRecording): void { - if (this.current === current) return; - - const infoEventId = current.infoEvent.getId(); - - if (!infoEventId) { - throw new Error("Got broadcast info event without Id"); - } - - if (this.current) { - this.current.off(VoiceBroadcastRecordingEvent.StateChanged, this.onCurrentStateChanged); - } - - this.current = current; - this.current.on(VoiceBroadcastRecordingEvent.StateChanged, this.onCurrentStateChanged); - this.recordings.set(infoEventId, current); - this.emit(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, current); - } - - public getCurrent(): VoiceBroadcastRecording | null { - return this.current; - } - - public hasCurrent(): boolean { - return this.current !== null; - } - - public clearCurrent(): void { - if (!this.current) return; - - this.current.off(VoiceBroadcastRecordingEvent.StateChanged, this.onCurrentStateChanged); - this.current = null; - this.emit(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, null); - } - - public getByInfoEvent(infoEvent: MatrixEvent, client: MatrixClient): VoiceBroadcastRecording { - const infoEventId = infoEvent.getId(); - - if (!infoEventId) { - throw new Error("Got broadcast info event without Id"); - } - - const recording = this.recordings.get(infoEventId) || new VoiceBroadcastRecording(infoEvent, client); - this.recordings.set(infoEventId, recording); - return recording; - } - - private onCurrentStateChanged = (state: VoiceBroadcastRecordingState): void => { - if (state === VoiceBroadcastInfoState.Stopped) { - this.clearCurrent(); - } - }; -} diff --git a/src/voice-broadcast/types.ts b/src/voice-broadcast/types.ts deleted file mode 100644 index 8191a0be16..0000000000 --- a/src/voice-broadcast/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { RelationType } from "matrix-js-sdk/src/matrix"; - -export const VoiceBroadcastInfoEventType = "io.element.voice_broadcast_info"; -export const VoiceBroadcastChunkEventType = "io.element.voice_broadcast_chunk"; - -export type VoiceBroadcastLiveness = "live" | "not-live" | "grey"; - -export enum VoiceBroadcastInfoState { - Started = "started", - Paused = "paused", - Resumed = "resumed", - Stopped = "stopped", -} - -export interface VoiceBroadcastInfoEventContent { - device_id: string; - state: VoiceBroadcastInfoState; - chunk_length?: number; - last_chunk_sequence?: number; - ["m.relates_to"]?: { - rel_type: RelationType; - event_id: string; - }; -} diff --git a/src/voice-broadcast/utils/VoiceBroadcastChunkEvents.ts b/src/voice-broadcast/utils/VoiceBroadcastChunkEvents.ts deleted file mode 100644 index 039749cf8d..0000000000 --- a/src/voice-broadcast/utils/VoiceBroadcastChunkEvents.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastChunkEventType } from ".."; - -/** - * Voice broadcast chunk collection. - * Orders chunks by sequence (if available) or timestamp. - */ -export class VoiceBroadcastChunkEvents { - private events: MatrixEvent[] = []; - - public getEvents(): MatrixEvent[] { - return [...this.events]; - } - - public getNext(event: MatrixEvent): MatrixEvent | undefined { - return this.events[this.events.indexOf(event) + 1]; - } - - public addEvent(event: MatrixEvent): void { - if (this.addOrReplaceEvent(event)) { - this.sort(); - } - } - - public addEvents(events: MatrixEvent[]): void { - const atLeastOneNew = events.reduce((newSoFar: boolean, event: MatrixEvent): boolean => { - return this.addOrReplaceEvent(event) || newSoFar; - }, false); - - if (atLeastOneNew) { - this.sort(); - } - } - - public includes(event: MatrixEvent): boolean { - return !!this.events.find((e) => this.equalByTxnIdOrId(event, e)); - } - - /** - * @returns {number} Length in milliseconds - */ - public getLength(): number { - return this.events.reduce((length: number, event: MatrixEvent) => { - return length + this.calculateChunkLength(event); - }, 0); - } - - public getLengthSeconds(): number { - return this.getLength() / 1000; - } - - /** - * Returns the accumulated length to (excl.) a chunk event. - */ - public getLengthTo(event: MatrixEvent): number { - let length = 0; - - for (let i = 0; i < this.events.indexOf(event); i++) { - length += this.calculateChunkLength(this.events[i]); - } - - return length; - } - - public findByTime(time: number): MatrixEvent | null { - let lengthSoFar = 0; - - for (let i = 0; i < this.events.length; i++) { - lengthSoFar += this.calculateChunkLength(this.events[i]); - - if (lengthSoFar >= time) { - return this.events[i]; - } - } - - return null; - } - - public isLast(event: MatrixEvent): boolean { - return this.events.indexOf(event) >= this.events.length - 1; - } - - public getSequenceForEvent(event: MatrixEvent): number | null { - const sequence = parseInt(event.getContent()?.[VoiceBroadcastChunkEventType]?.sequence, 10); - if (!isNaN(sequence)) return sequence; - - if (this.events.includes(event)) return this.events.indexOf(event) + 1; - - return null; - } - - public getNumberOfEvents(): number { - return this.events.length; - } - - private calculateChunkLength(event: MatrixEvent): number { - return event.getContent()?.["org.matrix.msc1767.audio"]?.duration || event.getContent()?.info?.duration || 0; - } - - private addOrReplaceEvent = (event: MatrixEvent): boolean => { - this.events = this.events.filter((e) => !this.equalByTxnIdOrId(event, e)); - this.events.push(event); - return true; - }; - - private equalByTxnIdOrId(eventA: MatrixEvent, eventB: MatrixEvent): boolean { - return ( - (eventA.getTxnId() && eventB.getTxnId() && eventA.getTxnId() === eventB.getTxnId()) || - eventA.getId() === eventB.getId() - ); - } - - /** - * Sort by sequence, if available for all events. - * Else fall back to timestamp. - */ - private sort(): void { - const compareFn = this.allHaveSequence() ? this.compareBySequence : this.compareByTimestamp; - this.events.sort(compareFn); - } - - private compareBySequence = (a: MatrixEvent, b: MatrixEvent): number => { - const aSequence = a.getContent()?.[VoiceBroadcastChunkEventType]?.sequence || 0; - const bSequence = b.getContent()?.[VoiceBroadcastChunkEventType]?.sequence || 0; - return aSequence - bSequence; - }; - - private compareByTimestamp = (a: MatrixEvent, b: MatrixEvent): number => { - return a.getTs() - b.getTs(); - }; - - private allHaveSequence(): boolean { - return !this.events.some((event: MatrixEvent) => { - const sequence = event.getContent()?.[VoiceBroadcastChunkEventType]?.sequence; - return parseInt(sequence, 10) !== sequence; - }); - } -} diff --git a/src/voice-broadcast/utils/VoiceBroadcastResumer.ts b/src/voice-broadcast/utils/VoiceBroadcastResumer.ts deleted file mode 100644 index 963b6ef3a6..0000000000 --- a/src/voice-broadcast/utils/VoiceBroadcastResumer.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { ClientEvent, MatrixClient, MatrixEvent, RelationType, Room, SyncState } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; -import { IDestroyable } from "../../utils/IDestroyable"; -import { findRoomLiveVoiceBroadcastFromUserAndDevice } from "./findRoomLiveVoiceBroadcastFromUserAndDevice"; - -/** - * Handles voice broadcasts on app resume (after logging in, reload, crash…). - */ -export class VoiceBroadcastResumer implements IDestroyable { - public constructor(private client: MatrixClient) { - if (client.isInitialSyncComplete()) { - this.resume(); - } else { - // wait for initial sync - client.on(ClientEvent.Sync, this.onClientSync); - } - } - - private onClientSync = (): void => { - if (this.client.getSyncState() === SyncState.Syncing) { - this.client.off(ClientEvent.Sync, this.onClientSync); - this.resume(); - } - }; - - private resume(): void { - const userId = this.client.getUserId(); - const deviceId = this.client.getDeviceId(); - - if (!userId || !deviceId) { - // Resuming a voice broadcast only makes sense if there is a user. - return; - } - - this.client.getRooms().forEach((room: Room) => { - const infoEvent = findRoomLiveVoiceBroadcastFromUserAndDevice(room, userId, deviceId); - - if (infoEvent) { - // Found a live broadcast event from current device; stop it. - // Stopping it is a temporary solution (see PSF-1669). - this.sendStopVoiceBroadcastStateEvent(infoEvent); - return false; - } - }); - } - - private sendStopVoiceBroadcastStateEvent(infoEvent: MatrixEvent): void { - const userId = this.client.getUserId(); - const deviceId = this.client.getDeviceId(); - const roomId = infoEvent.getRoomId(); - - if (!userId || !deviceId || !roomId) { - // We can only send a state event if we know all the IDs. - return; - } - - const content: VoiceBroadcastInfoEventContent = { - device_id: deviceId, - state: VoiceBroadcastInfoState.Stopped, - }; - - // all events should reference the started event - const referencedEventId = - infoEvent.getContent()?.state === VoiceBroadcastInfoState.Started - ? infoEvent.getId() - : infoEvent.getContent()?.["m.relates_to"]?.event_id; - - if (referencedEventId) { - content["m.relates_to"] = { - rel_type: RelationType.Reference, - event_id: referencedEventId, - }; - } - - this.client.sendStateEvent(roomId, VoiceBroadcastInfoEventType, content, userId); - } - - public destroy(): void { - this.client.off(ClientEvent.Sync, this.onClientSync); - } -} diff --git a/src/voice-broadcast/utils/checkVoiceBroadcastPreConditions.tsx b/src/voice-broadcast/utils/checkVoiceBroadcastPreConditions.tsx deleted file mode 100644 index ae96bc0b14..0000000000 --- a/src/voice-broadcast/utils/checkVoiceBroadcastPreConditions.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, SyncState } from "matrix-js-sdk/src/matrix"; - -import { hasRoomLiveVoiceBroadcast, VoiceBroadcastInfoEventType, VoiceBroadcastRecordingsStore } from ".."; -import InfoDialog from "../../components/views/dialogs/InfoDialog"; -import { _t } from "../../languageHandler"; -import Modal from "../../Modal"; - -const showAlreadyRecordingDialog = (): void => { - Modal.createDialog(InfoDialog, { - title: _t("voice_broadcast|failed_already_recording_title"), - description:

{_t("voice_broadcast|failed_already_recording_description")}

, - hasCloseButton: true, - }); -}; - -const showInsufficientPermissionsDialog = (): void => { - Modal.createDialog(InfoDialog, { - title: _t("voice_broadcast|failed_insufficient_permission_title"), - description:

{_t("voice_broadcast|failed_insufficient_permission_description")}

, - hasCloseButton: true, - }); -}; - -const showOthersAlreadyRecordingDialog = (): void => { - Modal.createDialog(InfoDialog, { - title: _t("voice_broadcast|failed_others_already_recording_title"), - description:

{_t("voice_broadcast|failed_others_already_recording_description")}

, - hasCloseButton: true, - }); -}; - -const showNoConnectionDialog = (): void => { - Modal.createDialog(InfoDialog, { - title: _t("voice_broadcast|failed_no_connection_title"), - description:

{_t("voice_broadcast|failed_no_connection_description")}

, - hasCloseButton: true, - }); -}; - -export const checkVoiceBroadcastPreConditions = async ( - room: Room, - client: MatrixClient, - recordingsStore: VoiceBroadcastRecordingsStore, -): Promise => { - if (recordingsStore.getCurrent()) { - showAlreadyRecordingDialog(); - return false; - } - - const currentUserId = client.getUserId(); - - if (!currentUserId) return false; - - if (!room.currentState.maySendStateEvent(VoiceBroadcastInfoEventType, currentUserId)) { - showInsufficientPermissionsDialog(); - return false; - } - - if (client.getSyncState() === SyncState.Error) { - showNoConnectionDialog(); - return false; - } - - const { hasBroadcast, startedByUser } = await hasRoomLiveVoiceBroadcast(client, room, currentUserId); - - if (hasBroadcast && startedByUser) { - showAlreadyRecordingDialog(); - return false; - } - - if (hasBroadcast) { - showOthersAlreadyRecordingDialog(); - return false; - } - - return true; -}; diff --git a/src/voice-broadcast/utils/cleanUpBroadcasts.ts b/src/voice-broadcast/utils/cleanUpBroadcasts.ts deleted file mode 100644 index 50133274b0..0000000000 --- a/src/voice-broadcast/utils/cleanUpBroadcasts.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { SdkContextClass } from "../../contexts/SDKContext"; - -export const cleanUpBroadcasts = async (stores: SdkContextClass): Promise => { - stores.voiceBroadcastPlaybacksStore.getCurrent()?.stop(); - stores.voiceBroadcastPlaybacksStore.clearCurrent(); - - await stores.voiceBroadcastRecordingsStore.getCurrent()?.stop(); - stores.voiceBroadcastRecordingsStore.clearCurrent(); - - stores.voiceBroadcastPreRecordingStore.getCurrent()?.cancel(); - stores.voiceBroadcastPreRecordingStore.clearCurrent(); -}; diff --git a/src/voice-broadcast/utils/determineVoiceBroadcastLiveness.ts b/src/voice-broadcast/utils/determineVoiceBroadcastLiveness.ts deleted file mode 100644 index 8d9660c572..0000000000 --- a/src/voice-broadcast/utils/determineVoiceBroadcastLiveness.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { VoiceBroadcastInfoState, VoiceBroadcastLiveness } from ".."; - -const stateLivenessMap: Map = new Map([ - ["started", "live"], - ["resumed", "live"], - ["paused", "grey"], - ["stopped", "not-live"], -] as Array<[VoiceBroadcastInfoState, VoiceBroadcastLiveness]>); - -export const determineVoiceBroadcastLiveness = (infoState: VoiceBroadcastInfoState): VoiceBroadcastLiveness => { - return stateLivenessMap.get(infoState) ?? "not-live"; -}; diff --git a/src/voice-broadcast/utils/doClearCurrentVoiceBroadcastPlaybackIfStopped.ts b/src/voice-broadcast/utils/doClearCurrentVoiceBroadcastPlaybackIfStopped.ts deleted file mode 100644 index ef0e1e7aed..0000000000 --- a/src/voice-broadcast/utils/doClearCurrentVoiceBroadcastPlaybackIfStopped.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { VoiceBroadcastPlaybacksStore, VoiceBroadcastPlaybackState } from ".."; - -export const doClearCurrentVoiceBroadcastPlaybackIfStopped = ( - voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore, -): void => { - if (voiceBroadcastPlaybacksStore.getCurrent()?.getState() === VoiceBroadcastPlaybackState.Stopped) { - // clear current if stopped - return; - } -}; diff --git a/src/voice-broadcast/utils/doMaybeSetCurrentVoiceBroadcastPlayback.ts b/src/voice-broadcast/utils/doMaybeSetCurrentVoiceBroadcastPlayback.ts deleted file mode 100644 index 2ec4ab185d..0000000000 --- a/src/voice-broadcast/utils/doMaybeSetCurrentVoiceBroadcastPlayback.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 { - hasRoomLiveVoiceBroadcast, - VoiceBroadcastPlaybacksStore, - VoiceBroadcastPlaybackState, - VoiceBroadcastRecordingsStore, -} from ".."; - -/** - * When a live voice broadcast is in the room and - * another voice broadcast is not currently being listened to or recorded - * the live broadcast in the room is set as the current broadcast to listen to. - * When there is no live broadcast in the room: clear current broadcast. - * - * @param {Room} room The room to check for a live voice broadcast - * @param {MatrixClient} client - * @param {VoiceBroadcastPlaybacksStore} voiceBroadcastPlaybacksStore - * @param {VoiceBroadcastRecordingsStore} voiceBroadcastRecordingsStore - */ -export const doMaybeSetCurrentVoiceBroadcastPlayback = async ( - room: Room, - client: MatrixClient, - voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore, - voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore, -): Promise => { - // do not disturb the current recording - if (voiceBroadcastRecordingsStore.hasCurrent()) return; - - const currentPlayback = voiceBroadcastPlaybacksStore.getCurrent(); - - if (currentPlayback && currentPlayback.getState() !== VoiceBroadcastPlaybackState.Stopped) { - // do not disturb the current playback - return; - } - - const { infoEvent } = await hasRoomLiveVoiceBroadcast(client, room); - - if (infoEvent) { - // live broadcast in the room + no recording + not listening yet: set the current broadcast - const voiceBroadcastPlayback = voiceBroadcastPlaybacksStore.getByInfoEvent(infoEvent, client); - voiceBroadcastPlaybacksStore.setCurrent(voiceBroadcastPlayback); - return; - } - - // no broadcast; not listening: clear current - voiceBroadcastPlaybacksStore.clearCurrent(); -}; diff --git a/src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice.ts b/src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice.ts deleted file mode 100644 index fbd02d44fb..0000000000 --- a/src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; - -export const findRoomLiveVoiceBroadcastFromUserAndDevice = ( - room: Room, - userId: string, - deviceId: string, -): MatrixEvent | null => { - const stateEvent = room.currentState.getStateEvents(VoiceBroadcastInfoEventType, userId); - - // no broadcast from that user - if (!stateEvent) return null; - - const content = stateEvent.getContent() || {}; - - // stopped broadcast - if (content.state === VoiceBroadcastInfoState.Stopped) return null; - - return content.device_id === deviceId ? stateEvent : null; -}; diff --git a/src/voice-broadcast/utils/getChunkLength.ts b/src/voice-broadcast/utils/getChunkLength.ts deleted file mode 100644 index b3fe2f557d..0000000000 --- a/src/voice-broadcast/utils/getChunkLength.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import SdkConfig, { DEFAULTS } from "../../SdkConfig"; -import { Features } from "../../settings/Settings"; -import SettingsStore from "../../settings/SettingsStore"; - -/** - * Returns the target chunk length for voice broadcasts: - * - If {@see Features.VoiceBroadcastForceSmallChunks} is enabled uses 15s chunk length - * - Otherwise to get the value from the voice_broadcast.chunk_length config - * - If that fails from DEFAULTS - * - If that fails fall back to 120 (two minutes) - */ -export const getChunkLength = (): number => { - if (SettingsStore.getValue(Features.VoiceBroadcastForceSmallChunks)) return 15; - return SdkConfig.get("voice_broadcast")?.chunk_length || DEFAULTS.voice_broadcast?.chunk_length || 120; -}; diff --git a/src/voice-broadcast/utils/getMaxBroadcastLength.ts b/src/voice-broadcast/utils/getMaxBroadcastLength.ts deleted file mode 100644 index e5df83ef05..0000000000 --- a/src/voice-broadcast/utils/getMaxBroadcastLength.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import SdkConfig, { DEFAULTS } from "../../SdkConfig"; - -/** - * Returns the max length for voice broadcasts: - * - Tries to get the value from the voice_broadcast.max_length config - * - If that fails from DEFAULTS - * - If that fails fall back to four hours - */ -export const getMaxBroadcastLength = (): number => { - return SdkConfig.get("voice_broadcast")?.max_length || DEFAULTS.voice_broadcast?.max_length || 4 * 60 * 60; -}; diff --git a/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts b/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts deleted file mode 100644 index 939eb1a0c2..0000000000 --- a/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; - -import { retrieveStartedInfoEvent, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; -import { asyncEvery } from "../../utils/arrays"; - -interface Result { - // whether there is a live broadcast in the room - hasBroadcast: boolean; - // info event of any live broadcast in the room - infoEvent: MatrixEvent | null; - // whether the broadcast was started by the user - startedByUser: boolean; -} - -export const hasRoomLiveVoiceBroadcast = async (client: MatrixClient, room: Room, userId?: string): Promise => { - let hasBroadcast = false; - let startedByUser = false; - let infoEvent: MatrixEvent | null = null; - - const stateEvents = room.currentState.getStateEvents(VoiceBroadcastInfoEventType); - await asyncEvery(stateEvents, async (event: MatrixEvent) => { - const state = event.getContent()?.state; - - if (state && state !== VoiceBroadcastInfoState.Stopped) { - const startEvent = await retrieveStartedInfoEvent(event, client); - - // skip if started voice broadcast event is redacted - if (startEvent?.isRedacted()) return true; - - hasBroadcast = true; - infoEvent = startEvent; - - // state key = sender's MXID - if (event.getStateKey() === userId) { - startedByUser = true; - // break here, because more than true / true is not possible - return false; - } - } - - return true; - }); - - return { - hasBroadcast, - infoEvent, - startedByUser, - }; -}; diff --git a/src/voice-broadcast/utils/isRelatedToVoiceBroadcast.ts b/src/voice-broadcast/utils/isRelatedToVoiceBroadcast.ts deleted file mode 100644 index eca8f890e0..0000000000 --- a/src/voice-broadcast/utils/isRelatedToVoiceBroadcast.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventType } from "../types"; - -export const isRelatedToVoiceBroadcast = (event: MatrixEvent, client: MatrixClient): boolean => { - const relation = event.getRelation(); - - return ( - relation?.rel_type === RelationType.Reference && - !!relation.event_id && - client.getRoom(event.getRoomId())?.findEventById(relation.event_id)?.getType() === VoiceBroadcastInfoEventType - ); -}; diff --git a/src/voice-broadcast/utils/isVoiceBroadcastStartedEvent.ts b/src/voice-broadcast/utils/isVoiceBroadcastStartedEvent.ts deleted file mode 100644 index fffe45850e..0000000000 --- a/src/voice-broadcast/utils/isVoiceBroadcastStartedEvent.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../types"; - -export const isVoiceBroadcastStartedEvent = (event: MatrixEvent): boolean => { - return ( - event.getType() === VoiceBroadcastInfoEventType && event.getContent()?.state === VoiceBroadcastInfoState.Started - ); -}; diff --git a/src/voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom.ts b/src/voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom.ts deleted file mode 100644 index e854ba9bac..0000000000 --- a/src/voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { Room } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastPlaybacksStore } from ".."; - -export const pauseNonLiveBroadcastFromOtherRoom = ( - room: Room, - voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore, -): void => { - const playingBroadcast = voiceBroadcastPlaybacksStore.getCurrent(); - - if ( - !playingBroadcast || - playingBroadcast?.getLiveness() === "live" || - playingBroadcast?.infoEvent.getRoomId() === room.roomId - ) { - return; - } - - voiceBroadcastPlaybacksStore.clearCurrent(); - playingBroadcast.pause(); -}; diff --git a/src/voice-broadcast/utils/retrieveStartedInfoEvent.ts b/src/voice-broadcast/utils/retrieveStartedInfoEvent.ts deleted file mode 100644 index cc5be144c9..0000000000 --- a/src/voice-broadcast/utils/retrieveStartedInfoEvent.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoState } from ".."; - -export const retrieveStartedInfoEvent = async ( - event: MatrixEvent, - client: MatrixClient, -): Promise => { - // started event passed as argument - if (event.getContent()?.state === VoiceBroadcastInfoState.Started) return event; - - const relatedEventId = event.getRelation()?.event_id; - - // no related event - if (!relatedEventId) return null; - - const roomId = event.getRoomId() || ""; - const relatedEventFromRoom = client.getRoom(roomId)?.findEventById(relatedEventId); - - // event found - if (relatedEventFromRoom) return relatedEventFromRoom; - - try { - const relatedEventData = await client.fetchRoomEvent(roomId, relatedEventId); - return new MatrixEvent(relatedEventData); - } catch {} - - return null; -}; diff --git a/src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording.ts b/src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording.ts deleted file mode 100644 index c50607c58f..0000000000 --- a/src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 { - checkVoiceBroadcastPreConditions, - VoiceBroadcastPlaybacksStore, - VoiceBroadcastPreRecording, - VoiceBroadcastPreRecordingStore, - VoiceBroadcastRecordingsStore, -} from ".."; - -export const setUpVoiceBroadcastPreRecording = async ( - room: Room, - client: MatrixClient, - playbacksStore: VoiceBroadcastPlaybacksStore, - recordingsStore: VoiceBroadcastRecordingsStore, - preRecordingStore: VoiceBroadcastPreRecordingStore, -): Promise => { - if (!(await checkVoiceBroadcastPreConditions(room, client, recordingsStore))) { - return null; - } - - const userId = client.getUserId(); - if (!userId) return null; - - const sender = room.getMember(userId); - if (!sender) return null; - - // pause and clear current playback (if any) - playbacksStore.getCurrent()?.pause(); - playbacksStore.clearCurrent(); - - const preRecording = new VoiceBroadcastPreRecording(room, sender, client, playbacksStore, recordingsStore); - preRecordingStore.setCurrent(preRecording); - return preRecording; -}; diff --git a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastRecordingTile.ts b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastRecordingTile.ts deleted file mode 100644 index d729d9e1ca..0000000000 --- a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastRecordingTile.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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, MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventContent, VoiceBroadcastInfoState } from ".."; - -export const shouldDisplayAsVoiceBroadcastRecordingTile = ( - state: VoiceBroadcastInfoState, - client: MatrixClient, - event: MatrixEvent, -): boolean => { - const userId = client.getUserId(); - return ( - !!userId && - userId === event.getSender() && - client.getDeviceId() === event.getContent()?.device_id && - state !== VoiceBroadcastInfoState.Stopped - ); -}; diff --git a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastStoppedText.ts b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastStoppedText.ts deleted file mode 100644 index 2179aff3b7..0000000000 --- a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastStoppedText.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; - -export const shouldDisplayAsVoiceBroadcastStoppedText = (event: MatrixEvent): boolean => - event.getType() === VoiceBroadcastInfoEventType && - event.getContent()?.state === VoiceBroadcastInfoState.Stopped && - !event.isRedacted(); diff --git a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts deleted file mode 100644 index 9a51b33c9a..0000000000 --- a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; - -export const shouldDisplayAsVoiceBroadcastTile = (event: MatrixEvent): boolean => - event.getType?.() === VoiceBroadcastInfoEventType && - (event.getContent?.()?.state === VoiceBroadcastInfoState.Started || event.isRedacted()); diff --git a/src/voice-broadcast/utils/showCantStartACallDialog.tsx b/src/voice-broadcast/utils/showCantStartACallDialog.tsx deleted file mode 100644 index eeeb86ee07..0000000000 --- a/src/voice-broadcast/utils/showCantStartACallDialog.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 InfoDialog from "../../components/views/dialogs/InfoDialog"; -import { _t } from "../../languageHandler"; -import Modal from "../../Modal"; - -export const showCantStartACallDialog = (): void => { - Modal.createDialog(InfoDialog, { - title: _t("voip|failed_call_live_broadcast_title"), - description:

{_t("voip|failed_call_live_broadcast_description")}

, - hasCloseButton: true, - }); -}; diff --git a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts deleted file mode 100644 index f0c5a91932..0000000000 --- a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { ISendEventResponse, MatrixClient, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; -import { defer } from "matrix-js-sdk/src/utils"; - -import { - VoiceBroadcastInfoEventContent, - VoiceBroadcastInfoEventType, - VoiceBroadcastInfoState, - VoiceBroadcastRecordingsStore, - VoiceBroadcastRecording, - getChunkLength, - VoiceBroadcastPlaybacksStore, -} from ".."; -import { checkVoiceBroadcastPreConditions } from "./checkVoiceBroadcastPreConditions"; - -const startBroadcast = async ( - room: Room, - client: MatrixClient, - recordingsStore: VoiceBroadcastRecordingsStore, -): Promise => { - const { promise, resolve, reject } = defer(); - - const userId = client.getUserId(); - - if (!userId) { - reject("unable to start voice broadcast if current user is unknown"); - return promise; - } - - let result: ISendEventResponse | null = null; - - const onRoomStateEvents = (): void => { - if (!result) return; - - const voiceBroadcastEvent = room.currentState.getStateEvents(VoiceBroadcastInfoEventType, userId); - - if (voiceBroadcastEvent?.getId() === result.event_id) { - room.off(RoomStateEvent.Events, onRoomStateEvents); - const recording = new VoiceBroadcastRecording(voiceBroadcastEvent, client); - recordingsStore.setCurrent(recording); - recording.start(); - resolve(recording); - } - }; - - room.on(RoomStateEvent.Events, onRoomStateEvents); - - // XXX Michael W: refactor to live event - result = await client.sendStateEvent( - room.roomId, - VoiceBroadcastInfoEventType, - { - device_id: client.getDeviceId(), - state: VoiceBroadcastInfoState.Started, - chunk_length: getChunkLength(), - } as VoiceBroadcastInfoEventContent, - userId, - ); - - return promise; -}; - -/** - * Starts a new Voice Broadcast Recording, if - * - the user has the permissions to do so in the room - * - the user is not already recording a voice broadcast - * - there is no other broadcast being recorded in the room, yet - * Sends a voice_broadcast_info state event and waits for the event to actually appear in the room state. - */ -export const startNewVoiceBroadcastRecording = async ( - room: Room, - client: MatrixClient, - playbacksStore: VoiceBroadcastPlaybacksStore, - recordingsStore: VoiceBroadcastRecordingsStore, -): Promise => { - if (!(await checkVoiceBroadcastPreConditions(room, client, recordingsStore))) { - return null; - } - - // pause and clear current playback (if any) - playbacksStore.getCurrent()?.pause(); - playbacksStore.clearCurrent(); - - return startBroadcast(room, client, recordingsStore); -}; diff --git a/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent.tsx b/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent.tsx deleted file mode 100644 index bc2aa412a5..0000000000 --- a/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import AccessibleButton from "../../components/views/elements/AccessibleButton"; -import { highlightEvent } from "../../utils/EventUtils"; -import { _t } from "../../languageHandler"; -import { getSenderName } from "../../utils/event/getSenderName"; - -export const textForVoiceBroadcastStoppedEvent = (event: MatrixEvent, client: MatrixClient): (() => ReactNode) => { - return (): ReactNode => { - const ownUserId = MatrixClientPeg.get()?.getUserId(); - const startEventId = event.getRelation()?.event_id; - const roomId = event.getRoomId(); - - const templateTags = { - a: (text: string) => - startEventId && roomId ? ( - highlightEvent(roomId, startEventId)}> - {text} - - ) : ( - text - ), - }; - - if (ownUserId && ownUserId === event.getSender()) { - return _t("timeline|io.element.voice_broadcast_info|you", {}, templateTags); - } - - return _t("timeline|io.element.voice_broadcast_info|user", { senderName: getSenderName(event) }, templateTags); - }; -}; diff --git a/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEventWithoutLink.ts b/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEventWithoutLink.ts deleted file mode 100644 index 13d7f47c48..0000000000 --- a/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEventWithoutLink.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { _t } from "../../languageHandler"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import { getSenderName } from "../../utils/event/getSenderName"; - -export const textForVoiceBroadcastStoppedEventWithoutLink = (event: MatrixEvent): string => { - const ownUserId = MatrixClientPeg.get()?.getUserId(); - - if (ownUserId && ownUserId === event.getSender()) { - return _t("event_preview|io.element.voice_broadcast_info|you", {}); - } - - return _t("event_preview|io.element.voice_broadcast_info|user", { senderName: getSenderName(event) }); -}; diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index 0262e5537f..df87fcaa55 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -44,17 +44,20 @@ export class MockedCall extends Call { } public static create(room: Room, id: string) { - room.addLiveEvents([ - mkEvent({ - event: true, - type: this.EVENT_TYPE, - room: room.roomId, - user: "@alice:example.org", - content: { "m.type": "m.video", "m.intent": "m.prompt" }, - skey: id, - ts: Date.now(), - }), - ]); + room.addLiveEvents( + [ + mkEvent({ + event: true, + type: this.EVENT_TYPE, + room: room.roomId, + user: "@alice:example.org", + content: { "m.type": "m.video", "m.intent": "m.prompt" }, + skey: id, + ts: Date.now(), + }), + ], + { addToState: true }, + ); // @ts-ignore deliberately calling a private method // Let CallStore know that a call might now exist CallStore.instance.updateRoom(room); @@ -81,17 +84,20 @@ export class MockedCall extends Call { public destroy() { // Terminate the call for good measure - this.room.addLiveEvents([ - mkEvent({ - event: true, - type: MockedCall.EVENT_TYPE, - room: this.room.roomId, - user: "@alice:example.org", - content: { ...this.event.getContent(), "m.terminated": "Call ended" }, - skey: this.widget.id, - ts: Date.now(), - }), - ]); + this.room.addLiveEvents( + [ + mkEvent({ + event: true, + type: MockedCall.EVENT_TYPE, + room: this.room.roomId, + user: "@alice:example.org", + content: { ...this.event.getContent(), "m.terminated": "Call ended" }, + skey: this.widget.id, + ts: Date.now(), + }), + ], + { addToState: true }, + ); super.destroy(); } diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index 7842afbfe5..a2347f9058 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -143,7 +143,6 @@ export const mockClientMethodsCrypto = (): Partial< > => ({ isKeyBackupKeyStored: jest.fn(), getCrossSigningCacheCallbacks: jest.fn().mockReturnValue({ getCrossSigningKeyCache: jest.fn() }), - getKeyBackupVersion: jest.fn().mockResolvedValue(null), secretStorage: { hasKey: jest.fn() }, getCrypto: jest.fn().mockReturnValue({ getUserDeviceInfo: jest.fn(), @@ -163,6 +162,7 @@ export const mockClientMethodsCrypto = (): Partial< getOwnDeviceKeys: jest.fn().mockReturnValue(new Promise(() => {})), getCrossSigningKeyId: jest.fn(), isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false), + getKeyBackupInfo: jest.fn().mockResolvedValue(null), }), }); 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/room.ts b/test/test-utils/room.ts index d618932726..bfd1f9004c 100644 --- a/test/test-utils/room.ts +++ b/test/test-utils/room.ts @@ -85,7 +85,7 @@ export function getRoomContext(room: Room, override: Partial): IRoom canAskToJoin: false, promptAskToJoin: false, viewRoomOpts: { buttons: [] }, - + isRoomEncrypted: false, ...override, }; } diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 78481c2fd0..f9aee512a3 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -99,7 +99,6 @@ export function createTestClient(): MatrixClient { getDevices: jest.fn().mockResolvedValue({ devices: [{ device_id: "ABCDEFGHI" }] }), getSessionId: jest.fn().mockReturnValue("iaszphgvfku"), credentials: { userId: "@userId:matrix.org" }, - getKeyBackupVersion: jest.fn(), secretStorage: { get: jest.fn(), @@ -117,7 +116,7 @@ export function createTestClient(): MatrixClient { getCrypto: jest.fn().mockReturnValue({ getOwnDeviceKeys: jest.fn(), - getUserDeviceInfo: jest.fn(), + getUserDeviceInfo: jest.fn().mockResolvedValue(new Map()), getUserVerificationStatus: jest.fn(), getDeviceVerificationStatus: jest.fn(), resetKeyBackup: jest.fn(), @@ -135,6 +134,8 @@ export function createTestClient(): MatrixClient { restoreKeyBackupWithPassphrase: jest.fn(), loadSessionBackupPrivateKeyFromSecretStorage: jest.fn(), storeSessionBackupPrivateKey: jest.fn(), + getKeyBackupInfo: jest.fn().mockResolvedValue(null), + getEncryptionInfoForEvent: jest.fn().mockResolvedValue(null), }), getPushActionsForEvent: jest.fn(), diff --git a/test/test-utils/threads.ts b/test/test-utils/threads.ts index d2459653e5..83313b1b8d 100644 --- a/test/test-utils/threads.ts +++ b/test/test-utils/threads.ts @@ -157,6 +157,6 @@ export const populateThread = async ({ // that it is already loaded, and send the events again to the room // so they are added to the thread timeline. ret.thread.initialEventsFetched = true; - await room.addLiveEvents(ret.events); + await room.addLiveEvents(ret.events, { addToState: false }); return ret; }; 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 906826e456..ad7f14e119 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -96,12 +96,12 @@ describe("DeviceListener", () => { }), getSessionBackupPrivateKey: jest.fn(), isEncryptionEnabledInRoom: jest.fn(), + getKeyBackupInfo: jest.fn().mockResolvedValue(null), } as unknown as Mocked; mockClient = getMockClientWithEventEmitter({ isGuest: jest.fn(), getUserId: jest.fn().mockReturnValue(userId), getSafeUserId: jest.fn().mockReturnValue(userId), - getKeyBackupVersion: jest.fn().mockResolvedValue(undefined), getRooms: jest.fn().mockReturnValue([]), isVersionSupported: jest.fn().mockResolvedValue(true), isInitialSyncComplete: jest.fn().mockReturnValue(true), @@ -354,7 +354,7 @@ describe("DeviceListener", () => { it("shows set up encryption toast when user has a key backup available", async () => { // non falsy response - mockClient!.getKeyBackupVersion.mockResolvedValue({} as unknown as KeyBackupInfo); + mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo); await createAndStart(); expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( @@ -673,7 +673,7 @@ describe("DeviceListener", () => { describe("When Room Key Backup is not enabled", () => { beforeEach(() => { // no backup - mockClient.getKeyBackupVersion.mockResolvedValue(null); + mockCrypto.getKeyBackupInfo.mockResolvedValue(null); }); it("Should report recovery state as Enabled", async () => { @@ -722,7 +722,7 @@ describe("DeviceListener", () => { }); // no backup - mockClient.getKeyBackupVersion.mockResolvedValue(null); + mockCrypto.getKeyBackupInfo.mockResolvedValue(null); await createAndStart(); @@ -872,7 +872,7 @@ describe("DeviceListener", () => { describe("When Room Key Backup is enabled", () => { beforeEach(() => { // backup enabled - just need a mock object - mockClient.getKeyBackupVersion.mockResolvedValue({} as KeyBackupInfo); + mockCrypto.getKeyBackupInfo.mockResolvedValue({} as KeyBackupInfo); }); const testCases = [ diff --git a/test/unit-tests/LegacyCallHandler-test.ts b/test/unit-tests/LegacyCallHandler-test.ts index c3e64dcf94..476d89a1f0 100644 --- a/test/unit-tests/LegacyCallHandler-test.ts +++ b/test/unit-tests/LegacyCallHandler-test.ts @@ -39,10 +39,6 @@ import { Action } from "../../src/dispatcher/actions"; import { getFunctionalMembers } from "../../src/utils/room/getFunctionalMembers"; import SettingsStore from "../../src/settings/SettingsStore"; import { UIFeature } from "../../src/settings/UIFeature"; -import { VoiceBroadcastInfoState, VoiceBroadcastPlayback, VoiceBroadcastRecording } from "../../src/voice-broadcast"; -import { mkVoiceBroadcastInfoStateEvent } from "./voice-broadcast/utils/test-utils"; -import { SdkContextClass } from "../../src/contexts/SDKContext"; -import Modal from "../../src/Modal"; import { createAudioContext } from "../../src/audio/compat"; import * as ManagedHybrid from "../../src/widgets/ManagedHybrid"; @@ -403,53 +399,6 @@ describe("LegacyCallHandler", () => { await callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice); expect(spy).toHaveBeenCalledWith(MatrixClientPeg.safeGet().getRoom(NATIVE_ROOM_ALICE)); }); - - describe("when listening to a voice broadcast", () => { - let voiceBroadcastPlayback: VoiceBroadcastPlayback; - - beforeEach(() => { - voiceBroadcastPlayback = new VoiceBroadcastPlayback( - mkVoiceBroadcastInfoStateEvent( - "!room:example.com", - VoiceBroadcastInfoState.Started, - MatrixClientPeg.safeGet().getSafeUserId(), - "d42", - ), - MatrixClientPeg.safeGet(), - SdkContextClass.instance.voiceBroadcastRecordingsStore, - ); - SdkContextClass.instance.voiceBroadcastPlaybacksStore.setCurrent(voiceBroadcastPlayback); - jest.spyOn(voiceBroadcastPlayback, "pause").mockImplementation(); - }); - - it("and placing a call should pause the broadcast", async () => { - callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice); - await untilCallHandlerEvent(callHandler, LegacyCallHandlerEvent.CallState); - - expect(voiceBroadcastPlayback.pause).toHaveBeenCalled(); - }); - }); - - describe("when recording a voice broadcast", () => { - beforeEach(() => { - SdkContextClass.instance.voiceBroadcastRecordingsStore.setCurrent( - new VoiceBroadcastRecording( - mkVoiceBroadcastInfoStateEvent( - "!room:example.com", - VoiceBroadcastInfoState.Started, - MatrixClientPeg.safeGet().getSafeUserId(), - "d42", - ), - MatrixClientPeg.safeGet(), - ), - ); - }); - - it("and placing a call should show the info dialog", async () => { - callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice); - expect(Modal.createDialog).toMatchSnapshot(); - }); - }); }); describe("LegacyCallHandler without third party protocols", () => { @@ -528,9 +477,6 @@ describe("LegacyCallHandler without third party protocols", () => { audioElement.id = "remoteAudio"; document.body.appendChild(audioElement); - SdkContextClass.instance.voiceBroadcastPlaybacksStore.clearCurrent(); - SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent(); - fetchMock.get( "/media/ring.mp3", { body: new Blob(["1", "2", "3", "4"], { type: "audio/mpeg" }) }, diff --git a/test/unit-tests/MatrixClientPeg-test.ts b/test/unit-tests/MatrixClientPeg-test.ts index 5a19b568c0..4653340574 100644 --- a/test/unit-tests/MatrixClientPeg-test.ts +++ b/test/unit-tests/MatrixClientPeg-test.ts @@ -11,8 +11,6 @@ import fetchMockJest from "fetch-mock-jest"; import { advanceDateAndTime, stubClient } from "../test-utils"; import { IMatrixClientPeg, MatrixClientPeg as peg } from "../../src/MatrixClientPeg"; -import SettingsStore from "../../src/settings/SettingsStore"; -import { SettingLevel } from "../../src/settings/SettingLevel"; jest.useFakeTimers(); @@ -81,27 +79,18 @@ describe("MatrixClientPeg", () => { }); it("should initialise the rust crypto library by default", async () => { - const mockSetValue = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); - const mockInitRustCrypto = jest.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined); const cryptoStoreKey = new Uint8Array([1, 2, 3, 4]); await testPeg.start({ rustCryptoStoreKey: cryptoStoreKey }); expect(mockInitRustCrypto).toHaveBeenCalledWith({ storageKey: cryptoStoreKey }); - - // we should have stashed the setting in the settings store - expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true); }); it("Should migrate existing login", async () => { - const mockSetValue = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); const mockInitRustCrypto = jest.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined); await testPeg.start(); expect(mockInitRustCrypto).toHaveBeenCalledTimes(1); - - // we should have stashed the setting in the settings store - expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true); }); }); }); diff --git a/test/unit-tests/Notifier-test.ts b/test/unit-tests/Notifier-test.ts index 2fe7fdec0b..f94f50724d 100644 --- a/test/unit-tests/Notifier-test.ts +++ b/test/unit-tests/Notifier-test.ts @@ -43,8 +43,6 @@ import { mkThread } from "../test-utils/threads"; import dis from "../../src/dispatcher/dispatcher"; import { ThreadPayload } from "../../src/dispatcher/payloads/ThreadPayload"; import { Action } from "../../src/dispatcher/actions"; -import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoState } from "../../src/voice-broadcast"; -import { mkVoiceBroadcastInfoStateEvent } from "./voice-broadcast/utils/test-utils"; import { addReplyToMessageContent } from "../../src/utils/Reply"; jest.mock("../../src/utils/notifications", () => ({ @@ -85,16 +83,13 @@ describe("Notifier", () => { }); }; - const mkAudioEvent = (broadcastChunkContent?: object): MatrixEvent => { - const chunkContent = broadcastChunkContent ? { [VoiceBroadcastChunkEventType]: broadcastChunkContent } : {}; - + const mkAudioEvent = (): MatrixEvent => { return mkEvent({ event: true, type: EventType.RoomMessage, user: "@user:example.com", room: "!room:example.com", content: { - ...chunkContent, msgtype: MsgType.Audio, body: "test audio message", }, @@ -320,24 +315,6 @@ describe("Notifier", () => { ); }); - it("should display the expected notification for a broadcast chunk with sequence = 1", () => { - const audioEvent = mkAudioEvent({ sequence: 1 }); - Notifier.displayPopupNotification(audioEvent, testRoom); - expect(MockPlatform.displayNotification).toHaveBeenCalledWith( - "@user:example.com (!room1:server)", - "@user:example.com started a voice broadcast", - "data:image/png;base64,00", - testRoom, - audioEvent, - ); - }); - - it("should display the expected notification for a broadcast chunk with sequence = 2", () => { - const audioEvent = mkAudioEvent({ sequence: 2 }); - Notifier.displayPopupNotification(audioEvent, testRoom); - expect(MockPlatform.displayNotification).not.toHaveBeenCalled(); - }); - it("should strip reply fallback", () => { const event = mkMessage({ msg: "Test", @@ -581,24 +558,6 @@ describe("Notifier", () => { Notifier.evaluateEvent(mkAudioEvent()); expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1); }); - - it("should not show a notification for broadcast info events in any case", () => { - // Let client decide to show a notification - mockClient.getPushActionsForEvent.mockReturnValue({ - notify: true, - tweaks: {}, - }); - - const broadcastStartedEvent = mkVoiceBroadcastInfoStateEvent( - "!other:example.org", - VoiceBroadcastInfoState.Started, - "@user:example.com", - "ABC123", - ); - - Notifier.evaluateEvent(broadcastStartedEvent); - expect(Notifier.displayPopupNotification).not.toHaveBeenCalled(); - }); }); describe("setPromptHidden", () => { @@ -624,8 +583,7 @@ describe("Notifier", () => { content: { body: "this is a thread root" }, }), testRoom.threadsTimelineSets[0]!.getLiveTimeline(), - false, - false, + { toStartOfTimeline: false, fromCache: false, addToState: true }, ); expect(fn).not.toHaveBeenCalled(); diff --git a/test/unit-tests/RoomNotifs-test.ts b/test/unit-tests/RoomNotifs-test.ts index 51416ab7fd..65089eba94 100644 --- a/test/unit-tests/RoomNotifs-test.ts +++ b/test/unit-tests/RoomNotifs-test.ts @@ -147,7 +147,7 @@ describe("RoomNotifs test", () => { const itShouldCountPredecessorHighlightWhenThereIsAPredecessorInTheCreateEvent = (): void => { it("and there is a predecessor in the create event, it should count predecessor highlight", () => { - room.addLiveEvents([mkCreateEvent(OLD_ROOM_ID)]); + room.addLiveEvents([mkCreateEvent(OLD_ROOM_ID)], { addToState: true }); expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(8); expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(7); @@ -157,7 +157,7 @@ describe("RoomNotifs test", () => { const itShouldCountPredecessorHighlightWhenThereIsAPredecessorEvent = (): void => { it("and there is a predecessor event, it should count predecessor highlight", () => { client.getVisibleRooms(); - room.addLiveEvents([mkCreateEvent(OLD_ROOM_ID)]); + room.addLiveEvents([mkCreateEvent(OLD_ROOM_ID)], { addToState: true }); upsertRoomStateEvents(room, [mkPredecessorEvent(OLD_ROOM_ID)]); expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(8); @@ -185,7 +185,7 @@ describe("RoomNotifs test", () => { itShouldCountPredecessorHighlightWhenThereIsAPredecessorEvent(); it("and there is only a predecessor event, it should not count predecessor highlight", () => { - room.addLiveEvents([mkCreateEvent()]); + room.addLiveEvents([mkCreateEvent()], { addToState: true }); upsertRoomStateEvents(room, [mkPredecessorEvent(OLD_ROOM_ID)]); expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(2); @@ -204,7 +204,7 @@ describe("RoomNotifs test", () => { itShouldCountPredecessorHighlightWhenThereIsAPredecessorEvent(); it("and there is only a predecessor event, it should count predecessor highlight", () => { - room.addLiveEvents([mkCreateEvent()]); + room.addLiveEvents([mkCreateEvent()], { addToState: true }); upsertRoomStateEvents(room, [mkPredecessorEvent(OLD_ROOM_ID)]); expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(8); @@ -212,7 +212,7 @@ describe("RoomNotifs test", () => { }); it("and there is an unknown room in the predecessor event, it should not count predecessor highlight", () => { - room.addLiveEvents([mkCreateEvent()]); + room.addLiveEvents([mkCreateEvent()], { addToState: true }); upsertRoomStateEvents(room, [mkPredecessorEvent("!unknon:example.com")]); expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(2); diff --git a/test/unit-tests/SdkConfig-test.ts b/test/unit-tests/SdkConfig-test.ts index 4204a698fa..19d0eec9c3 100644 --- a/test/unit-tests/SdkConfig-test.ts +++ b/test/unit-tests/SdkConfig-test.ts @@ -18,10 +18,6 @@ describe("SdkConfig", () => { describe("with custom values", () => { beforeEach(() => { SdkConfig.put({ - voice_broadcast: { - chunk_length: 42, - max_length: 1337, - }, feedback: { existing_issues_url: "https://existing", } as any, @@ -30,8 +26,6 @@ describe("SdkConfig", () => { it("should return the custom config", () => { const customConfig = JSON.parse(JSON.stringify(DEFAULTS)); - customConfig.voice_broadcast.chunk_length = 42; - customConfig.voice_broadcast.max_length = 1337; customConfig.feedback.existing_issues_url = "https://existing"; expect(SdkConfig.get()).toEqual(customConfig); }); diff --git a/test/unit-tests/SupportedBrowser-test.ts b/test/unit-tests/SupportedBrowser-test.ts index ccf75e0dab..a116ab1f9f 100644 --- a/test/unit-tests/SupportedBrowser-test.ts +++ b/test/unit-tests/SupportedBrowser-test.ts @@ -66,10 +66,10 @@ describe("SupportedBrowser", () => { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", // Firefox 131 on macOS Sonoma "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0", - // Edge 129 on Windows - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/129.0.2792.79", - // Edge 129 on macOS - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/129.0.2792.79", + // Edge 131 on Windows + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.2903.70", + // Edge 131 on macOS + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.2903.70", // Firefox 131 on Windows "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0", // Firefox 131 on Linux diff --git a/test/unit-tests/TestSdkContext.ts b/test/unit-tests/TestSdkContext.ts index d4bda4f889..f60b083bef 100644 --- a/test/unit-tests/TestSdkContext.ts +++ b/test/unit-tests/TestSdkContext.ts @@ -16,29 +16,21 @@ import { SpaceStoreClass } from "../../src/stores/spaces/SpaceStore"; import { WidgetLayoutStore } from "../../src/stores/widgets/WidgetLayoutStore"; import { WidgetPermissionStore } from "../../src/stores/widgets/WidgetPermissionStore"; import WidgetStore from "../../src/stores/WidgetStore"; -import { - VoiceBroadcastPlaybacksStore, - VoiceBroadcastPreRecordingStore, - VoiceBroadcastRecordingsStore, -} from "../../src/voice-broadcast"; /** * A class which provides the same API as SdkContextClass but adds additional unsafe setters which can * replace individual stores. This is useful for tests which need to mock out stores. */ export class TestSdkContext extends SdkContextClass { - public declare _RightPanelStore?: RightPanelStore; - public declare _RoomNotificationStateStore?: RoomNotificationStateStore; - public declare _RoomViewStore?: RoomViewStore; - public declare _WidgetPermissionStore?: WidgetPermissionStore; - public declare _WidgetLayoutStore?: WidgetLayoutStore; - public declare _WidgetStore?: WidgetStore; - public declare _PosthogAnalytics?: PosthogAnalytics; - public declare _SlidingSyncManager?: SlidingSyncManager; - public declare _SpaceStore?: SpaceStoreClass; - public declare _VoiceBroadcastRecordingsStore?: VoiceBroadcastRecordingsStore; - public declare _VoiceBroadcastPreRecordingStore?: VoiceBroadcastPreRecordingStore; - public declare _VoiceBroadcastPlaybacksStore?: VoiceBroadcastPlaybacksStore; + declare public _RightPanelStore?: RightPanelStore; + declare public _RoomNotificationStateStore?: RoomNotificationStateStore; + declare public _RoomViewStore?: RoomViewStore; + declare public _WidgetPermissionStore?: WidgetPermissionStore; + declare public _WidgetLayoutStore?: WidgetLayoutStore; + declare public _WidgetStore?: WidgetStore; + declare public _PosthogAnalytics?: PosthogAnalytics; + declare public _SlidingSyncManager?: SlidingSyncManager; + declare public _SpaceStore?: SpaceStoreClass; constructor() { super(); diff --git a/test/unit-tests/Unread-test.ts b/test/unit-tests/Unread-test.ts index 8719da06ef..15d3dab8f5 100644 --- a/test/unit-tests/Unread-test.ts +++ b/test/unit-tests/Unread-test.ts @@ -138,7 +138,7 @@ describe("Unread", () => { room: roomId, content: {}, }); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: true }); // Don't care about the code path of hidden events. mocked(haveRendererForEvent).mockClear().mockReturnValue(true); @@ -157,7 +157,7 @@ describe("Unread", () => { content: {}, }); // Only for timeline events. - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: true }); expect(doesRoomHaveUnreadMessages(room, false)).toBe(false); }); @@ -201,7 +201,7 @@ describe("Unread", () => { content: {}, }); // Only for timeline events. - room.addLiveEvents([event2]); + room.addLiveEvents([event2], { addToState: true }); expect(doesRoomHaveUnreadMessages(room, false)).toBe(true); }); @@ -403,7 +403,7 @@ describe("Unread", () => { redactedEvent.makeRedacted(redactedEvent, room); console.log("Event Id", redactedEvent.getId()); // Only for timeline events. - room.addLiveEvents([redactedEvent]); + room.addLiveEvents([redactedEvent], { addToState: true }); expect(doesRoomHaveUnreadMessages(room, true)).toBe(true); expect(logger.warn).toHaveBeenCalledWith( @@ -448,7 +448,7 @@ describe("Unread", () => { room: roomId, content: {}, }); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: true }); }); it("an unthreaded receipt for the event makes the room read", () => { @@ -502,7 +502,7 @@ describe("Unread", () => { ts: 100, currentUserId: myId, }); - room.addLiveEvents(events); + room.addLiveEvents(events, { addToState: true }); threadEvent = events[1]; }); @@ -555,7 +555,7 @@ describe("Unread", () => { room: roomId, content: {}, }); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: true }); // It still returns false expect(doesRoomHaveUnreadThreads(room)).toBe(false); diff --git a/test/unit-tests/__snapshots__/LegacyCallHandler-test.ts.snap b/test/unit-tests/__snapshots__/LegacyCallHandler-test.ts.snap deleted file mode 100644 index aaf4d78758..0000000000 --- a/test/unit-tests/__snapshots__/LegacyCallHandler-test.ts.snap +++ /dev/null @@ -1,24 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LegacyCallHandler when recording a voice broadcast and placing a call should show the info dialog 1`] = ` -[MockFunction] { - "calls": [ - [ - [Function], - { - "description":

- You can’t start a call as you are currently recording a live broadcast. Please end your live broadcast in order to start a call. -

, - "hasCloseButton": true, - "title": "Can’t start a call", - }, - ], - ], - "results": [ - { - "type": "return", - "value": undefined, - }, - ], -} -`; 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/FilePanel-test.tsx b/test/unit-tests/components/structures/FilePanel-test.tsx index 1dce220682..25bdd99676 100644 --- a/test/unit-tests/components/structures/FilePanel-test.tsx +++ b/test/unit-tests/components/structures/FilePanel-test.tsx @@ -7,13 +7,13 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { EventTimelineSet, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix"; +import { EventTimelineSet, PendingEventOrdering, Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { screen, render, waitFor } from "jest-matrix-react"; import { mocked } from "jest-mock"; import FilePanel from "../../../../src/components/structures/FilePanel"; import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; -import { stubClient } from "../../../test-utils"; +import { mkEvent, stubClient } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; jest.mock("matrix-js-sdk/src/matrix", () => ({ @@ -47,4 +47,43 @@ describe("FilePanel", () => { }); expect(asFragment()).toMatchSnapshot(); }); + + describe("addEncryptedLiveEvent", () => { + it("should add file msgtype event to filtered timelineSet", async () => { + const cli = MatrixClientPeg.safeGet(); + const room = new Room("!room:server", cli, cli.getSafeUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + cli.reEmitter.reEmit(room, [RoomEvent.Timeline]); + const timelineSet = new EventTimelineSet(room); + room.getOrCreateFilteredTimelineSet = jest.fn().mockReturnValue(timelineSet); + mocked(cli.getRoom).mockReturnValue(room); + + let filePanel: FilePanel | null; + render( + (filePanel = ref)} + />, + ); + await screen.findByText("No files visible in this room"); + + const event = mkEvent({ + type: "m.room.message", + user: cli.getSafeUserId(), + room: room.roomId, + content: { + body: "hello", + url: "mxc://matrix.org/1234", + msgtype: "m.file", + }, + event: true, + }); + filePanel!.addEncryptedLiveEvent(event); + + expect(timelineSet.getLiveTimeline().getEvents()).toContain(event); + }); + }); }); diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index 043aeebb60..bc6bb0f9ff 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"; @@ -44,7 +44,6 @@ import { } from "../../../test-utils"; import * as leaveRoomUtils from "../../../../src/utils/leave-behaviour"; import { OidcClientError } from "../../../../src/utils/oidc/error"; -import * as voiceBroadcastUtils from "../../../../src/voice-broadcast/utils/cleanUpBroadcasts"; import LegacyCallHandler from "../../../../src/LegacyCallHandler"; import { CallStore } from "../../../../src/stores/CallStore"; import { Call } from "../../../../src/models/Call"; @@ -139,6 +138,7 @@ describe("", () => { globalBlacklistUnverifiedDevices: false, // This needs to not finish immediately because we need to test the screen appears bootstrapCrossSigning: jest.fn().mockImplementation(() => bootstrapDeferred.promise), + getKeyBackupInfo: jest.fn().mockResolvedValue(null), }), secretStorage: { isStored: jest.fn().mockReturnValue(null), @@ -148,7 +148,6 @@ describe("", () => { whoami: jest.fn(), logout: jest.fn(), getDeviceId: jest.fn(), - getKeyBackupVersion: jest.fn().mockResolvedValue(null), }); let mockClient: Mocked; const serverConfig = { @@ -162,8 +161,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( @@ -201,7 +203,7 @@ describe("", () => { // we are logged in, but are still waiting for the /sync to complete await screen.findByText("Syncing…"); // initial sync - client.emit(ClientEvent.Sync, SyncState.Prepared, null); + await act(() => client.emit(ClientEvent.Sync, SyncState.Prepared, null)); } // let things settle @@ -263,7 +265,7 @@ describe("", () => { // emit a loggedOut event so that all of the Store singletons forget about their references to the mock client // (must be sync otherwise the next test will start before it happens) - defaultDispatcher.dispatch({ action: Action.OnLoggedOut }, true); + act(() => defaultDispatcher.dispatch({ action: Action.OnLoggedOut }, true)); localStorage.clear(); }); @@ -328,7 +330,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(() => { @@ -808,7 +810,6 @@ describe("", () => { jest.spyOn(LegacyCallHandler.instance, "hangupAllCalls") .mockClear() .mockImplementation(() => {}); - jest.spyOn(voiceBroadcastUtils, "cleanUpBroadcasts").mockImplementation(async () => {}); jest.spyOn(PosthogAnalytics.instance, "logout").mockImplementation(() => {}); jest.spyOn(EventIndexPeg, "deleteEventIndex").mockImplementation(async () => {}); @@ -828,22 +829,12 @@ describe("", () => { jest.spyOn(logger, "warn").mockClear(); }); - afterAll(() => { - jest.spyOn(voiceBroadcastUtils, "cleanUpBroadcasts").mockRestore(); - }); - it("should hangup all legacy calls", async () => { await getComponentAndWaitForReady(); await dispatchLogoutAndWait(); expect(LegacyCallHandler.instance.hangupAllCalls).toHaveBeenCalled(); }); - it("should cleanup broadcasts", async () => { - await getComponentAndWaitForReady(); - await dispatchLogoutAndWait(); - expect(voiceBroadcastUtils.cleanUpBroadcasts).toHaveBeenCalled(); - }); - it("should disconnect all calls", async () => { await getComponentAndWaitForReady(); await dispatchLogoutAndWait(); @@ -956,9 +947,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(); @@ -1129,7 +1122,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(); }); }); }); @@ -1398,7 +1393,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 cf44716ba9..dbb83da312 100644 --- a/test/unit-tests/components/structures/MessagePanel-test.tsx +++ b/test/unit-tests/components/structures/MessagePanel-test.tsx @@ -30,6 +30,7 @@ import { import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import { IRoomState } from "../../../../src/components/structures/RoomView"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx"; jest.mock("../../../../src/utils/beacon", () => ({ useBeacon: jest.fn(), @@ -91,9 +92,9 @@ describe("MessagePanel", function () { const getComponent = (props = {}, roomContext: Partial = {}) => ( - + - + ); diff --git a/test/unit-tests/components/structures/PipContainer-test.tsx b/test/unit-tests/components/structures/PipContainer-test.tsx index d55905d4b4..f573b0a0cd 100644 --- a/test/unit-tests/components/structures/PipContainer-test.tsx +++ b/test/unit-tests/components/structures/PipContainer-test.tsx @@ -10,7 +10,7 @@ import React from "react"; import { mocked, Mocked } from "jest-mock"; import { screen, render, act, cleanup } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; -import { MatrixClient, PendingEventOrdering, Room, MatrixEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, PendingEventOrdering, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { Widget, ClientWidgetApi } from "matrix-widget-api"; import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup"; @@ -26,7 +26,6 @@ import { wrapInSdkContext, mkRoomCreateEvent, mockPlatformPeg, - flushPromises, useMockMediaDevices, } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; @@ -39,17 +38,7 @@ import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../src/dispatcher/actions"; import { ViewRoomPayload } from "../../../../src/dispatcher/payloads/ViewRoomPayload"; import { TestSdkContext } from "../../TestSdkContext"; -import { - VoiceBroadcastInfoState, - VoiceBroadcastPlaybacksStore, - VoiceBroadcastPreRecording, - VoiceBroadcastPreRecordingStore, - VoiceBroadcastRecording, - VoiceBroadcastRecordingsStore, -} from "../../../../src/voice-broadcast"; -import { mkVoiceBroadcastInfoStateEvent } from "../../voice-broadcast/utils/test-utils"; import { RoomViewStore } from "../../../../src/stores/RoomViewStore"; -import { IRoomStateEventsActionPayload } from "../../../../src/actions/MatrixActionCreators"; import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore"; import WidgetStore from "../../../../src/stores/WidgetStore"; import { WidgetType } from "../../../../src/widgets/WidgetType"; @@ -76,15 +65,6 @@ describe("PipContainer", () => { let room: Room; let room2: Room; let alice: RoomMember; - let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore; - let voiceBroadcastPreRecordingStore: VoiceBroadcastPreRecordingStore; - let voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore; - - const actFlushPromises = async () => { - await act(async () => { - await flushPromises(); - }); - }; beforeEach(async () => { useMockMediaDevices(); @@ -127,13 +107,7 @@ describe("PipContainer", () => { sdkContext = new TestSdkContext(); // @ts-ignore PipContainer uses SDKContext in the constructor SdkContextClass.instance = sdkContext; - voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore(); - voiceBroadcastPreRecordingStore = new VoiceBroadcastPreRecordingStore(); - voiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore(voiceBroadcastRecordingsStore); sdkContext.client = client; - sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore; - sdkContext._VoiceBroadcastPreRecordingStore = voiceBroadcastPreRecordingStore; - sdkContext._VoiceBroadcastPlaybacksStore = voiceBroadcastPlaybacksStore; }); afterEach(async () => { @@ -165,12 +139,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 +152,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 => { @@ -190,51 +166,10 @@ describe("PipContainer", () => { ActiveWidgetStore.instance.destroyPersistentWidget("1", room.roomId); }; - const makeVoiceBroadcastInfoStateEvent = (): MatrixEvent => { - return mkVoiceBroadcastInfoStateEvent( - room.roomId, - VoiceBroadcastInfoState.Started, - alice.userId, - client.getDeviceId() || "", - ); - }; - - const setUpVoiceBroadcastRecording = () => { - const infoEvent = makeVoiceBroadcastInfoStateEvent(); - const voiceBroadcastRecording = new VoiceBroadcastRecording(infoEvent, client); - voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording); - }; - - const setUpVoiceBroadcastPreRecording = () => { - const voiceBroadcastPreRecording = new VoiceBroadcastPreRecording( - room, - alice, - client, - voiceBroadcastPlaybacksStore, - voiceBroadcastRecordingsStore, - ); - voiceBroadcastPreRecordingStore.setCurrent(voiceBroadcastPreRecording); - }; - const setUpRoomViewStore = () => { sdkContext._RoomViewStore = new RoomViewStore(defaultDispatcher, sdkContext); }; - const mkVoiceBroadcast = (room: Room): MatrixEvent => { - const infoEvent = makeVoiceBroadcastInfoStateEvent(); - room.currentState.setStateEvents([infoEvent]); - defaultDispatcher.dispatch( - { - action: "MatrixActions.RoomState.events", - event: infoEvent, - state: room.currentState, - lastStateEvent: null, - }, - true, - ); - return infoEvent; - }; - it("hides if there's no content", () => { renderPip(); expect(screen.queryByRole("complementary")).toBeNull(); @@ -339,138 +274,4 @@ describe("PipContainer", () => { WidgetStore.instance.removeVirtualWidget("1", room.roomId); }); - - describe("when there is a voice broadcast recording and pre-recording", () => { - beforeEach(async () => { - setUpVoiceBroadcastPreRecording(); - setUpVoiceBroadcastRecording(); - renderPip(); - await actFlushPromises(); - }); - - it("should render the voice broadcast recording PiP", () => { - // check for the „Live“ badge to be present - expect(screen.queryByText("Live")).toBeInTheDocument(); - }); - - it("and a call it should show both, the call and the recording", async () => { - await withCall(async () => { - // Broadcast: Check for the „Live“ badge to be present - expect(screen.queryByText("Live")).toBeInTheDocument(); - // Call: Check for the „Leave“ button to be present - screen.getByRole("button", { name: "Leave" }); - }); - }); - }); - - describe("when there is a voice broadcast playback and pre-recording", () => { - beforeEach(async () => { - mkVoiceBroadcast(room); - setUpVoiceBroadcastPreRecording(); - renderPip(); - await actFlushPromises(); - }); - - it("should render the voice broadcast pre-recording PiP", () => { - // check for the „Go live“ button - expect(screen.queryByText("Go live")).toBeInTheDocument(); - }); - }); - - describe("when there is a voice broadcast pre-recording", () => { - beforeEach(async () => { - setUpVoiceBroadcastPreRecording(); - renderPip(); - await actFlushPromises(); - }); - - it("should render the voice broadcast pre-recording PiP", () => { - // check for the „Go live“ button - expect(screen.queryByText("Go live")).toBeInTheDocument(); - }); - }); - - describe("when listening to a voice broadcast in a room and then switching to another room", () => { - beforeEach(async () => { - setUpRoomViewStore(); - viewRoom(room.roomId); - mkVoiceBroadcast(room); - await actFlushPromises(); - - expect(voiceBroadcastPlaybacksStore.getCurrent()).toBeTruthy(); - - await voiceBroadcastPlaybacksStore.getCurrent()?.start(); - viewRoom(room2.roomId); - renderPip(); - }); - - it("should render the small voice broadcast playback PiP", () => { - // check for the „pause voice broadcast“ button - expect(screen.getByLabelText("pause voice broadcast")).toBeInTheDocument(); - // check for the absence of the „30s forward“ button - expect(screen.queryByLabelText("30s forward")).not.toBeInTheDocument(); - }); - }); - - describe("when viewing a room with a live voice broadcast", () => { - let startEvent!: MatrixEvent; - - beforeEach(async () => { - setUpRoomViewStore(); - viewRoom(room.roomId); - startEvent = mkVoiceBroadcast(room); - renderPip(); - await actFlushPromises(); - }); - - it("should render the voice broadcast playback pip", () => { - // check for the „resume voice broadcast“ button - expect(screen.queryByLabelText("play voice broadcast")).toBeInTheDocument(); - }); - - describe("and the broadcast stops", () => { - beforeEach(async () => { - const stopEvent = mkVoiceBroadcastInfoStateEvent( - room.roomId, - VoiceBroadcastInfoState.Stopped, - alice.userId, - client.getDeviceId() || "", - startEvent, - ); - - await act(async () => { - room.currentState.setStateEvents([stopEvent]); - defaultDispatcher.dispatch( - { - action: "MatrixActions.RoomState.events", - event: stopEvent, - state: room.currentState, - lastStateEvent: stopEvent, - }, - true, - ); - await flushPromises(); - }); - }); - - it("should not render the voice broadcast playback pip", () => { - // check for the „resume voice broadcast“ button - expect(screen.queryByLabelText("play voice broadcast")).not.toBeInTheDocument(); - }); - }); - - describe("and leaving the room", () => { - beforeEach(async () => { - await act(async () => { - viewRoom(room2.roomId); - await flushPromises(); - }); - }); - - it("should not render the voice broadcast playback pip", () => { - // check for the „resume voice broadcast“ button - expect(screen.queryByLabelText("play voice broadcast")).not.toBeInTheDocument(); - }); - }); - }); }); diff --git a/test/unit-tests/components/structures/RightPanel-test.tsx b/test/unit-tests/components/structures/RightPanel-test.tsx index e569369db5..ad29791ee9 100644 --- a/test/unit-tests/components/structures/RightPanel-test.tsx +++ b/test/unit-tests/components/structures/RightPanel-test.tsx @@ -91,7 +91,7 @@ describe("RightPanel", () => { if (name !== "RightPanel.phases") return realGetValue(name, roomId); if (roomId === "r1") { return { - history: [{ phase: RightPanelPhases.RoomMemberList }], + history: [{ phase: RightPanelPhases.MemberList }], isOpen: true, }; } @@ -123,7 +123,7 @@ describe("RightPanel", () => { await rpsUpdated; await waitFor(() => expect(screen.queryByTestId("spinner")).not.toBeInTheDocument()); - // room one will be in the RoomMemberList phase - confirm this is rendered + // room one will be in the MemberList phase - confirm this is rendered expect(container.getElementsByClassName("mx_MemberList")).toHaveLength(1); // wait for RPS room 2 updates to fire, then rerender diff --git a/test/unit-tests/components/structures/RoomView-test.tsx b/test/unit-tests/components/structures/RoomView-test.tsx index f30db3d80e..385204c01b 100644 --- a/test/unit-tests/components/structures/RoomView-test.tsx +++ b/test/unit-tests/components/structures/RoomView-test.tsx @@ -10,27 +10,37 @@ import React, { createRef, RefObject } from "react"; import { mocked, MockedObject } from "jest-mock"; import { ClientEvent, + EventTimeline, + EventType, + IEvent, + JoinRule, MatrixClient, + MatrixError, + MatrixEvent, Room, RoomEvent, - EventType, - JoinRule, - MatrixError, RoomStateEvent, - MatrixEvent, SearchResult, - IEvent, } from "matrix-js-sdk/src/matrix"; -import { CryptoApi, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; +import { CryptoApi, UserVerificationStatus, CryptoEvent } 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 { defer } from "matrix-js-sdk/src/utils"; import { stubClient, mockPlatformPeg, unmockPlatformPeg, - wrapInMatrixClientContext, flushPromises, mkEvent, setupAsyncStoreWithClient, @@ -45,7 +55,7 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { Action } from "../../../../src/dispatcher/actions"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { ViewRoomPayload } from "../../../../src/dispatcher/payloads/ViewRoomPayload"; -import { RoomView as _RoomView } from "../../../../src/components/structures/RoomView"; +import { RoomView } from "../../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../../src/settings/SettingLevel"; @@ -64,8 +74,8 @@ 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"; +import { ViewUserPayload } from "../../../../src/dispatcher/payloads/ViewUserPayload.ts"; describe("RoomView", () => { let cli: MockedObject; @@ -80,8 +90,7 @@ describe("RoomView", () => { beforeEach(() => { mockPlatformPeg({ reload: () => {} }); - stubClient(); - cli = mocked(MatrixClientPeg.safeGet()); + cli = mocked(stubClient()); room = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); jest.spyOn(room, "findPredecessor"); @@ -106,9 +115,10 @@ describe("RoomView", () => { afterEach(() => { unmockPlatformPeg(); jest.clearAllMocks(); + cleanup(); }); - const mountRoomView = async (ref?: RefObject<_RoomView>): Promise => { + const mountRoomView = async (ref?: RefObject): Promise => { if (stores.roomViewStore.getRoomId() !== room.roomId) { const switchedRoom = new Promise((resolve) => { const subFn = () => { @@ -120,26 +130,30 @@ describe("RoomView", () => { stores.roomViewStore.on(UPDATE_EVENT, subFn); }); - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - room_id: room.roomId, - metricsTrigger: undefined, - }); + act(() => + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + metricsTrigger: undefined, + }), + ); await switchedRoom; } const roomView = render( - - - , + + + + + , ); await flushPromises(); return roomView; @@ -167,33 +181,50 @@ 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!; }; + it("should show member list right panel phase on Action.ViewUser without `payload.member`", async () => { + const spy = jest.spyOn(stores.rightPanelStore, "showOrHidePhase"); + await renderRoomView(false); + + defaultDispatcher.dispatch( + { + action: Action.ViewUser, + member: undefined, + }, + true, + ); + + expect(spy).toHaveBeenCalledWith(RightPanelPhases.MemberList); + }); + it("when there is no room predecessor, getHiddenHighlightCount should return 0", async () => { const instance = await getRoomViewInstance(); expect(instance.getHiddenHighlightCount()).toBe(0); }); describe("when there is an old room", () => { - let instance: _RoomView; + let instance: RoomView; let oldRoom: Room; beforeEach(async () => { @@ -217,11 +248,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 () => { @@ -233,8 +264,9 @@ describe("RoomView", () => { it("updates url preview visibility on encryption state change", async () => { room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); + jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); // we should be starting unencrypted - expect(cli.isRoomEncrypted(room.roomId)).toEqual(false); + expect(await cli.getCrypto()?.isEncryptionEnabledInRoom(room.roomId)).toEqual(false); const roomViewInstance = await getRoomViewInstance(); @@ -249,31 +281,74 @@ describe("RoomView", () => { expect(roomViewInstance.state.showUrlPreview).toBe(true); // now enable encryption - cli.isRoomEncrypted.mockReturnValue(true); + jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); // and fake an encryption event into the room to prompt it to re-check - room.addLiveEvents([ - new MatrixEvent({ - type: "m.room.encryption", + act(() => { + const encryptionEvent = new MatrixEvent({ + type: EventType.RoomEncryption, sender: cli.getUserId()!, content: {}, event_id: "someid", room_id: room.roomId, - }), - ]); + }); + const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!; + cli.emit(RoomStateEvent.Events, encryptionEvent, roomState, null); + }); // URL previews should now be disabled - expect(roomViewInstance.state.showUrlPreview).toBe(false); + await waitFor(() => expect(roomViewInstance.state.showUrlPreview).toBe(false)); + }); + + it("should not display the timeline when the room encryption is loading", async () => { + jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join); + jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); + const deferred = defer(); + jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockImplementation(() => deferred.promise); + + const { asFragment, container } = await mountRoomView(); + expect(container.querySelector(".mx_RoomView_messagePanel")).toBeNull(); + expect(asFragment()).toMatchSnapshot(); + + deferred.resolve(true); + await waitFor(() => expect(container.querySelector(".mx_RoomView_messagePanel")).not.toBeNull()); + expect(asFragment()).toMatchSnapshot(); }); it("updates live timeline when a timeline reset happens", async () => { const roomViewInstance = await getRoomViewInstance(); const oldTimeline = roomViewInstance.state.liveTimeline; - room.getUnfilteredTimelineSet().resetLiveTimeline(); + act(() => room.getUnfilteredTimelineSet().resetLiveTimeline()); expect(roomViewInstance.state.liveTimeline).not.toEqual(oldTimeline); }); + it("should update when the e2e status when the user verification changed", async () => { + room.currentState.setStateEvents([ + mkRoomMemberJoinEvent(cli.getSafeUserId(), room.roomId), + mkRoomMemberJoinEvent("user@example.com", room.roomId), + ]); + room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); + // 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, false, false), + ); + jest.spyOn(cli.getCrypto()!, "getUserDeviceInfo").mockResolvedValue( + new Map([["user@example.com", new Map()]]), + ); + + const { container } = await renderRoomView(); + await waitFor(() => expect(container.querySelector(".mx_E2EIcon_normal")).toBeInTheDocument()); + + const verificationStatus = new UserVerificationStatus(true, true, false); + jest.spyOn(cli.getCrypto()!, "getUserVerificationStatus").mockResolvedValue(verificationStatus); + cli.emit(CryptoEvent.UserTrustStatusChanged, cli.getSafeUserId(), verificationStatus); + await waitFor(() => expect(container.querySelector(".mx_E2EIcon_verified")).toBeInTheDocument()); + }); + describe("with virtual rooms", () => { it("checks for a virtual room on initial load", async () => { const { container } = await renderRoomView(); @@ -287,7 +362,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); @@ -411,7 +486,8 @@ describe("RoomView", () => { ]); jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(cli.getSafeUserId()); jest.spyOn(DMRoomMap.shared(), "getRoomIds").mockReturnValue(new Set([room.roomId])); - mocked(cli).isRoomEncrypted.mockReturnValue(true); + jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); + jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); await renderRoomView(); }); @@ -429,6 +505,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"; @@ -449,7 +713,7 @@ describe("RoomView", () => { skey: id, ts, }); - room.addLiveEvents([widgetEvent]); + room.addLiveEvents([widgetEvent], { addToState: false }); room.currentState.setStateEvents([widgetEvent]); cli.emit(RoomStateEvent.Events, widgetEvent, room.currentState, null); await flushPromises(); @@ -514,184 +778,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/SpaceRoomView-test.tsx b/test/unit-tests/components/structures/SpaceRoomView-test.tsx new file mode 100644 index 0000000000..fb24603283 --- /dev/null +++ b/test/unit-tests/components/structures/SpaceRoomView-test.tsx @@ -0,0 +1,117 @@ +/* +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 { mocked, MockedObject } from "jest-mock"; +import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { render, cleanup, screen, fireEvent } from "jest-matrix-react"; + +import { stubClient, mockPlatformPeg, unmockPlatformPeg, withClientContextRenderOptions } from "../../../test-utils"; +import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases"; +import SpaceRoomView from "../../../../src/components/structures/SpaceRoomView.tsx"; +import ResizeNotifier from "../../../../src/utils/ResizeNotifier.ts"; +import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks.ts"; +import RightPanelStore from "../../../../src/stores/right-panel/RightPanelStore.ts"; +import DMRoomMap from "../../../../src/utils/DMRoomMap.ts"; + +describe("SpaceRoomView", () => { + let cli: MockedObject; + let space: Room; + + beforeEach(() => { + mockPlatformPeg({ reload: () => {} }); + cli = mocked(stubClient()); + + space = new Room(`!space:example.org`, cli, cli.getSafeUserId()); + space.currentState.setStateEvents([ + new MatrixEvent({ + type: "m.room.create", + room_id: space.roomId, + sender: cli.getSafeUserId(), + state_key: "", + content: { + creator: cli.getSafeUserId(), + type: "m.space", + }, + }), + new MatrixEvent({ + type: "m.room.member", + room_id: space.roomId, + sender: cli.getSafeUserId(), + state_key: cli.getSafeUserId(), + content: { + membership: "join", + }, + }), + new MatrixEvent({ + type: "m.room.member", + room_id: space.roomId, + sender: "@userA:server", + state_key: "@userA:server", + content: { + membership: "join", + }, + }), + new MatrixEvent({ + type: "m.room.member", + room_id: space.roomId, + sender: "@userB:server", + state_key: "@userB:server", + content: { + membership: "join", + }, + }), + new MatrixEvent({ + type: "m.room.member", + room_id: space.roomId, + sender: "@userC:server", + state_key: "@userC:server", + content: { + membership: "join", + }, + }), + ]); + space.updateMyMembership("join"); + + DMRoomMap.makeShared(cli); + }); + + afterEach(() => { + unmockPlatformPeg(); + jest.clearAllMocks(); + cleanup(); + }); + + const renderSpaceRoomView = async (): Promise> => { + const resizeNotifier = new ResizeNotifier(); + const permalinkCreator = new RoomPermalinkCreator(space); + + const spaceRoomView = render( + , + withClientContextRenderOptions(cli), + ); + return spaceRoomView; + }; + + describe("SpaceLanding", () => { + it("should show member list right panel phase on members click on landing", async () => { + const spy = jest.spyOn(RightPanelStore.instance, "setCard"); + const { container } = await renderSpaceRoomView(); + + await expect(screen.findByText("Welcome to")).resolves.toBeVisible(); + fireEvent.click(container.querySelector(".mx_FacePile")!); + + expect(spy).toHaveBeenCalledWith({ phase: RightPanelPhases.MemberList }); + }); + }); +}); diff --git a/test/unit-tests/components/structures/ThreadPanel-test.tsx b/test/unit-tests/components/structures/ThreadPanel-test.tsx index 1b4d59d9af..20fc708103 100644 --- a/test/unit-tests/components/structures/ThreadPanel-test.tsx +++ b/test/unit-tests/components/structures/ThreadPanel-test.tsx @@ -20,7 +20,6 @@ import { import ThreadPanel, { ThreadFilterType, ThreadPanelHeader } from "../../../../src/components/structures/ThreadPanel"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; -import RoomContext from "../../../../src/contexts/RoomContext"; import { _t } from "../../../../src/languageHandler"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; @@ -28,6 +27,7 @@ import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import { createTestClient, getRoomContext, mkRoom, mockPlatformPeg, stubClient } from "../../../test-utils"; import { mkThread } from "../../../test-utils/threads"; import { IRoomState } from "../../../../src/components/structures/RoomView"; +import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx"; jest.mock("../../../../src/utils/Feedback"); @@ -81,11 +81,11 @@ describe("ThreadPanel", () => { room: mockRoom, } as unknown as IRoomState; const { container } = render( - + undefined} /> - , + , ); fireEvent.click(getByRole(container, "button", { name: "Mark all as read" })); await waitFor(() => @@ -114,8 +114,8 @@ describe("ThreadPanel", () => { const TestThreadPanel = () => ( - @@ -125,7 +125,7 @@ describe("ThreadPanel", () => { resizeNotifier={new ResizeNotifier()} permalinkCreator={new RoomPermalinkCreator(room)} /> - + ); @@ -209,40 +209,39 @@ describe("ThreadPanel", () => { return event ? Promise.resolve(event) : Promise.reject(); }); const [allThreads, myThreads] = room.threadsTimelineSets; - allThreads!.addLiveEvent(otherThread.rootEvent); - allThreads!.addLiveEvent(mixedThread.rootEvent); - allThreads!.addLiveEvent(ownThread.rootEvent); - myThreads!.addLiveEvent(mixedThread.rootEvent); - myThreads!.addLiveEvent(ownThread.rootEvent); + allThreads!.addLiveEvent(otherThread.rootEvent, { addToState: true }); + allThreads!.addLiveEvent(mixedThread.rootEvent, { addToState: true }); + allThreads!.addLiveEvent(ownThread.rootEvent, { addToState: true }); + myThreads!.addLiveEvent(mixedThread.rootEvent, { addToState: true }); + myThreads!.addLiveEvent(ownThread.rootEvent, { addToState: true }); - 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 () => { @@ -259,30 +258,29 @@ describe("ThreadPanel", () => { return event ? Promise.resolve(event) : Promise.reject(); }); const [allThreads] = room.threadsTimelineSets; - allThreads!.addLiveEvent(otherThread.rootEvent); + allThreads!.addLiveEvent(otherThread.rootEvent, { addToState: true }); - 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/ThreadView-test.tsx b/test/unit-tests/components/structures/ThreadView-test.tsx index 697fd25181..ee4afff525 100644 --- a/test/unit-tests/components/structures/ThreadView-test.tsx +++ b/test/unit-tests/components/structures/ThreadView-test.tsx @@ -23,7 +23,6 @@ import React, { useState } from "react"; import ThreadView from "../../../../src/components/structures/ThreadView"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; -import RoomContext from "../../../../src/contexts/RoomContext"; import { SdkContextClass } from "../../../../src/contexts/SDKContext"; import { Action } from "../../../../src/dispatcher/actions"; import dispatcher from "../../../../src/dispatcher/dispatcher"; @@ -34,6 +33,7 @@ import { mockPlatformPeg } from "../../../test-utils/platform"; import { getRoomContext } from "../../../test-utils/room"; import { mkMessage, stubClient } from "../../../test-utils/test-utils"; import { mkThread } from "../../../test-utils/threads"; +import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx"; describe("ThreadView", () => { const ROOM_ID = "!roomId:example.org"; @@ -51,8 +51,8 @@ describe("ThreadView", () => { return ( - @@ -63,7 +63,7 @@ describe("ThreadView", () => { initialEvent={initialEvent} resizeNotifier={new ResizeNotifier()} /> - + , ); diff --git a/test/unit-tests/components/structures/TimelinePanel-test.tsx b/test/unit-tests/components/structures/TimelinePanel-test.tsx index 4a66351779..442ed1c1d2 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"; @@ -66,7 +66,7 @@ const mkTimeline = (room: Room, events: MatrixEvent[]): [EventTimeline, EventTim getPendingEvents: () => [] as MatrixEvent[], } as unknown as EventTimelineSet; const timeline = new EventTimeline(timelineSet); - events.forEach((event) => timeline.addEvent(event, { toStartOfTimeline: false })); + events.forEach((event) => timeline.addEvent(event, { toStartOfTimeline: false, addToState: true })); return [timeline, timelineSet]; }; @@ -150,9 +150,11 @@ const setupPagination = ( mocked(client).paginateEventTimeline.mockImplementation(async (tl, { backwards }) => { if (tl === timeline) { if (backwards) { - forEachRight(previousPage ?? [], (event) => tl.addEvent(event, { toStartOfTimeline: true })); + forEachRight(previousPage ?? [], (event) => + tl.addEvent(event, { toStartOfTimeline: true, addToState: true }), + ); } else { - (nextPage ?? []).forEach((event) => tl.addEvent(event, { toStartOfTimeline: false })); + (nextPage ?? []).forEach((event) => tl.addEvent(event, { toStartOfTimeline: false, addToState: true })); } // Prevent any further pagination attempts in this direction tl.setPaginationToken(null, backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS); @@ -178,7 +180,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 +199,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 +233,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 () => { @@ -256,9 +258,8 @@ describe("TimelinePanel", () => { describe("and reading the timeline", () => { beforeEach(async () => { await renderTimelinePanel(); - timelineSet.addLiveEvent(ev1, {}); + timelineSet.addLiveEvent(ev1, { addToState: true }); await flushPromises(); - // @ts-ignore await timelinePanel.sendReadReceipts(); // @ts-ignore Simulate user activity by calling updateReadMarker on the TimelinePanel. @@ -276,7 +277,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", () => { @@ -285,13 +286,13 @@ describe("TimelinePanel", () => { }); it("and forgetting the read markers, should send the stored marker again", async () => { - timelineSet.addLiveEvent(ev2, {}); + timelineSet.addLiveEvent(ev2, { addToState: true }); // Add the event to the room as well as the timeline, so we can find it when we // call findEventById in getEventReadUpTo. This is odd because in our test // setup, timelineSet is not actually the timelineSet of the room. - await room.addLiveEvents([ev2], {}); + await room.addLiveEvents([ev2], { addToState: true }); room.addEphemeralEvents([newReceipt(ev2.getId()!, userId, 222, 200)]); - await timelinePanel.forgetReadMarker(); + await timelinePanel!.forgetReadMarker(); expect(client.setRoomReadMarkers).toHaveBeenCalledWith(roomId, ev2.getId()); }); }); @@ -315,7 +316,7 @@ describe("TimelinePanel", () => { it("should send a fully read marker and a private receipt", async () => { await renderTimelinePanel(); - timelineSet.addLiveEvent(ev1, {}); + act(() => timelineSet.addLiveEvent(ev1, { addToState: true })); await flushPromises(); // @ts-ignore @@ -326,6 +327,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 +336,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 +363,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, { addToState: true })); 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(); @@ -871,7 +873,9 @@ describe("TimelinePanel", () => { // @ts-ignore thread.fetchEditsWhereNeeded = () => Promise.resolve(); await thread.addEvent(reply1, false, true); - await allThreads.getLiveTimeline().addEvent(thread.rootEvent!, { toStartOfTimeline: true }); + await allThreads + .getLiveTimeline() + .addEvent(thread.rootEvent!, { toStartOfTimeline: true, addToState: true }); const replyToEvent = jest.spyOn(thread, "replyToEvent", "get"); const dom = render( @@ -907,7 +911,9 @@ describe("TimelinePanel", () => { // @ts-ignore realThread.fetchEditsWhereNeeded = () => Promise.resolve(); await realThread.addEvent(reply1, true); - await allThreads.getLiveTimeline().addEvent(realThread.rootEvent!, { toStartOfTimeline: true }); + await allThreads + .getLiveTimeline() + .addEvent(realThread.rootEvent!, { toStartOfTimeline: true, addToState: true }); const replyToEvent = jest.spyOn(realThread, "replyToEvent", "get"); // @ts-ignore @@ -968,7 +974,9 @@ describe("TimelinePanel", () => { events.push(rootEvent); - events.forEach((event) => timelineSet.getLiveTimeline().addEvent(event, { toStartOfTimeline: true })); + events.forEach((event) => + timelineSet.getLiveTimeline().addEvent(event, { toStartOfTimeline: true, addToState: true }), + ); const roomMembership = mkMembership({ mship: KnownMembership.Join, @@ -988,7 +996,10 @@ describe("TimelinePanel", () => { jest.spyOn(roomState, "getMember").mockReturnValue(member); jest.spyOn(timelineSet.getLiveTimeline(), "getState").mockReturnValue(roomState); - timelineSet.addEventToTimeline(roomMembership, timelineSet.getLiveTimeline(), { toStartOfTimeline: false }); + timelineSet.addEventToTimeline(roomMembership, timelineSet.getLiveTimeline(), { + toStartOfTimeline: false, + addToState: true, + }); for (const event of events) { jest.spyOn(event, "isDecryptionFailure").mockReturnValue(true); @@ -1021,7 +1032,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/UserMenu-test.tsx b/test/unit-tests/components/structures/UserMenu-test.tsx index ac76aba2ad..907bf664b7 100644 --- a/test/unit-tests/components/structures/UserMenu-test.tsx +++ b/test/unit-tests/components/structures/UserMenu-test.tsx @@ -7,20 +7,14 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { act, render, RenderResult, screen, waitFor } from "jest-matrix-react"; -import { DEVICE_CODE_SCOPE, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { render, screen, waitFor } from "jest-matrix-react"; +import { DEVICE_CODE_SCOPE, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { CryptoApi } from "matrix-js-sdk/src/crypto-api"; import { mocked } from "jest-mock"; import fetchMock from "fetch-mock-jest"; import UnwrappedUserMenu from "../../../../src/components/structures/UserMenu"; import { stubClient, wrapInSdkContext } from "../../../test-utils"; -import { - VoiceBroadcastInfoState, - VoiceBroadcastRecording, - VoiceBroadcastRecordingsStore, -} from "../../../../src/voice-broadcast"; -import { mkVoiceBroadcastInfoStateEvent } from "../../voice-broadcast/utils/test-utils"; import { TestSdkContext } from "../../TestSdkContext"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import LogoutDialog from "../../../../src/components/views/dialogs/LogoutDialog"; @@ -34,71 +28,12 @@ import { UserTab } from "../../../../src/components/views/dialogs/UserTab"; describe("", () => { let client: MatrixClient; - let renderResult: RenderResult; let sdkContext: TestSdkContext; beforeEach(() => { sdkContext = new TestSdkContext(); }); - describe(" when video broadcast", () => { - let voiceBroadcastInfoEvent: MatrixEvent; - let voiceBroadcastRecording: VoiceBroadcastRecording; - let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore; - - beforeAll(() => { - client = stubClient(); - voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent( - "!room:example.com", - VoiceBroadcastInfoState.Started, - client.getUserId() || "", - client.getDeviceId() || "", - ); - }); - - beforeEach(() => { - voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore(); - sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore; - - voiceBroadcastRecording = new VoiceBroadcastRecording(voiceBroadcastInfoEvent, client); - }); - - describe("when rendered", () => { - beforeEach(() => { - const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - renderResult = render(); - }); - - it("should render as expected", () => { - expect(renderResult.container).toMatchSnapshot(); - }); - - describe("and a live voice broadcast starts", () => { - beforeEach(() => { - act(() => { - voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording); - }); - }); - - it("should render the live voice broadcast avatar addon", () => { - expect(renderResult.queryByTestId("user-menu-live-vb")).toBeInTheDocument(); - }); - - describe("and the broadcast ends", () => { - beforeEach(() => { - act(() => { - voiceBroadcastRecordingsStore.clearCurrent(); - }); - }); - - it("should not render the live voice broadcast avatar addon", () => { - expect(renderResult.queryByTestId("user-menu-live-vb")).not.toBeInTheDocument(); - }); - }); - }); - }); - }); - describe(" logout", () => { beforeEach(() => { client = stubClient(); @@ -106,7 +41,7 @@ describe("", () => { it("should logout directly if no crypto", async () => { const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - renderResult = render(); + render(); mocked(client.getRooms).mockReturnValue([ { @@ -128,7 +63,7 @@ describe("", () => { it("should logout directly if no encrypted rooms", async () => { const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - renderResult = render(); + render(); mocked(client.getRooms).mockReturnValue([ { @@ -152,7 +87,7 @@ describe("", () => { it("should show dialog if some encrypted rooms", async () => { const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - renderResult = render(); + render(); mocked(client.getRooms).mockReturnValue([ { 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
`; +exports[`RoomView should not display the timeline when the room encryption is loading 1`] = ` + +
+
+
+
+ + +
+ + + + +
+
+
+
+ 0 +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +`; + +exports[`RoomView should not display the timeline when the room encryption is loading 2`] = ` + +
+
+
+
+ + +
+ + + + +
+
+
+
+ 0 +
+
+
+
+
+
+
+
+
+
    +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +`; + exports[`RoomView should show error view if failed to look up room alias 1`] = `
- !10:example.org + !13:example.org
@@ -1370,7 +1935,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = ` >
-
- -
-
-
-
{ @@ -77,7 +77,7 @@ describe("CreateSecretStorageDialog", () => { filterConsole("Error fetching backup data from server"); it("shows an error", async () => { - mockClient.getKeyBackupVersion.mockImplementation(async () => { + jest.spyOn(mockClient.getCrypto()!, "getKeyBackupInfo").mockImplementation(async () => { throw new Error("bleh bleh"); }); @@ -92,7 +92,7 @@ describe("CreateSecretStorageDialog", () => { expect(result.container).toMatchSnapshot(); // Now we can get the backup and we retry - mockClient.getKeyBackupVersion.mockRestore(); + jest.spyOn(mockClient.getCrypto()!, "getKeyBackupInfo").mockRestore(); await userEvent.click(screen.getByRole("button", { name: "Retry" })); await screen.findByText("Your keys are now being backed up from this device."); }); @@ -125,6 +125,7 @@ describe("CreateSecretStorageDialog", () => { resetFunctionCallLog.push("resetKeyBackup"); }); + await flushPromises(); result.getByRole("button", { name: "Continue" }).click(); await result.findByText("Your keys are now being backed up from this device."); diff --git a/test/unit-tests/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx b/test/unit-tests/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx index b0ee3531e2..6e8837c50d 100644 --- a/test/unit-tests/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { screen, fireEvent, render, waitFor } from "jest-matrix-react"; +import { screen, fireEvent, render, waitFor, act } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { Crypto, IMegolmSessionData } from "matrix-js-sdk/src/matrix"; @@ -23,12 +23,12 @@ describe("ExportE2eKeysDialog", () => { expect(asFragment()).toMatchSnapshot(); }); - it("should have disabled submit button initially", () => { + it("should have disabled submit button initially", async () => { const cli = createTestClient(); const onFinished = jest.fn(); const { container } = render(); - fireEvent.click(container.querySelector("[type=submit]")!); - expect(screen.getByText("Enter passphrase")).toBeInTheDocument(); + await act(() => fireEvent.click(container.querySelector("[type=submit]")!)); + expect(screen.getByLabelText("Enter passphrase")).toBeInTheDocument(); }); it("should complain about weak passphrases", async () => { @@ -38,7 +38,7 @@ describe("ExportE2eKeysDialog", () => { const { container } = render(); const input = screen.getByLabelText("Enter passphrase"); await userEvent.type(input, "password"); - fireEvent.click(container.querySelector("[type=submit]")!); + await act(() => fireEvent.click(container.querySelector("[type=submit]")!)); await expect(screen.findByText("This is a top-10 common password")).resolves.toBeInTheDocument(); }); @@ -49,7 +49,7 @@ describe("ExportE2eKeysDialog", () => { const { container } = render(); await userEvent.type(screen.getByLabelText("Enter passphrase"), "ThisIsAMoreSecurePW123$$"); await userEvent.type(screen.getByLabelText("Confirm passphrase"), "ThisIsAMoreSecurePW124$$"); - fireEvent.click(container.querySelector("[type=submit]")!); + await act(() => fireEvent.click(container.querySelector("[type=submit]")!)); await expect(screen.findByText("Passphrases must match")).resolves.toBeInTheDocument(); }); @@ -74,7 +74,7 @@ describe("ExportE2eKeysDialog", () => { const { container } = render(); await userEvent.type(screen.getByLabelText("Enter passphrase"), passphrase); await userEvent.type(screen.getByLabelText("Confirm passphrase"), passphrase); - fireEvent.click(container.querySelector("[type=submit]")!); + await act(() => fireEvent.click(container.querySelector("[type=submit]")!)); // Then it exports keys and encrypts them await waitFor(() => expect(exportRoomKeysAsJson).toHaveBeenCalled()); diff --git a/test/unit-tests/components/views/dialogs/security/RestoreKeyBackupDialog-test.tsx b/test/unit-tests/components/views/dialogs/security/RestoreKeyBackupDialog-test.tsx index 4cfa74073b..c19010a089 100644 --- a/test/unit-tests/components/views/dialogs/security/RestoreKeyBackupDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/security/RestoreKeyBackupDialog-test.tsx @@ -28,7 +28,7 @@ describe("", () => { beforeEach(() => { matrixClient = stubClient(); jest.spyOn(recoveryKeyModule, "decodeRecoveryKey").mockReturnValue(new Uint8Array(32)); - jest.spyOn(matrixClient, "getKeyBackupVersion").mockResolvedValue({ version: "1" } as KeyBackupInfo); + jest.spyOn(matrixClient.getCrypto()!, "getKeyBackupInfo").mockResolvedValue({ version: "1" } as KeyBackupInfo); }); it("should render", async () => { @@ -99,7 +99,7 @@ describe("", () => { test("should restore key backup when passphrase is filled", async () => { // Determine that the passphrase is required - jest.spyOn(matrixClient, "getKeyBackupVersion").mockResolvedValue({ + jest.spyOn(matrixClient.getCrypto()!, "getKeyBackupInfo").mockResolvedValue({ version: "1", auth_data: { private_key_salt: "salt", diff --git a/test/unit-tests/components/views/elements/AppTile-test.tsx b/test/unit-tests/components/views/elements/AppTile-test.tsx index 95ce95d3f4..12363f56f0 100644 --- a/test/unit-tests/components/views/elements/AppTile-test.tsx +++ b/test/unit-tests/components/views/elements/AppTile-test.tsx @@ -10,7 +10,7 @@ import React from "react"; import { Room, MatrixClient } from "matrix-js-sdk/src/matrix"; import { ClientWidgetApi, IWidget, MatrixWidgetType } from "matrix-widget-api"; import { Optional } from "matrix-events-sdk"; -import { act, render, RenderResult } from "jest-matrix-react"; +import { act, render, RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { ApprovalOpts, @@ -29,7 +29,6 @@ import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext import SettingsStore from "../../../../../src/settings/SettingsStore"; import { RightPanelPhases } from "../../../../../src/stores/right-panel/RightPanelStorePhases"; import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelStore"; -import { UPDATE_EVENT } from "../../../../../src/stores/AsyncStore"; import WidgetStore, { IApp } from "../../../../../src/stores/WidgetStore"; import ActiveWidgetStore from "../../../../../src/stores/ActiveWidgetStore"; import AppTile from "../../../../../src/components/views/elements/AppTile"; @@ -59,16 +58,6 @@ describe("AppTile", () => { let app1: IApp; let app2: IApp; - const waitForRps = (roomId: string) => - new Promise((resolve) => { - const update = () => { - if (RightPanelStore.instance.currentCardForRoom(roomId).phase !== RightPanelPhases.Widget) return; - RightPanelStore.instance.off(UPDATE_EVENT, update); - resolve(); - }; - RightPanelStore.instance.on(UPDATE_EVENT, update); - }); - beforeAll(async () => { stubClient(); cli = MatrixClientPeg.safeGet(); @@ -160,29 +149,28 @@ describe("AppTile", () => { /> , ); - // Wait for RPS room 1 updates to fire - const rpsUpdated = waitForRps("r1"); - dis.dispatch({ - action: Action.ViewRoom, - room_id: "r1", - }); - await rpsUpdated; + act(() => + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r1", + }), + ); - expect(renderResult.getByText("Example 1")).toBeInTheDocument(); + await expect(renderResult.findByText("Example 1")).resolves.toBeInTheDocument(); expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(true); - const { container, asFragment } = renderResult; - expect(container.getElementsByClassName("mx_Spinner").length).toBeTruthy(); + const { asFragment } = renderResult; expect(asFragment()).toMatchSnapshot(); - // We want to verify that as we change to room 2, we should close the // right panel and destroy the widget. // Switch to room 2 - dis.dispatch({ - action: Action.ViewRoom, - room_id: "r2", - }); + act(() => + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r2", + }), + ); renderResult.rerender( @@ -233,16 +221,17 @@ describe("AppTile", () => { /> , ); - // Wait for RPS room 1 updates to fire - const rpsUpdated1 = waitForRps("r1"); - dis.dispatch({ - action: Action.ViewRoom, - room_id: "r1", - }); - await rpsUpdated1; + act(() => + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r1", + }), + ); - expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(true); - expect(ActiveWidgetStore.instance.isLive("1", "r2")).toBe(false); + await waitFor(() => { + expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(true); + expect(ActiveWidgetStore.instance.isLive("1", "r2")).toBe(false); + }); jest.spyOn(SettingsStore, "getValue").mockImplementation((name, roomId) => { if (name === "RightPanel.phases") { @@ -263,13 +252,13 @@ describe("AppTile", () => { } return realGetValue(name, roomId); }); - // Wait for RPS room 2 updates to fire - const rpsUpdated2 = waitForRps("r2"); // Switch to room 2 - dis.dispatch({ - action: Action.ViewRoom, - room_id: "r2", - }); + act(() => + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r2", + }), + ); renderResult.rerender( { /> , ); - await rpsUpdated2; - expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(false); - expect(ActiveWidgetStore.instance.isLive("1", "r2")).toBe(true); + await waitFor(() => { + expect(ActiveWidgetStore.instance.isLive("1", "r1")).toBe(false); + expect(ActiveWidgetStore.instance.isLive("1", "r2")).toBe(true); + }); }); it("preserves non-persisted widget on container move", async () => { @@ -345,7 +335,7 @@ describe("AppTile", () => { let renderResult: RenderResult; let moveToContainerSpy: jest.SpyInstance; - beforeEach(() => { + beforeEach(async () => { renderResult = render( @@ -353,12 +343,12 @@ describe("AppTile", () => { ); moveToContainerSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer"); + await waitForElementToBeRemoved(() => renderResult.queryByRole("progressbar")); }); it("should render", () => { - const { container, asFragment } = renderResult; + const { asFragment } = renderResult; - expect(container.querySelector(".mx_Spinner")).toBeFalsy(); // Assert that the spinner is gone expect(asFragment()).toMatchSnapshot(); // Take a snapshot of the pinned widget }); @@ -459,18 +449,19 @@ describe("AppTile", () => { describe("for a persistent app", () => { let renderResult: RenderResult; - beforeEach(() => { + beforeEach(async () => { renderResult = render( , ); + + await waitForElementToBeRemoved(() => renderResult.queryByRole("progressbar")); }); - it("should render", () => { - const { container, asFragment } = renderResult; + it("should render", async () => { + const { asFragment } = renderResult; - expect(container.querySelector(".mx_Spinner")).toBeFalsy(); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/test/unit-tests/components/views/elements/Pill-test.tsx b/test/unit-tests/components/views/elements/Pill-test.tsx index 24fb2ca5dd..db075b3b6f 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"; @@ -86,7 +86,7 @@ describe("", () => { room: room1Id, msg: "Room 1 Message", }); - room1.addLiveEvents([room1Message]); + room1.addLiveEvents([room1Message], { addToState: true }); room2 = new Room(room2Id, client, user1Id); room2.currentState.setStateEvents([mkRoomMemberJoinEvent(user2Id, room2Id)]); @@ -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/RoomTopic-test.tsx b/test/unit-tests/components/views/elements/RoomTopic-test.tsx index dcf80a95b9..0fe833fe4e 100644 --- a/test/unit-tests/components/views/elements/RoomTopic-test.tsx +++ b/test/unit-tests/components/views/elements/RoomTopic-test.tsx @@ -41,7 +41,7 @@ describe("", () => { ts: 123, event: true, }); - room.addLiveEvents([topicEvent]); + room.addLiveEvents([topicEvent], { addToState: true }); return room; } diff --git a/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap b/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap index b3b5fc3b89..f039d94514 100644 --- a/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap +++ b/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap @@ -60,29 +60,9 @@ exports[`AppTile destroys non-persisted right panel widget on room change 1`] = id="1" >
-
-
-
- Loading… -
-   -
-
-
+
diff --git a/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx b/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx index d069d663b8..e67334ca61 100644 --- a/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx +++ b/test/unit-tests/components/views/emojipicker/EmojiPicker-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { createRef } from "react"; -import { render, waitFor } from "jest-matrix-react"; +import { render, waitFor, act } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import EmojiPicker from "../../../../../src/components/views/emojipicker/EmojiPicker"; @@ -27,12 +27,12 @@ describe("EmojiPicker", function () { // Apply a filter and assert that the HTML has changed //@ts-ignore private access - ref.current!.onChangeFilter("test"); + act(() => ref.current!.onChangeFilter("test")); expect(beforeHtml).not.toEqual(container.innerHTML); // Clear the filter and assert that the HTML matches what it was before filtering //@ts-ignore private access - ref.current!.onChangeFilter(""); + act(() => ref.current!.onChangeFilter("")); await waitFor(() => expect(beforeHtml).toEqual(container.innerHTML)); }); @@ -40,7 +40,7 @@ describe("EmojiPicker", function () { const ep = new EmojiPicker({ onChoose: (str: string) => false, onFinished: jest.fn() }); //@ts-ignore private access - ep.onChangeFilter("heart"); + act(() => ep.onChangeFilter("heart")); //@ts-ignore private access expect(ep.memoizedDataByCategory["people"][0].shortcodes[0]).toEqual("heart"); diff --git a/test/unit-tests/components/views/location/LocationShareMenu-test.tsx b/test/unit-tests/components/views/location/LocationShareMenu-test.tsx index 672580e952..84c5e91ea0 100644 --- a/test/unit-tests/components/views/location/LocationShareMenu-test.tsx +++ b/test/unit-tests/components/views/location/LocationShareMenu-test.tsx @@ -139,7 +139,7 @@ describe("", () => { const [, onGeolocateCallback] = mocked(mockGeolocate.on).mock.calls.find(([event]) => event === "geolocate")!; // set the location - onGeolocateCallback(position); + act(() => onGeolocateCallback(position)); }; const setLocationClick = () => { @@ -151,7 +151,7 @@ describe("", () => { lngLat: { lng: position.coords.longitude, lat: position.coords.latitude }, } as unknown as maplibregl.MapMouseEvent; // set the location - onMapClickCallback(event); + act(() => onMapClickCallback(event)); }; const shareTypeLabels: Record = { diff --git a/test/unit-tests/components/views/messages/DateSeparator-test.tsx b/test/unit-tests/components/views/messages/DateSeparator-test.tsx index 0c953a1738..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 ca5f3d04b9..5788daebc0 100644 --- a/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx +++ b/test/unit-tests/components/views/messages/EncryptionEvent-test.tsx @@ -27,9 +27,9 @@ const renderEncryptionEvent = (client: MatrixClient, event: MatrixEvent) => { ); }; -const checkTexts = (title: string, subTitle: string) => { - screen.getByText(title); - screen.getByText(subTitle); +const checkTexts = async (title: string, subTitle: string) => { + await screen.findByText(title); + await screen.findByText(subTitle); }; describe("EncryptionEvent", () => { @@ -120,9 +120,9 @@ describe("EncryptionEvent", () => { renderEncryptionEvent(client, event); }); - it("should show the expected texts", () => { + it("should show the expected texts", async () => { expect(client.getCrypto()!.isEncryptionEnabledInRoom).toHaveBeenCalledWith(roomId); - checkTexts("Encryption enabled", "Messages in this chat will be end-to-end encrypted."); + await checkTexts("Encryption enabled", "Messages in this chat will be end-to-end encrypted."); }); }); }); diff --git a/test/unit-tests/components/views/messages/MFileBody-test.tsx b/test/unit-tests/components/views/messages/MFileBody-test.tsx index 60795babde..e124e3a2ff 100644 --- a/test/unit-tests/components/views/messages/MFileBody-test.tsx +++ b/test/unit-tests/components/views/messages/MFileBody-test.tsx @@ -20,7 +20,8 @@ import { import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import MFileBody from "../../../../../src/components/views/messages/MFileBody.tsx"; -import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts"; +import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; jest.mock("matrix-encrypt-attachment", () => ({ decryptAttachment: jest.fn(), @@ -72,14 +73,14 @@ describe("", () => { it("should show a download button in file rendering type", async () => { const { container, getByRole } = render( - + - , + , ); expect(getByRole("link", { name: "Download" })).toBeInTheDocument(); diff --git a/test/unit-tests/components/views/messages/MPollBody-test.tsx b/test/unit-tests/components/views/messages/MPollBody-test.tsx index 598542d297..982fadad20 100644 --- a/test/unit-tests/components/views/messages/MPollBody-test.tsx +++ b/test/unit-tests/components/views/messages/MPollBody-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { fireEvent, render, RenderResult, waitFor } from "jest-matrix-react"; +import { act, fireEvent, render, RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react"; import { MatrixEvent, Relations, @@ -226,7 +226,7 @@ describe("MPollBody", () => { clickOption(renderResult, "pizza"); // When a new vote from me comes in - await room.processPollEvents([responseEvent("@me:example.com", "wings", 101)]); + await act(() => room.processPollEvents([responseEvent("@me:example.com", "wings", 101)])); // Then the new vote is counted, not the old one expect(votesCount(renderResult, "pizza")).toBe("0 votes"); @@ -255,7 +255,7 @@ describe("MPollBody", () => { clickOption(renderResult, "pizza"); // When a new vote from someone else comes in - await room.processPollEvents([responseEvent("@xx:example.com", "wings", 101)]); + await act(() => room.processPollEvents([responseEvent("@xx:example.com", "wings", 101)])); // Then my vote is still for pizza // NOTE: the new event does not affect the counts for other people - @@ -596,11 +596,13 @@ describe("MPollBody", () => { ]; const renderResult = await newMPollBody(votes, ends); - expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); - expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); - expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); + await waitFor(() => { + expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); + expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); + expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); + expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); + expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); + }); }); it("ignores votes that arrived after the first end poll event", async () => { @@ -890,12 +892,14 @@ async function newMPollBody( room_id: "#myroom:example.com", content: newPollStart(answers, undefined, disclosed), }); - const result = newMPollBodyFromEvent(mxEvent, relationEvents, endEvents); - // flush promises from loading relations + const prom = newMPollBodyFromEvent(mxEvent, relationEvents, endEvents); if (waitForResponsesLoad) { - await flushPromises(); + const result = await prom; + if (result.queryByTestId("spinner")) { + await waitForElementToBeRemoved(() => result.getByTestId("spinner")); + } } - return result; + return prom; } function getMPollBodyPropsFromEvent(mxEvent: MatrixEvent): IBodyProps { diff --git a/test/unit-tests/components/views/messages/MPollEndBody-test.tsx b/test/unit-tests/components/views/messages/MPollEndBody-test.tsx index e3883b7033..7bd2ad0a08 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/MessageActionBar-test.tsx b/test/unit-tests/components/views/messages/MessageActionBar-test.tsx index 8b639eb94e..dda5b348a9 100644 --- a/test/unit-tests/components/views/messages/MessageActionBar-test.tsx +++ b/test/unit-tests/components/views/messages/MessageActionBar-test.tsx @@ -35,6 +35,7 @@ import dispatcher from "../../../../../src/dispatcher/dispatcher"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { Action } from "../../../../../src/dispatcher/actions"; import PinningUtils from "../../../../../src/utils/PinningUtils"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; jest.mock("../../../../../src/dispatcher/dispatcher"); @@ -117,9 +118,9 @@ describe("", () => { } as unknown as IRoomState; const getComponent = (props = {}, roomContext: Partial = {}) => render( - + - , + , ); beforeEach(() => { diff --git a/test/unit-tests/components/views/messages/MessageEvent-test.tsx b/test/unit-tests/components/views/messages/MessageEvent-test.tsx index 9ee323f175..4ff574f752 100644 --- a/test/unit-tests/components/views/messages/MessageEvent-test.tsx +++ b/test/unit-tests/components/views/messages/MessageEvent-test.tsx @@ -14,7 +14,6 @@ import fs from "fs"; import path from "path"; import SettingsStore from "../../../../../src/settings/SettingsStore"; -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../../../../../src/voice-broadcast"; import { mkEvent, mkRoom, stubClient } from "../../../../test-utils"; import MessageEvent from "../../../../../src/components/views/messages/MessageEvent"; import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; @@ -24,10 +23,6 @@ jest.mock("../../../../../src/components/views/messages/UnknownBody", () => ({ default: () =>
, })); -jest.mock("../../../../../src/voice-broadcast/components/VoiceBroadcastBody", () => ({ - VoiceBroadcastBody: () =>
, -})); - jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({ __esModule: true, default: () =>
, @@ -81,27 +76,6 @@ describe("MessageEvent", () => { jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn()); }); - describe("when a voice broadcast start event occurs", () => { - let result: RenderResult; - - beforeEach(() => { - event = mkEvent({ - event: true, - type: VoiceBroadcastInfoEventType, - user: client.getUserId()!, - room: room.roomId, - content: { - state: VoiceBroadcastInfoState.Started, - }, - }); - result = renderMessageEvent(); - }); - - it("should render a VoiceBroadcast component", () => { - result.getByTestId("voice-broadcast-body"); - }); - }); - describe("when an image with a caption is sent", () => { let result: RenderResult; diff --git a/test/unit-tests/components/views/messages/RoomPredecessorTile-test.tsx b/test/unit-tests/components/views/messages/RoomPredecessorTile-test.tsx index f666b13b33..3ed7d7eb2b 100644 --- a/test/unit-tests/components/views/messages/RoomPredecessorTile-test.tsx +++ b/test/unit-tests/components/views/messages/RoomPredecessorTile-test.tsx @@ -20,9 +20,9 @@ import { } from "../../../../../src/components/views/messages/RoomPredecessorTile"; import { stubClient, upsertRoomStateEvents } from "../../../../test-utils/test-utils"; import { Action } from "../../../../../src/dispatcher/actions"; -import RoomContext from "../../../../../src/contexts/RoomContext"; import { filterConsole, getRoomContext } from "../../../../test-utils"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; jest.mock("../../../../../src/dispatcher/dispatcher"); @@ -99,9 +99,9 @@ describe("", () => { expect(createEvent).toBeTruthy(); return render( - + - , + , ); } 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/RoomSummaryCard-test.tsx b/test/unit-tests/components/views/right_panel/RoomSummaryCard-test.tsx index 4026149f98..9eeec43dba 100644 --- a/test/unit-tests/components/views/right_panel/RoomSummaryCard-test.tsx +++ b/test/unit-tests/components/views/right_panel/RoomSummaryCard-test.tsx @@ -30,7 +30,8 @@ import { _t } from "../../../../../src/languageHandler"; import { tagRoom } from "../../../../../src/utils/room/tagRoom"; import { DefaultTagID } from "../../../../../src/stores/room-list/models"; import { Action } from "../../../../../src/dispatcher/actions"; -import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; +import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; jest.mock("../../../../../src/utils/room/tagRoom"); @@ -172,14 +173,14 @@ describe("", () => { const onSearchChange = jest.fn(); const { rerender } = render( - + - + , ); @@ -188,13 +189,13 @@ describe("", () => { rerender( - + - + , ); expect(screen.getByPlaceholderText("Search messages…")).toHaveValue(""); @@ -254,10 +255,7 @@ describe("", () => { fireEvent.click(getByText("People")); - expect(RightPanelStore.instance.pushCard).toHaveBeenCalledWith( - { phase: RightPanelPhases.RoomMemberList }, - true, - ); + expect(RightPanelStore.instance.pushCard).toHaveBeenCalledWith({ phase: RightPanelPhases.MemberList }, true); }); it("opens room threads list on button click", () => { 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..38d38e2a32 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"; @@ -188,7 +188,7 @@ describe("", () => { const defaultProps = { user: defaultUser, // idk what is wrong with this type - phase: RightPanelPhases.RoomMemberInfo as RightPanelPhases.RoomMemberInfo, + phase: RightPanelPhases.MemberInfo as RightPanelPhases.MemberInfo, onClose: jest.fn(), }; @@ -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" }); @@ -455,13 +455,13 @@ describe("", () => { mockCrypto.getUserVerificationStatus.mockResolvedValue(new UserVerificationStatus(false, false, false)); const { container } = renderComponent({ - phase: RightPanelPhases.SpaceMemberInfo, + phase: RightPanelPhases.MemberInfo, 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" }); @@ -649,11 +649,14 @@ describe("", () => { mockClient.getDomain.mockReturnValue("example.com"); const { container } = renderComponent({ - phase: RightPanelPhases.RoomMemberInfo, + phase: RightPanelPhases.MemberInfo, 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/EditMessageComposer-test.tsx b/test/unit-tests/components/views/rooms/EditMessageComposer-test.tsx index a34e2e895b..beac0c8e1d 100644 --- a/test/unit-tests/components/views/rooms/EditMessageComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/EditMessageComposer-test.tsx @@ -27,12 +27,12 @@ import { import DocumentOffset from "../../../../../src/editor/offset"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; -import RoomContext from "../../../../../src/contexts/RoomContext"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import Autocompleter, { IProviderCompletions } from "../../../../../src/autocomplete/Autocompleter"; import NotifProvider from "../../../../../src/autocomplete/NotifProvider"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; describe("", () => { const userId = "@alice:server.org"; @@ -79,7 +79,7 @@ describe("", () => { render(, { wrapper: ({ children }) => ( - {children} + {children} ), }); @@ -128,7 +128,7 @@ describe("", () => { const expectedBody = { ...editedEvent.getContent(), - "body": " * original message + edit", + "body": "* original message + edit", "m.new_content": { "body": "original message + edit", "msgtype": "m.text", @@ -160,7 +160,7 @@ describe("", () => { const content = createEditContent(model, editedEvent); expect(content).toEqual({ - "body": " * hello world", + "body": "* hello world", "msgtype": "m.text", "m.new_content": { "body": "hello world", @@ -183,10 +183,10 @@ describe("", () => { const content = createEditContent(model, editedEvent); expect(content).toEqual({ - "body": " * hello *world*", + "body": "* hello *world*", "msgtype": "m.text", "format": "org.matrix.custom.html", - "formatted_body": " * hello world", + "formatted_body": "* hello world", "m.new_content": { "body": "hello *world*", "msgtype": "m.text", @@ -210,10 +210,10 @@ describe("", () => { const content = createEditContent(model, editedEvent); expect(content).toEqual({ - "body": " * blinks __quickly__", + "body": "* blinks __quickly__", "msgtype": "m.emote", "format": "org.matrix.custom.html", - "formatted_body": " * blinks quickly", + "formatted_body": "* blinks quickly", "m.new_content": { "body": "blinks __quickly__", "msgtype": "m.emote", @@ -238,7 +238,7 @@ describe("", () => { const content = createEditContent(model, editedEvent); expect(content).toEqual({ - "body": " * ✨sparkles✨", + "body": "* ✨sparkles✨", "msgtype": "m.emote", "m.new_content": { "body": "✨sparkles✨", @@ -264,7 +264,7 @@ describe("", () => { // TODO Edits do not properly strip the double slash used to skip // command processing. expect(content).toEqual({ - "body": " * //dev/null is my favourite place", + "body": "* //dev/null is my favourite place", "msgtype": "m.text", "m.new_content": { "body": "//dev/null is my favourite place", diff --git a/test/unit-tests/components/views/rooms/EventTile-test.tsx b/test/unit-tests/components/views/rooms/EventTile-test.tsx index b2835d15c0..93445efbe3 100644 --- a/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -30,7 +30,7 @@ import { mkEncryptedMatrixEvent } from "matrix-js-sdk/src/testing"; import EventTile, { EventTileProps } from "../../../../../src/components/views/rooms/EventTile"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; +import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { filterConsole, flushPromises, getRoomContext, mkEvent, mkMessage, stubClient } from "../../../../test-utils"; import { mkThread } from "../../../../test-utils/threads"; @@ -40,6 +40,7 @@ import { Action } from "../../../../../src/dispatcher/actions"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; import PinningUtils from "../../../../../src/utils/PinningUtils"; import { Layout } from "../../../../../src/settings/enums/Layout"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; describe("EventTile", () => { const ROOM_ID = "!roomId:example.org"; @@ -56,13 +57,13 @@ describe("EventTile", () => { }) { return ( - + - + ); } @@ -70,9 +71,11 @@ describe("EventTile", () => { function getComponent( overrides: Partial = {}, renderingType: TimelineRenderingType = TimelineRenderingType.Room, + roomContext: Partial = {}, ) { const context = getRoomContext(room, { timelineRenderingType: renderingType, + ...roomContext, }); return render(); } @@ -260,7 +263,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 +288,7 @@ describe("EventTile", () => { } as EventEncryptionInfo); const { container } = getComponent(); - await act(flushPromises); + await flushPromises(); const eventTiles = container.getElementsByClassName("mx_EventTile"); expect(eventTiles).toHaveLength(1); @@ -301,6 +304,8 @@ describe("EventTile", () => { [EventShieldReason.UNKNOWN_DEVICE, "unknown or deleted device"], [EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, "can't be guaranteed"], [EventShieldReason.MISMATCHED_SENDER_KEY, "Encrypted by an unverified session"], + [EventShieldReason.SENT_IN_CLEAR, "Not encrypted"], + [EventShieldReason.VERIFICATION_VIOLATION, "Sender's verified identity has changed"], ])("shows the correct reason code for %i (%s)", async (reasonCode: EventShieldReason, expectedText: string) => { mxEvent = await mkEncryptedMatrixEvent({ plainContent: { msgtype: "m.text", body: "msg1" }, @@ -314,7 +319,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 +351,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 +405,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); @@ -434,8 +439,6 @@ describe("EventTile", () => { }); it("should update the warning when the event is replaced with an unencrypted one", async () => { - jest.spyOn(client, "isRoomEncrypted").mockReturnValue(true); - // we start out with an event from the trusted device mxEvent = await mkEncryptedMatrixEvent({ plainContent: { msgtype: "m.text", body: "msg1" }, @@ -449,9 +452,9 @@ describe("EventTile", () => { shieldReason: null, } as EventEncryptionInfo); - const roomContext = getRoomContext(room, {}); + const roomContext = getRoomContext(room, { isRoomEncrypted: true }); const { container, rerender } = render(); - await act(flushPromises); + await flushPromises(); const eventTiles = container.getElementsByClassName("mx_EventTile"); expect(eventTiles).toHaveLength(1); @@ -578,4 +581,28 @@ describe("EventTile", () => { }); }); }); + + it("should display the not encrypted status for an unencrypted event when the room becomes encrypted", async () => { + jest.spyOn(client.getCrypto()!, "getEncryptionInfoForEvent").mockResolvedValue({ + shieldColour: EventShieldColour.NONE, + shieldReason: null, + }); + + const { rerender } = getComponent(); + await flushPromises(); + // The room and the event are unencrypted, the tile should not show the not encrypted status + expect(screen.queryByText("Not encrypted")).toBeNull(); + + // The room is now encrypted + rerender( + , + ); + + // The event tile should now show the not encrypted status + await waitFor(() => expect(screen.getByText("Not encrypted")).toBeInTheDocument()); + }); }); 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..6849612fee 100644 --- a/test/unit-tests/components/views/rooms/MessageComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/MessageComposer-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import * as React from "react"; -import { EventType, MatrixEvent, Room, RoomMember, THREAD_RELATION_TYPE } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixEvent, RoomMember, THREAD_RELATION_TYPE } from "matrix-js-sdk/src/matrix"; import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; @@ -19,17 +19,14 @@ import { mkStubRoom, mockPlatformPeg, stubClient, - waitEnoughCyclesForModal, } from "../../../../test-utils"; import MessageComposer from "../../../../../src/components/views/rooms/MessageComposer"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import RoomContext from "../../../../../src/contexts/RoomContext"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../../../src/utils/ResizeNotifier"; import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; import { LocalRoom } from "../../../../../src/models/LocalRoom"; -import { Features } from "../../../../../src/settings/Settings"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../../../src/settings/SettingLevel"; import dis from "../../../../../src/dispatcher/dispatcher"; @@ -37,31 +34,16 @@ import { E2EStatus } from "../../../../../src/utils/ShieldUtils"; import { addTextToComposerRTL } from "../../../../test-utils/composer"; import UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore"; import { Action } from "../../../../../src/dispatcher/actions"; -import { VoiceBroadcastInfoState, VoiceBroadcastRecording } from "../../../../../src/voice-broadcast"; -import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils"; -import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; 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")); - }); -}; - -const setCurrentBroadcastRecording = (room: Room, state: VoiceBroadcastInfoState): void => { - const recording = new VoiceBroadcastRecording( - mkVoiceBroadcastInfoStateEvent(room.roomId, state, "@user:example.com", "ABC123"), - MatrixClientPeg.safeGet(), - state, - ); - SdkContextClass.instance.voiceBroadcastRecordingsStore.setCurrent(recording); + await userEvent.click(screen.getByLabelText("More options")); + await userEvent.click(screen.getByLabelText("Voice Message")); }; const expectVoiceMessageRecordingTriggered = (): void => { @@ -82,14 +64,11 @@ describe("MessageComposer", () => { await clearAllModals(); jest.useRealTimers(); - SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent(); - // restore settings act(() => { [ "MessageComposerInput.showStickersButton", "MessageComposerInput.showPollsButton", - Features.VoiceBroadcast, "feature_wysiwyg_composer", ].forEach((setting: string): void => { SettingsStore.setValue(setting, null, SettingLevel.DEVICE, SettingsStore.getDefaultValue(setting)); @@ -97,6 +76,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); @@ -177,22 +195,16 @@ describe("MessageComposer", () => { setting: "MessageComposerInput.showPollsButton", buttonLabel: "Poll", }, - { - setting: Features.VoiceBroadcast, - buttonLabel: "Voice broadcast", - }, ].forEach(({ setting, buttonLabel }) => { [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 +217,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 +287,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 +309,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", () => { @@ -402,34 +416,6 @@ describe("MessageComposer", () => { expectVoiceMessageRecordingTriggered(); }); }); - - describe("when recording a voice broadcast and trying to start a voice message", () => { - beforeEach(async () => { - setCurrentBroadcastRecording(room, VoiceBroadcastInfoState.Started); - wrapAndRender({ room }); - await startVoiceMessage(); - await waitEnoughCyclesForModal(); - }); - - it("should not start a voice message and display the info dialog", async () => { - expect(screen.queryByLabelText("Stop recording")).not.toBeInTheDocument(); - expect(screen.getByText("Can't start voice message")).toBeInTheDocument(); - }); - }); - - describe("when there is a stopped voice broadcast recording and trying to start a voice message", () => { - beforeEach(async () => { - setCurrentBroadcastRecording(room, VoiceBroadcastInfoState.Stopped); - wrapAndRender({ room }); - await startVoiceMessage(); - await waitEnoughCyclesForModal(); - }); - - it("should try to start a voice message and should not display the info dialog", async () => { - expect(screen.queryByText("Can't start voice message")).not.toBeInTheDocument(); - expectVoiceMessageRecordingTriggered(); - }); - }); }); describe("for a LocalRoom", () => { @@ -443,51 +429,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( @@ -522,9 +463,9 @@ function wrapAndRender( const getRawComponent = (props = {}, context = roomContext, client = mockClient) => ( - + - + ); return { diff --git a/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx b/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx index a66dc0d925..c2f56dc968 100644 --- a/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx +++ b/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx @@ -10,11 +10,11 @@ import React from "react"; import { render, screen, waitFor } from "jest-matrix-react"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import RoomContext from "../../../../../src/contexts/RoomContext"; import { createTestClient, getRoomContext, mkStubRoom } from "../../../../test-utils"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; describe("MessageComposerButtons", () => { // @ts-ignore - we're deliberately not implementing the whole interface here, but @@ -54,7 +54,7 @@ describe("MessageComposerButtons", () => { return render( - {component} + {component} , ); } @@ -168,27 +168,4 @@ describe("MessageComposerButtons", () => { ]); }); }); - - describe("with showVoiceBroadcastButton = true", () => { - it("should render the »Voice broadcast« button", () => { - wrapAndRender( - , - false, - ); - - expect(getButtonLabels()).toEqual([ - "Emoji", - "Attachment", - "More options", - ["Sticker", "Voice Message", "Voice broadcast", "Poll", "Location"], - ]); - }); - }); }); diff --git a/test/unit-tests/components/views/rooms/NewRoomIntro-test.tsx b/test/unit-tests/components/views/rooms/NewRoomIntro-test.tsx index 291d28c967..a1cd452610 100644 --- a/test/unit-tests/components/views/rooms/NewRoomIntro-test.tsx +++ b/test/unit-tests/components/views/rooms/NewRoomIntro-test.tsx @@ -13,19 +13,19 @@ import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { LocalRoom } from "../../../../../src/models/LocalRoom"; import { filterConsole, mkRoomMemberJoinEvent, mkThirdPartyInviteEvent, stubClient } from "../../../../test-utils"; -import RoomContext from "../../../../../src/contexts/RoomContext"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import NewRoomIntro from "../../../../../src/components/views/rooms/NewRoomIntro"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import { DirectoryMember } from "../../../../../src/utils/direct-messages"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; const renderNewRoomIntro = (client: MatrixClient, room: Room | LocalRoom) => { render( - + - + , ); }; diff --git a/test/unit-tests/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx b/test/unit-tests/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx index ea54dc1085..5b2dddc73a 100644 --- a/test/unit-tests/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx +++ b/test/unit-tests/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx @@ -165,7 +165,7 @@ describe("UnreadNotificationBadge", () => { }, ts: 5, }); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: true }); const { container } = render(getComponent(THREAD_ID)); expect(container.querySelector(".mx_NotificationBadge_dot")).toBeTruthy(); diff --git a/test/unit-tests/components/views/rooms/PinnedMessageBanner-test.tsx b/test/unit-tests/components/views/rooms/PinnedMessageBanner-test.tsx index f59b9f3a9f..8d380c76bb 100644 --- a/test/unit-tests/components/views/rooms/PinnedMessageBanner-test.tsx +++ b/test/unit-tests/components/views/rooms/PinnedMessageBanner-test.tsx @@ -224,7 +224,7 @@ describe("", () => { // The Right panel is opened on another card jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true); jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({ - phase: RightPanelPhases.RoomMemberList, + phase: RightPanelPhases.MemberList, }); renderBanner(); diff --git a/test/unit-tests/components/views/rooms/RoomHeader-test.tsx b/test/unit-tests/components/views/rooms/RoomHeader-test.tsx index 1be9c77713..8b9b0d9848 100644 --- a/test/unit-tests/components/views/rooms/RoomHeader-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomHeader-test.tsx @@ -158,7 +158,7 @@ describe("RoomHeader", () => { fireEvent.click(facePile); - expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomMemberList }); + expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.MemberList }); }); it("has room info icon that opens the room info panel", async () => { @@ -589,7 +589,7 @@ describe("RoomHeader", () => { state_key: "", room_id: room.roomId, }); - room.addLiveEvents([joinRuleEvent]); + room.addLiveEvents([joinRuleEvent], { addToState: true }); render(, getWrapper()); diff --git a/test/unit-tests/components/views/rooms/RoomTile-test.tsx b/test/unit-tests/components/views/rooms/RoomTile-test.tsx index 7aa9ad8462..c14c699699 100644 --- a/test/unit-tests/components/views/rooms/RoomTile-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomTile-test.tsx @@ -9,14 +9,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { render, screen, act, RenderResult } from "jest-matrix-react"; import { mocked, Mocked } from "jest-mock"; -import { - MatrixClient, - PendingEventOrdering, - Room, - MatrixEvent, - RoomStateEvent, - Thread, -} from "matrix-js-sdk/src/matrix"; +import { MatrixClient, PendingEventOrdering, Room, RoomStateEvent, Thread } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { Widget } from "matrix-widget-api"; @@ -40,8 +33,6 @@ import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import PlatformPeg from "../../../../../src/PlatformPeg"; import BasePlatform from "../../../../../src/BasePlatform"; import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore"; -import { VoiceBroadcastInfoState } from "../../../../../src/voice-broadcast"; -import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils"; import { TestSdkContext } from "../../../TestSdkContext"; import { SDKContext } from "../../../../../src/contexts/SDKContext"; import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents"; @@ -61,20 +52,6 @@ describe("RoomTile", () => { } as unknown as BasePlatform); useMockedCalls(); - const setUpVoiceBroadcast = async (state: VoiceBroadcastInfoState): Promise => { - voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent( - room.roomId, - state, - client.getSafeUserId(), - client.getDeviceId()!, - ); - - await act(async () => { - room.currentState.setStateEvents([voiceBroadcastInfoEvent]); - await flushPromises(); - }); - }; - const renderRoomTile = (): RenderResult => { return render( @@ -89,7 +66,6 @@ describe("RoomTile", () => { }; let client: Mocked; - let voiceBroadcastInfoEvent: MatrixEvent; let room: Room; let sdkContext: TestSdkContext; let showMessagePreview = false; @@ -303,49 +279,6 @@ describe("RoomTile", () => { }); expect(screen.queryByLabelText(/participant/)).toBe(null); }); - - describe("and a live broadcast starts", () => { - beforeEach(async () => { - renderRoomTile(); - await setUpVoiceBroadcast(VoiceBroadcastInfoState.Started); - }); - - it("should still render the call subtitle", () => { - expect(screen.queryByText("Video")).toBeInTheDocument(); - expect(screen.queryByText("Live")).not.toBeInTheDocument(); - }); - }); - }); - - describe("when a live voice broadcast starts", () => { - beforeEach(async () => { - renderRoomTile(); - await setUpVoiceBroadcast(VoiceBroadcastInfoState.Started); - }); - - it("should render the »Live« subtitle", () => { - expect(screen.queryByText("Live")).toBeInTheDocument(); - }); - - describe("and the broadcast stops", () => { - beforeEach(async () => { - const stopEvent = mkVoiceBroadcastInfoStateEvent( - room.roomId, - VoiceBroadcastInfoState.Stopped, - client.getSafeUserId(), - client.getDeviceId()!, - voiceBroadcastInfoEvent, - ); - await act(async () => { - room.currentState.setStateEvents([stopEvent]); - await flushPromises(); - }); - }); - - it("should not render the »Live« subtitle", () => { - expect(screen.queryByText("Live")).not.toBeInTheDocument(); - }); - }); }); }); diff --git a/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx b/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx index e423d03ea9..c372819ce2 100644 --- a/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx @@ -18,7 +18,7 @@ import SendMessageComposer, { isQuickReaction, } from "../../../../../src/components/views/rooms/SendMessageComposer"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; +import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import EditorModel from "../../../../../src/editor/model"; import { createPartCreator } from "../../../editor/mock"; import { createTestClient, mkEvent, mkStubRoom, stubClient } from "../../../../test-utils"; @@ -30,6 +30,7 @@ import { IRoomState, MainSplitContentType } from "../../../../../src/components/ import { mockPlatformPeg } from "../../../../test-utils/platform"; import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room"; import { addTextToComposer } from "../../../../test-utils/composer"; +import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; jest.mock("../../../../../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), @@ -77,6 +78,7 @@ describe("", () => { canAskToJoin: false, promptAskToJoin: false, viewRoomOpts: { buttons: [] }, + isRoomEncrypted: false, }; describe("createMessageContent", () => { it("sends plaintext messages correctly", () => { @@ -364,9 +366,9 @@ describe("", () => { }; const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => ( - + - + ); const getComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => { @@ -385,7 +387,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 +404,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 +415,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..a76c7a8b90 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,10 +8,9 @@ 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"; import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../../src/dispatcher/actions"; import { flushPromises, mkEvent } from "../../../../../test-utils"; @@ -23,6 +22,7 @@ import { ComposerInsertPayload, ComposerType } from "../../../../../../src/dispa import { ActionPayload } from "../../../../../../src/dispatcher/payloads"; import * as EmojiButton from "../../../../../../src/components/views/rooms/EmojiButton"; import { createMocks } from "./utils"; +import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx"; describe("EditWysiwygComposer", () => { afterEach(() => { @@ -39,9 +39,9 @@ describe("EditWysiwygComposer", () => { ) => { return render( - + - + , ); }; @@ -64,9 +64,9 @@ describe("EditWysiwygComposer", () => { rerender( - + - + , ); @@ -196,9 +196,9 @@ describe("EditWysiwygComposer", () => { // Then screen.getByText("Save").click(); const expectedContent = { - "body": ` * foo bar`, + "body": `* foo bar`, "format": "org.matrix.custom.html", - "formatted_body": ` * foo bar`, + "formatted_body": `* foo bar`, "m.new_content": { body: "foo bar", format: "org.matrix.custom.html", @@ -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(); @@ -277,10 +275,10 @@ describe("EditWysiwygComposer", () => { ); render( - + - + , ); // Same behavior as in RoomView.tsx diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index 27110149f5..89415448b7 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -11,7 +11,6 @@ import React from "react"; import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react"; import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; -import RoomContext from "../../../../../../src/contexts/RoomContext"; import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../../src/dispatcher/actions"; import { flushPromises } from "../../../../../test-utils"; @@ -20,6 +19,7 @@ import { aboveLeftOf } from "../../../../../../src/components/structures/Context import { ComposerInsertPayload, ComposerType } from "../../../../../../src/dispatcher/payloads/ComposerInsertPayload"; import { setSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection"; import { createMocks } from "./utils"; +import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx"; jest.mock("../../../../../../src/components/views/rooms/EmojiButton", () => ({ EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => { @@ -66,7 +66,7 @@ describe("SendWysiwygComposer", () => { ) => { return render( - + { menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })} placeholder={placeholder} /> - + , ); }; diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx index 1ca6e9736c..ae37afe860 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx @@ -14,7 +14,7 @@ import { PlainTextComposer } from "../../../../../../../src/components/views/roo import * as mockUseSettingsHook from "../../../../../../../src/hooks/useSettings"; import * as mockKeyboard from "../../../../../../../src/Keyboard"; import { createMocks } from "../utils"; -import RoomContext from "../../../../../../../src/contexts/RoomContext"; +import { ScopedRoomContextProvider } from "../../../../../../../src/contexts/ScopedRoomContext.tsx"; describe("PlainTextComposer", () => { const customRender = ( @@ -275,9 +275,9 @@ describe("PlainTextComposer", () => { const { defaultRoomContext } = createMocks(); render( - + - , + , ); expect(screen.getByTestId("autocomplete-wrapper")).toBeInTheDocument(); diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx index 68ce88ce0c..b0f4bbb5bd 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete-test.tsx @@ -11,12 +11,12 @@ import React, { createRef } from "react"; import { render, screen, waitFor } from "jest-matrix-react"; import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext"; -import RoomContext from "../../../../../../../src/contexts/RoomContext"; import { WysiwygAutocomplete } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete"; import { getRoomContext, mkStubRoom, stubClient } from "../../../../../../test-utils"; import Autocomplete from "../../../../../../../src/components/views/rooms/Autocomplete"; import Autocompleter, { ICompletion } from "../../../../../../../src/autocomplete/Autocompleter"; import AutocompleteProvider from "../../../../../../../src/autocomplete/AutocompleteProvider"; +import { ScopedRoomContextProvider } from "../../../../../../../src/contexts/ScopedRoomContext.tsx"; const mockCompletion: ICompletion[] = [ { @@ -71,7 +71,7 @@ describe("WysiwygAutocomplete", () => { return render( - + { handleAtRoomMention={mockHandleAtRoomMention} {...props} /> - + , ); }; diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx index adfd1412a7..c3c480eb82 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -18,7 +18,6 @@ import defaultDispatcher from "../../../../../../../src/dispatcher/dispatcher"; import * as EventUtils from "../../../../../../../src/utils/EventUtils"; import { Action } from "../../../../../../../src/dispatcher/actions"; import MatrixClientContext from "../../../../../../../src/contexts/MatrixClientContext"; -import RoomContext from "../../../../../../../src/contexts/RoomContext"; import { ComposerContext, getDefaultContextValue, @@ -32,20 +31,21 @@ import Autocompleter, { ICompletion } from "../../../../../../../src/autocomplet import AutocompleteProvider from "../../../../../../../src/autocomplete/AutocompleteProvider"; import * as Permalinks from "../../../../../../../src/utils/permalinks/Permalinks"; import { PermalinkParts } from "../../../../../../../src/utils/permalinks/PermalinkConstructor"; +import { ScopedRoomContextProvider } from "../../../../../../../src/contexts/ScopedRoomContext.tsx"; describe("WysiwygComposer", () => { const customRender = (onChange = jest.fn(), onSend = jest.fn(), disabled = false, initialContent?: string) => { const { mockClient, defaultRoomContext } = createMocks(); return render( - + - + , ); }; @@ -523,7 +523,7 @@ describe("WysiwygComposer", () => { ) => { return render( - + @@ -537,7 +537,7 @@ describe("WysiwygComposer", () => { } /> - + , ); }; diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts b/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts index f0d9ed73c1..74852e9c6d 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts @@ -88,9 +88,9 @@ describe("createMessageContent", () => { // Then expect(content).toEqual({ - "body": " * *__hello__ world*", + "body": "* *__hello__ world*", "format": "org.matrix.custom.html", - "formatted_body": ` * ${message}`, + "formatted_body": `* ${message}`, "msgtype": "m.text", "m.new_content": { body: "*__hello__ world*", diff --git a/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts b/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts index c339978f92..43320b0444 100644 --- a/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts +++ b/test/unit-tests/components/views/rooms/wysiwyg_composer/utils/message-test.ts @@ -418,8 +418,8 @@ describe("message", () => { // Then const { msgtype, format } = mockEvent.getContent(); const expectedContent = { - "body": ` * ${newMessage}`, - "formatted_body": ` * ${newMessage}`, + "body": `* ${newMessage}`, + "formatted_body": `* ${newMessage}`, "m.new_content": { body: "Replying to this new content", format: "org.matrix.custom.html", 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/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/Notifications-test.tsx b/test/unit-tests/components/views/settings/Notifications-test.tsx index 69b45135cb..e3d9716901 100644 --- a/test/unit-tests/components/views/settings/Notifications-test.tsx +++ b/test/unit-tests/components/views/settings/Notifications-test.tsx @@ -915,7 +915,7 @@ describe("", () => { user: "@alice:example.org", ts: 1, }); - await room.addLiveEvents([message]); + await room.addLiveEvents([message], { addToState: true }); const { container } = await getComponentAndWait(); const clearNotificationEl = getByTestId(container, "clear-notifications"); diff --git a/test/unit-tests/components/views/settings/SecureBackupPanel-test.tsx b/test/unit-tests/components/views/settings/SecureBackupPanel-test.tsx index f2aa15f355..63490bf915 100644 --- a/test/unit-tests/components/views/settings/SecureBackupPanel-test.tsx +++ b/test/unit-tests/components/views/settings/SecureBackupPanel-test.tsx @@ -28,14 +28,13 @@ describe("", () => { const client = getMockClientWithEventEmitter({ ...mockClientMethodsUser(userId), ...mockClientMethodsCrypto(), - getKeyBackupVersion: jest.fn().mockReturnValue("1"), getClientWellKnown: jest.fn(), }); const getComponent = () => render(); beforeEach(() => { - client.getKeyBackupVersion.mockResolvedValue({ + jest.spyOn(client.getCrypto()!, "getKeyBackupInfo").mockResolvedValue({ version: "1", algorithm: "test", auth_data: { @@ -52,7 +51,6 @@ describe("", () => { }); mocked(client.secretStorage.hasKey).mockClear().mockResolvedValue(false); - client.getKeyBackupVersion.mockClear(); mocked(accessSecretStorage).mockClear().mockResolvedValue(); }); @@ -65,8 +63,8 @@ describe("", () => { }); it("handles error fetching backup", async () => { - // getKeyBackupVersion can fail for various reasons - client.getKeyBackupVersion.mockImplementation(async () => { + // getKeyBackupInfo can fail for various reasons + jest.spyOn(client.getCrypto()!, "getKeyBackupInfo").mockImplementation(async () => { throw new Error("beep beep"); }); const renderResult = getComponent(); @@ -75,9 +73,9 @@ describe("", () => { }); it("handles absence of backup", async () => { - client.getKeyBackupVersion.mockResolvedValue(null); + jest.spyOn(client.getCrypto()!, "getKeyBackupInfo").mockResolvedValue(null); getComponent(); - // flush getKeyBackupVersion promise + // flush getKeyBackupInfo promise await flushPromises(); expect(screen.getByText("Back up your keys before signing out to avoid losing them.")).toBeInTheDocument(); }); @@ -120,7 +118,7 @@ describe("", () => { }); it("deletes backup after confirmation", async () => { - client.getKeyBackupVersion + jest.spyOn(client.getCrypto()!, "getKeyBackupInfo") .mockResolvedValueOnce({ version: "1", algorithm: "test", @@ -157,7 +155,7 @@ describe("", () => { // flush checkKeyBackup promise await flushPromises(); - client.getKeyBackupVersion.mockClear(); + jest.spyOn(client.getCrypto()!, "getKeyBackupInfo").mockClear(); mocked(client.getCrypto()!).isKeyBackupTrusted.mockClear(); fireEvent.click(screen.getByText("Reset")); @@ -167,7 +165,7 @@ describe("", () => { await flushPromises(); // backup status refreshed - expect(client.getKeyBackupVersion).toHaveBeenCalled(); + expect(client.getCrypto()!.getKeyBackupInfo).toHaveBeenCalled(); expect(client.getCrypto()!.isKeyBackupTrusted).toHaveBeenCalled(); }); }); 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/devices/LoginWithQRSection-test.tsx b/test/unit-tests/components/views/settings/devices/LoginWithQRSection-test.tsx index dd6caef1ce..edaf7b39d1 100644 --- a/test/unit-tests/components/views/settings/devices/LoginWithQRSection-test.tsx +++ b/test/unit-tests/components/views/settings/devices/LoginWithQRSection-test.tsx @@ -56,7 +56,6 @@ describe("", () => { const defaultProps = { onShowQr: () => {}, versions: makeVersions({ "org.matrix.msc4108": true }), - wellKnown: {}, }; const getComponent = (props = {}) => ; diff --git a/test/unit-tests/components/views/settings/notifications/Notifications2-test.tsx b/test/unit-tests/components/views/settings/notifications/Notifications2-test.tsx index 029777c153..2507aa173f 100644 --- a/test/unit-tests/components/views/settings/notifications/Notifications2-test.tsx +++ b/test/unit-tests/components/views/settings/notifications/Notifications2-test.tsx @@ -716,7 +716,7 @@ describe("", () => { user: "@alice:example.org", ts: 1, }); - room.addLiveEvents([message]); + room.addLiveEvents([message], { addToState: true }); room.setUnreadNotificationCount(NotificationCountType.Total, 1); const user = userEvent.setup(); 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..e1a451c9d5 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 @@ -17,7 +17,6 @@ import userEvent from "@testing-library/user-event"; import RolesRoomSettingsTab from "../../../../../../../src/components/views/settings/tabs/room/RolesRoomSettingsTab"; import { mkStubRoom, withClientContextRenderOptions, stubClient } from "../../../../../../test-utils"; import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg"; -import { VoiceBroadcastInfoEventType } from "../../../../../../../src/voice-broadcast"; import SettingsStore from "../../../../../../../src/settings/SettingsStore"; import { ElementCall } from "../../../../../../../src/models/Call"; @@ -27,16 +26,11 @@ describe("RolesRoomSettingsTab", () => { let cli: MatrixClient; let room: Room; - const renderTab = (propRoom: Room = room): RenderResult => { - return render(, withClientContextRenderOptions(cli)); - }; - - const getVoiceBroadcastsSelect = (): HTMLElement => { - return renderTab().container.querySelector("select[label='Voice broadcasts']")!; - }; - - const getVoiceBroadcastsSelectedOption = (): HTMLElement => { - return renderTab().container.querySelector("select[label='Voice broadcasts'] option:checked")!; + 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; }; beforeEach(() => { @@ -45,7 +39,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,32 +61,12 @@ 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"); - }); - - describe("when setting »Default« permission for »Voice broadcasts«", () => { - beforeEach(() => { - fireEvent.change(getVoiceBroadcastsSelect(), { - target: { value: 0 }, - }); - }); - - it("should update the power levels", () => { - expect(cli.sendStateEvent).toHaveBeenCalledWith(roomId, EventType.RoomPowerLevels, { - events: { - [VoiceBroadcastInfoEventType]: 0, - }, - }); - }); - }); - describe("Element Call", () => { const setGroupCallsEnabled = (val: boolean): void => { jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { @@ -122,12 +96,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 +117,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 +138,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 +224,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/room/SecurityRoomSettingsTab-test.tsx b/test/unit-tests/components/views/settings/tabs/room/SecurityRoomSettingsTab-test.tsx index 6534a34004..5fc10714e6 100644 --- a/test/unit-tests/components/views/settings/tabs/room/SecurityRoomSettingsTab-test.tsx +++ b/test/unit-tests/components/views/settings/tabs/room/SecurityRoomSettingsTab-test.tsx @@ -75,7 +75,7 @@ describe("", () => { beforeEach(async () => { client.sendStateEvent.mockReset().mockResolvedValue({ event_id: "test" }); - client.isRoomEncrypted.mockReturnValue(false); + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); client.getClientWellKnown.mockReturnValue(undefined); jest.spyOn(SettingsStore, "getValue").mockRestore(); @@ -313,7 +313,7 @@ describe("", () => { setRoomStateEvents(room); getComponent(room); - expect(screen.getByLabelText("Encrypted")).not.toBeChecked(); + await waitFor(() => expect(screen.getByLabelText("Encrypted")).not.toBeChecked()); fireEvent.click(screen.getByLabelText("Encrypted")); @@ -330,7 +330,7 @@ describe("", () => { setRoomStateEvents(room); getComponent(room); - expect(screen.getByLabelText("Encrypted")).not.toBeChecked(); + await waitFor(() => expect(screen.getByLabelText("Encrypted")).not.toBeChecked()); fireEvent.click(screen.getByLabelText("Encrypted")); @@ -416,12 +416,12 @@ describe("", () => { expect(screen.getByText("Once enabled, encryption cannot be disabled.")).toBeInTheDocument(); }); - it("displays unencrypted rooms with toggle disabled", () => { + it("displays unencrypted rooms with toggle disabled", async () => { const room = new Room(roomId, client, userId); setRoomStateEvents(room); getComponent(room); - expect(screen.getByLabelText("Encrypted")).not.toBeChecked(); + await waitFor(() => expect(screen.getByLabelText("Encrypted")).not.toBeChecked()); expect(screen.getByLabelText("Encrypted").getAttribute("aria-disabled")).toEqual("true"); expect(screen.queryByText("Once enabled, encryption cannot be disabled.")).not.toBeInTheDocument(); expect(screen.getByText("Your server requires encryption to be disabled.")).toBeInTheDocument(); diff --git a/test/unit-tests/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx b/test/unit-tests/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx index 0ee882767b..27403919b6 100644 --- a/test/unit-tests/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx +++ b/test/unit-tests/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx @@ -34,7 +34,6 @@ describe("", () => { ...mockClientMethodsCrypto(), getRooms: jest.fn().mockReturnValue([]), getIgnoredUsers: jest.fn(), - getKeyBackupVersion: jest.fn(), }); const sdkContext = new SdkContextClass(); diff --git a/test/unit-tests/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/unit-tests/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 52c9d3aaa9..87411e18a1 100644 --- a/test/unit-tests/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/unit-tests/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -277,9 +277,7 @@ describe("", () => { mockClient.getDevices.mockRejectedValue({ httpStatus: 404 }); const { container } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(container.getElementsByClassName("mx_Spinner").length).toBeFalsy(); }); @@ -302,9 +300,7 @@ describe("", () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(mockCrypto.getDeviceVerificationStatus).toHaveBeenCalledTimes(3); expect( @@ -337,9 +333,7 @@ describe("", () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); // twice for each device expect(mockClient.getAccountData).toHaveBeenCalledTimes(4); @@ -356,9 +350,7 @@ describe("", () => { const { getByTestId, queryByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); toggleDeviceDetails(getByTestId, alicesDevice.device_id); // application metadata section not rendered @@ -369,9 +361,7 @@ describe("", () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice] }); const { queryByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(queryByTestId("other-sessions-section")).toBeFalsy(); }); @@ -382,9 +372,7 @@ describe("", () => { }); const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(getByTestId("other-sessions-section")).toBeTruthy(); }); @@ -395,9 +383,7 @@ describe("", () => { }); const { getByTestId, container } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); fireEvent.click(getByTestId("unverified-devices-cta")); @@ -908,7 +894,8 @@ describe("", () => { }); it("deletes a device when interactive auth is not required", async () => { - mockClient.deleteMultipleDevices.mockResolvedValue({}); + const deferredDeleteMultipleDevices = defer<{}>(); + mockClient.deleteMultipleDevices.mockReturnValue(deferredDeleteMultipleDevices.promise); mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice], }); @@ -933,6 +920,7 @@ describe("", () => { fireEvent.click(signOutButton); await confirmSignout(getByTestId); await prom; + deferredDeleteMultipleDevices.resolve({}); // delete called expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith( @@ -991,7 +979,7 @@ describe("", () => { const { getByTestId, getByLabelText } = render(getComponent()); - await act(flushPromises); + await flushPromises(); // reset mock count after initial load mockClient.getDevices.mockClear(); @@ -1025,7 +1013,7 @@ describe("", () => { fireEvent.submit(getByLabelText("Password")); }); - await act(flushPromises); + await flushPromises(); // called again with auth expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([alicesMobileDevice.device_id], { @@ -1551,7 +1539,7 @@ describe("", () => { }); const { getByTestId, container } = render(getComponent()); - await act(flushPromises); + await flushPromises(); // filter for inactive sessions await setFilter(container, DeviceSecurityVariation.Inactive); @@ -1577,9 +1565,7 @@ describe("", () => { it("lets you change the pusher state", async () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); @@ -1598,9 +1584,7 @@ describe("", () => { it("lets you change the local notification settings state", async () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); toggleDeviceDetails(getByTestId, alicesDevice.device_id); @@ -1621,9 +1605,7 @@ describe("", () => { it("updates the UI when another session changes the local notifications", async () => { const { getByTestId } = render(getComponent()); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); toggleDeviceDetails(getByTestId, alicesDevice.device_id); diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap index 6c51cc41ab..5c6a8ac8ee 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AccountUserSettingsTab-test.tsx.snap @@ -42,14 +42,14 @@ exports[` 3pids should display 3pid email addresses an > @@ -145,14 +145,14 @@ exports[` 3pids should display 3pid email addresses an diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap index 38f9e483c8..72f94d29c6 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap @@ -388,7 +388,7 @@ exports[` goes to filtered list from security recommendatio > { otherDeviceId, }); const result = renderComponent({ request }); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(result.container).toMatchSnapshot(); }); @@ -76,9 +74,7 @@ describe("VerificationRequestToast", () => { otherUserId, }); const result = renderComponent({ request }); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); expect(result.container).toMatchSnapshot(); }); @@ -89,9 +85,7 @@ describe("VerificationRequestToast", () => { otherUserId, }); renderComponent({ request, toastKey: "testKey" }); - await act(async () => { - await flushPromises(); - }); + await flushPromises(); const dismiss = jest.spyOn(ToastStore.sharedInstance(), "dismissToast"); Object.defineProperty(request, "accepting", { value: true }); diff --git a/test/unit-tests/contexts/SdkContext-test.ts b/test/unit-tests/contexts/SdkContext-test.ts index 21f356ed94..340fabdd3d 100644 --- a/test/unit-tests/contexts/SdkContext-test.ts +++ b/test/unit-tests/contexts/SdkContext-test.ts @@ -11,11 +11,8 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { SdkContextClass } from "../../../src/contexts/SDKContext"; import { OidcClientStore } from "../../../src/stores/oidc/OidcClientStore"; import { UserProfilesStore } from "../../../src/stores/UserProfilesStore"; -import { VoiceBroadcastPreRecordingStore } from "../../../src/voice-broadcast"; import { createTestClient } from "../../test-utils"; -jest.mock("../../../src/voice-broadcast/stores/VoiceBroadcastPreRecordingStore"); - describe("SdkContextClass", () => { let sdkContext = SdkContextClass.instance; let client: MatrixClient; @@ -33,12 +30,6 @@ describe("SdkContextClass", () => { expect(SdkContextClass.instance).toBe(globalInstance); }); - it("voiceBroadcastPreRecordingStore should always return the same VoiceBroadcastPreRecordingStore", () => { - const first = sdkContext.voiceBroadcastPreRecordingStore; - expect(first).toBeInstanceOf(VoiceBroadcastPreRecordingStore); - expect(sdkContext.voiceBroadcastPreRecordingStore).toBe(first); - }); - it("userProfilesStore should raise an error without a client", () => { expect(() => sdkContext.userProfilesStore).toThrow("Unable to create UserProfilesStore without a client"); }); diff --git a/test/unit-tests/events/EventTileFactory-test.ts b/test/unit-tests/events/EventTileFactory-test.ts index 9c8f7718af..8a7d09c434 100644 --- a/test/unit-tests/events/EventTileFactory-test.ts +++ b/test/unit-tests/events/EventTileFactory-test.ts @@ -7,19 +7,16 @@ Please see LICENSE files in the repository root for full details. */ import { mocked } from "jest-mock"; -import { EventType, MatrixClient, MatrixEvent, MsgType, RelationType, Room } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from "matrix-js-sdk/src/matrix"; import { JSONEventFactory, MessageEventFactory, pickFactory, RoomCreateEventFactory, - TextualEventFactory, } from "../../../src/events/EventTileFactory"; import SettingsStore from "../../../src/settings/SettingsStore"; -import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoState } from "../../../src/voice-broadcast"; import { createTestClient, mkEvent } from "../../test-utils"; -import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-utils"; const roomId = "!room:example.com"; @@ -31,11 +28,7 @@ describe("pickFactory", () => { let createEventWithoutPredecessor: MatrixEvent; let dynamicPredecessorEvent: MatrixEvent; - let voiceBroadcastStartedEvent: MatrixEvent; - let voiceBroadcastStoppedEvent: MatrixEvent; - let voiceBroadcastChunkEvent: MatrixEvent; let utdEvent: MatrixEvent; - let utdBroadcastChunkEvent: MatrixEvent; let audioMessageEvent: MatrixEvent; beforeAll(() => { @@ -82,29 +75,6 @@ describe("pickFactory", () => { last_known_event_id: null, }, }); - voiceBroadcastStartedEvent = mkVoiceBroadcastInfoStateEvent( - roomId, - VoiceBroadcastInfoState.Started, - client.getUserId()!, - client.deviceId!, - ); - room.addLiveEvents([voiceBroadcastStartedEvent]); - voiceBroadcastStoppedEvent = mkVoiceBroadcastInfoStateEvent( - roomId, - VoiceBroadcastInfoState.Stopped, - client.getUserId()!, - client.deviceId!, - ); - voiceBroadcastChunkEvent = mkEvent({ - event: true, - type: EventType.RoomMessage, - user: client.getUserId()!, - room: roomId, - content: { - msgtype: MsgType.Audio, - [VoiceBroadcastChunkEventType]: {}, - }, - }); audioMessageEvent = mkEvent({ event: true, type: EventType.RoomMessage, @@ -123,20 +93,6 @@ describe("pickFactory", () => { msgtype: "m.bad.encrypted", }, }); - utdBroadcastChunkEvent = mkEvent({ - event: true, - type: EventType.RoomMessage, - user: client.getUserId()!, - room: roomId, - content: { - "msgtype": "m.bad.encrypted", - "m.relates_to": { - rel_type: RelationType.Reference, - event_id: voiceBroadcastStartedEvent.getId(), - }, - }, - }); - jest.spyOn(utdBroadcastChunkEvent, "isDecryptionFailure").mockReturnValue(true); }); it("should return JSONEventFactory for a no-op m.room.power_levels event", () => { @@ -151,10 +107,6 @@ describe("pickFactory", () => { }); describe("when showing hidden events", () => { - it("should return a JSONEventFactory for a voice broadcast event", () => { - expect(pickFactory(voiceBroadcastChunkEvent, client, true)).toBe(JSONEventFactory); - }); - it("should return a JSONEventFactory for a room create event without predecessor", () => { room.currentState.events.set( EventType.RoomCreate, @@ -164,17 +116,9 @@ describe("pickFactory", () => { expect(pickFactory(createEventWithoutPredecessor, client, true)).toBe(JSONEventFactory); }); - it("should return a TextualEventFactory for a voice broadcast stopped event", () => { - expect(pickFactory(voiceBroadcastStoppedEvent, client, true)).toBe(TextualEventFactory); - }); - it("should return a MessageEventFactory for an audio message event", () => { expect(pickFactory(audioMessageEvent, client, true)).toBe(MessageEventFactory); }); - - it("should return a MessageEventFactory for a UTD broadcast chunk event", () => { - expect(pickFactory(utdBroadcastChunkEvent, client, true)).toBe(MessageEventFactory); - }); }); describe("when not showing hidden events", () => { @@ -252,14 +196,6 @@ describe("pickFactory", () => { }); }); - it("should return undefined for a voice broadcast event", () => { - expect(pickFactory(voiceBroadcastChunkEvent, client, false)).toBeUndefined(); - }); - - it("should return a TextualEventFactory for a voice broadcast stopped event", () => { - expect(pickFactory(voiceBroadcastStoppedEvent, client, false)).toBe(TextualEventFactory); - }); - it("should return a MessageEventFactory for an audio message event", () => { expect(pickFactory(audioMessageEvent, client, false)).toBe(MessageEventFactory); }); @@ -267,9 +203,5 @@ describe("pickFactory", () => { it("should return a MessageEventFactory for a UTD event", () => { expect(pickFactory(utdEvent, client, false)).toBe(MessageEventFactory); }); - - it("should return undefined for a UTD broadcast chunk event", () => { - expect(pickFactory(utdBroadcastChunkEvent, client, false)).toBeUndefined(); - }); }); }); diff --git a/test/unit-tests/linkify-matrix-test.ts b/test/unit-tests/linkify-matrix-test.ts index 97548c62d4..50edff2535 100644 --- a/test/unit-tests/linkify-matrix-test.ts +++ b/test/unit-tests/linkify-matrix-test.ts @@ -332,7 +332,7 @@ describe("linkify-matrix", () => { const event = new MouseEvent("mousedown"); event.preventDefault = jest.fn(); - handlers.click(event); + handlers!.click(event); expect(event.preventDefault).toHaveBeenCalled(); expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -372,7 +372,7 @@ describe("linkify-matrix", () => { const event = new MouseEvent("mousedown"); event.preventDefault = jest.fn(); - handlers.click(event); + handlers!.click(event); expect(event.preventDefault).toHaveBeenCalled(); expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/test/unit-tests/models/Call-test.ts b/test/unit-tests/models/Call-test.ts index 40e929fb4a..316adbbea9 100644 --- a/test/unit-tests/models/Call-test.ts +++ b/test/unit-tests/models/Call-test.ts @@ -119,7 +119,7 @@ const setUpClientRoomAndStores = (): { skey: stateKey, content: content as IContent, }); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: true }); return { event_id: event.getId()! }; }); diff --git a/test/unit-tests/stores/MemberListStore-test.ts b/test/unit-tests/stores/MemberListStore-test.ts index 889a9d3505..470087031a 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)]); @@ -202,6 +201,7 @@ describe("MemberListStore", () => { function addEventToRoom(room: Room, ev: MatrixEvent) { room.getLiveTimeline().addEvent(ev, { toStartOfTimeline: false, + addToState: true, }); } diff --git a/test/unit-tests/stores/RoomViewStore-test.ts b/test/unit-tests/stores/RoomViewStore-test.ts index c9b80553e5..782bb79738 100644 --- a/test/unit-tests/stores/RoomViewStore-test.ts +++ b/test/unit-tests/stores/RoomViewStore-test.ts @@ -24,13 +24,6 @@ import { ActiveRoomChangedPayload } from "../../../src/dispatcher/payloads/Activ import { SpaceStoreClass } from "../../../src/stores/spaces/SpaceStore"; import { TestSdkContext } from "../TestSdkContext"; import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload"; -import { - VoiceBroadcastInfoState, - VoiceBroadcastPlayback, - VoiceBroadcastPlaybacksStore, - VoiceBroadcastRecording, -} from "../../../src/voice-broadcast"; -import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-utils"; import Modal from "../../../src/Modal"; import ErrorDialog from "../../../src/components/views/dialogs/ErrorDialog"; import { CancelAskToJoinPayload } from "../../../src/dispatcher/payloads/CancelAskToJoinPayload"; @@ -160,7 +153,6 @@ describe("RoomViewStore", function () { stores._SlidingSyncManager = slidingSyncManager; stores._PosthogAnalytics = new MockPosthogAnalytics(); stores._SpaceStore = new MockSpaceStore(); - stores._VoiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore(stores.voiceBroadcastRecordingsStore); roomViewStore = new RoomViewStore(dis, stores); stores._RoomViewStore = roomViewStore; }); @@ -343,88 +335,6 @@ describe("RoomViewStore", function () { }); }); - describe("when listening to a voice broadcast", () => { - let voiceBroadcastPlayback: VoiceBroadcastPlayback; - - beforeEach(() => { - voiceBroadcastPlayback = new VoiceBroadcastPlayback( - mkVoiceBroadcastInfoStateEvent( - roomId, - VoiceBroadcastInfoState.Started, - mockClient.getSafeUserId(), - "d42", - ), - mockClient, - stores.voiceBroadcastRecordingsStore, - ); - stores.voiceBroadcastPlaybacksStore.setCurrent(voiceBroadcastPlayback); - jest.spyOn(voiceBroadcastPlayback, "pause").mockImplementation(); - }); - - it("and viewing a call it should pause the current broadcast", async () => { - await viewCall(); - expect(voiceBroadcastPlayback.pause).toHaveBeenCalled(); - expect(roomViewStore.isViewingCall()).toBe(true); - }); - }); - - describe("when recording a voice broadcast", () => { - beforeEach(() => { - stores.voiceBroadcastRecordingsStore.setCurrent( - new VoiceBroadcastRecording( - mkVoiceBroadcastInfoStateEvent( - roomId, - VoiceBroadcastInfoState.Started, - mockClient.getSafeUserId(), - "d42", - ), - mockClient, - ), - ); - }); - - it("and trying to view a call, it should not actually view it and show the info dialog", async () => { - await viewCall(); - expect(Modal.createDialog).toMatchSnapshot(); - expect(roomViewStore.isViewingCall()).toBe(false); - }); - - describe("and viewing a room with a broadcast", () => { - beforeEach(async () => { - const broadcastEvent = mkVoiceBroadcastInfoStateEvent( - roomId2, - VoiceBroadcastInfoState.Started, - mockClient.getSafeUserId(), - "ABC123", - ); - room2.addLiveEvents([broadcastEvent]); - - stores.voiceBroadcastPlaybacksStore.getByInfoEvent(broadcastEvent, mockClient); - dis.dispatch({ action: Action.ViewRoom, room_id: roomId2 }); - await untilDispatch(Action.ActiveRoomChanged, dis); - }); - - it("should continue recording", () => { - expect(stores.voiceBroadcastPlaybacksStore.getCurrent()).toBeNull(); - expect(stores.voiceBroadcastRecordingsStore.getCurrent()?.getState()).toBe( - VoiceBroadcastInfoState.Started, - ); - }); - - describe("and stopping the recording", () => { - beforeEach(async () => { - await stores.voiceBroadcastRecordingsStore.getCurrent()?.stop(); - // check test precondition - expect(stores.voiceBroadcastRecordingsStore.getCurrent()).toBeNull(); - }); - - it("should view the broadcast", () => { - expect(stores.voiceBroadcastPlaybacksStore.getCurrent()?.infoEvent.getRoomId()).toBe(roomId2); - }); - }); - }); - }); - describe("Sliding Sync", function () { beforeEach(() => { jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName, roomId, value) => { diff --git a/test/unit-tests/stores/SetupEncryptionStore-test.ts b/test/unit-tests/stores/SetupEncryptionStore-test.ts index b9ab29b94b..d3d0300a21 100644 --- a/test/unit-tests/stores/SetupEncryptionStore-test.ts +++ b/test/unit-tests/stores/SetupEncryptionStore-test.ts @@ -37,6 +37,7 @@ describe("SetupEncryptionStore", () => { getDeviceVerificationStatus: jest.fn(), isDehydrationSupported: jest.fn().mockResolvedValue(false), startDehydration: jest.fn(), + getKeyBackupInfo: jest.fn().mockResolvedValue(null), } as unknown as Mocked; client.getCrypto.mockReturnValue(mockCrypto); diff --git a/test/unit-tests/stores/__snapshots__/RoomViewStore-test.ts.snap b/test/unit-tests/stores/__snapshots__/RoomViewStore-test.ts.snap index a6b7953697..77ee50df5b 100644 --- a/test/unit-tests/stores/__snapshots__/RoomViewStore-test.ts.snap +++ b/test/unit-tests/stores/__snapshots__/RoomViewStore-test.ts.snap @@ -18,26 +18,3 @@ exports[`RoomViewStore should display the generic error message when the roomId "title": "Failed to join", } `; - -exports[`RoomViewStore when recording a voice broadcast and trying to view a call, it should not actually view it and show the info dialog 1`] = ` -[MockFunction] { - "calls": [ - [ - [Function], - { - "description":

- You can’t start a call as you are currently recording a live broadcast. Please end your live broadcast in order to start a call. -

, - "hasCloseButton": true, - "title": "Can’t start a call", - }, - ], - ], - "results": [ - { - "type": "return", - "value": undefined, - }, - ], -} -`; diff --git a/test/unit-tests/stores/right-panel/RightPanelStore-test.ts b/test/unit-tests/stores/right-panel/RightPanelStore-test.ts index 92299426ba..27c4d730a9 100644 --- a/test/unit-tests/stores/right-panel/RightPanelStore-test.ts +++ b/test/unit-tests/stores/right-panel/RightPanelStore-test.ts @@ -18,6 +18,9 @@ import { ActiveRoomChangedPayload } from "../../../../src/dispatcher/payloads/Ac import RightPanelStore from "../../../../src/stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases"; import SettingsStore from "../../../../src/settings/SettingsStore"; +import { pendingVerificationRequestForUser } from "../../../../src/verification.ts"; + +jest.mock("../../../../src/verification"); describe("RightPanelStore", () => { // Mock out the settings store so the right panel store can't persist values between tests @@ -97,7 +100,7 @@ describe("RightPanelStore", () => { it("does nothing if given an invalid state", async () => { await viewRoom("!1:example.org"); // Needs a member specified to be valid - store.setCard({ phase: RightPanelPhases.RoomMemberInfo }, true, "!1:example.org"); + store.setCard({ phase: RightPanelPhases.MemberInfo }, true, "!1:example.org"); expect(store.roomPhaseHistory).toEqual([]); }); it("only creates a single history entry if given the same card twice", async () => { @@ -114,15 +117,15 @@ describe("RightPanelStore", () => { it("overwrites history if changing the phase", async () => { await viewRoom("!1:example.org"); store.setCard({ phase: RightPanelPhases.RoomSummary }, true, "!1:example.org"); - store.setCard({ phase: RightPanelPhases.RoomMemberList }, true, "!1:example.org"); - expect(store.roomPhaseHistory).toEqual([{ phase: RightPanelPhases.RoomMemberList, state: {} }]); + store.setCard({ phase: RightPanelPhases.MemberList }, true, "!1:example.org"); + expect(store.roomPhaseHistory).toEqual([{ phase: RightPanelPhases.MemberList, state: {} }]); }); }); describe("setCards", () => { it("overwrites history", async () => { await viewRoom("!1:example.org"); - store.setCard({ phase: RightPanelPhases.RoomMemberList }, true, "!1:example.org"); + store.setCard({ phase: RightPanelPhases.MemberList }, true, "!1:example.org"); store.setCards( [{ phase: RightPanelPhases.RoomSummary }, { phase: RightPanelPhases.PinnedMessages }], true, @@ -200,21 +203,40 @@ describe("RightPanelStore", () => { store.setCards( [ { - phase: RightPanelPhases.RoomMemberList, + phase: RightPanelPhases.MemberList, }, { - phase: RightPanelPhases.RoomMemberInfo, + phase: RightPanelPhases.MemberInfo, state: { member: new RoomMember("!1:example.org", "@alice:example.org") }, }, ], true, "!1:example.org", ); - expect(store.currentCardForRoom("!1:example.org").phase).toEqual(RightPanelPhases.RoomMemberInfo); + expect(store.currentCardForRoom("!1:example.org").phase).toEqual(RightPanelPhases.MemberInfo); // Switch away and back await viewRoom("!2:example.org"); await viewRoom("!1:example.org"); - expect(store.currentCardForRoom("!1:example.org").phase).toEqual(RightPanelPhases.RoomMemberList); + expect(store.currentCardForRoom("!1:example.org").phase).toEqual(RightPanelPhases.MemberList); + }); + + it("should redirect to verification if set to phase MemberInfo for a user with a pending verification", async () => { + const member = new RoomMember("!1:example.org", "@alice:example.org"); + const verificationRequest = { mockVerificationRequest: true } as any; + mocked(pendingVerificationRequestForUser).mockReturnValue(verificationRequest); + await viewRoom("!1:example.org"); + store.setCard( + { + phase: RightPanelPhases.MemberInfo, + state: { member }, + }, + true, + "!1:example.org", + ); + expect(store.currentCard).toEqual({ + phase: RightPanelPhases.EncryptionPanel, + state: { member, verificationRequest }, + }); }); }); diff --git a/test/unit-tests/stores/right-panel/action-handlers/View3pidInvite-test.ts b/test/unit-tests/stores/right-panel/action-handlers/View3pidInvite-test.ts index d0b721243f..9503bb4ae3 100644 --- a/test/unit-tests/stores/right-panel/action-handlers/View3pidInvite-test.ts +++ b/test/unit-tests/stores/right-panel/action-handlers/View3pidInvite-test.ts @@ -30,7 +30,7 @@ describe("onView3pidInvite()", () => { }; onView3pidInvite(payload, rightPanelStore); - expect(rightPanelStore.showOrHidePhase).toHaveBeenCalledWith(RightPanelPhases.RoomMemberList); + expect(rightPanelStore.showOrHidePhase).toHaveBeenCalledWith(RightPanelPhases.MemberList); expect(rightPanelStore.pushCard).not.toHaveBeenCalled(); }); @@ -43,7 +43,7 @@ describe("onView3pidInvite()", () => { expect(rightPanelStore.showOrHidePhase).not.toHaveBeenCalled(); expect(rightPanelStore.pushCard).toHaveBeenCalledWith({ - phase: RightPanelPhases.Room3pidMemberInfo, + phase: RightPanelPhases.ThreePidMemberInfo, state: { memberInfoEvent: payload.event }, }); }); diff --git a/test/unit-tests/stores/room-list/MessagePreviewStore-test.ts b/test/unit-tests/stores/room-list/MessagePreviewStore-test.ts index 75013bd4e1..861b6dcd11 100644 --- a/test/unit-tests/stores/room-list/MessagePreviewStore-test.ts +++ b/test/unit-tests/stores/room-list/MessagePreviewStore-test.ts @@ -35,7 +35,7 @@ describe("MessagePreviewStore", () => { event: MatrixEvent, fireAction = true, ): Promise { - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: true }); if (fireAction) { // @ts-ignore private access await store.onAction({ diff --git a/test/unit-tests/stores/room-list/algorithms/RecentAlgorithm-test.ts b/test/unit-tests/stores/room-list/algorithms/RecentAlgorithm-test.ts index 18a073b632..e0e06bbaf7 100644 --- a/test/unit-tests/stores/room-list/algorithms/RecentAlgorithm-test.ts +++ b/test/unit-tests/stores/room-list/algorithms/RecentAlgorithm-test.ts @@ -47,11 +47,11 @@ describe("RecentAlgorithm", () => { room.getMyMembership = () => KnownMembership.Join; - room.addLiveEvents([event1]); + room.addLiveEvents([event1], { addToState: true }); expect(algorithm.getLastTs(room, "@jane:matrix.org")).toBe(5); expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(5); - room.addLiveEvents([event2]); + room.addLiveEvents([event2], { addToState: true }); expect(algorithm.getLastTs(room, "@jane:matrix.org")).toBe(10); expect(algorithm.getLastTs(room, "@john:matrix.org")).toBe(10); @@ -94,8 +94,8 @@ describe("RecentAlgorithm", () => { event: true, }); - room1.addLiveEvents([evt]); - room2.addLiveEvents([evt2]); + room1.addLiveEvents([evt], { addToState: true }); + room2.addLiveEvents([evt2], { addToState: true }); expect(algorithm.sortRooms([room2, room1], DefaultTagID.Untagged)).toEqual([room1, room2]); }); @@ -115,7 +115,7 @@ describe("RecentAlgorithm", () => { event: true, }); - room1.addLiveEvents([evt]); + room1.addLiveEvents([evt], { addToState: true }); expect(algorithm.sortRooms([room2, room1], DefaultTagID.Untagged)).toEqual([room2, room1]); @@ -127,7 +127,7 @@ describe("RecentAlgorithm", () => { ts: 12, }); - room1.addLiveEvents(events); + room1.addLiveEvents(events, { addToState: true }); }); it("orders rooms based on thread replies too", () => { @@ -145,7 +145,7 @@ describe("RecentAlgorithm", () => { ts: 12, length: 5, }); - room1.addLiveEvents(events1); + room1.addLiveEvents(events1, { addToState: true }); const { events: events2 } = mkThread({ room: room2, @@ -155,7 +155,7 @@ describe("RecentAlgorithm", () => { ts: 14, length: 10, }); - room2.addLiveEvents(events2); + room2.addLiveEvents(events2, { addToState: true }); expect(algorithm.sortRooms([room1, room2], DefaultTagID.Untagged)).toEqual([room2, room1]); @@ -169,7 +169,7 @@ describe("RecentAlgorithm", () => { // replies are 1ms after each other ts: 50, }); - room1.addLiveEvents([threadReply]); + room1.addLiveEvents([threadReply], { addToState: true }); expect(algorithm.sortRooms([room1, room2], DefaultTagID.Untagged)).toEqual([room1, room2]); }); diff --git a/test/unit-tests/stores/room-list/previews/MessageEventPreview-test.ts b/test/unit-tests/stores/room-list/previews/MessageEventPreview-test.ts index fd6626e6b6..ec97cdd20e 100644 --- a/test/unit-tests/stores/room-list/previews/MessageEventPreview-test.ts +++ b/test/unit-tests/stores/room-list/previews/MessageEventPreview-test.ts @@ -71,18 +71,5 @@ describe("MessageEventPreview", () => { }); expect(preview.getTextFor(event)).toBe(`${userId}: test new content body`); }); - - it("when called with a broadcast chunk event it should return null", () => { - const event = mkEvent({ - event: true, - content: { - body: "test body", - ["io.element.voice_broadcast_chunk"]: {}, - }, - user: userId, - type: "m.room.message", - }); - expect(preview.getTextFor(event)).toBeNull(); - }); }); }); diff --git a/test/unit-tests/stores/room-list/previews/ReactionEventPreview-test.ts b/test/unit-tests/stores/room-list/previews/ReactionEventPreview-test.ts index b3facccb06..7a7ce9e2b3 100644 --- a/test/unit-tests/stores/room-list/previews/ReactionEventPreview-test.ts +++ b/test/unit-tests/stores/room-list/previews/ReactionEventPreview-test.ts @@ -70,7 +70,7 @@ describe("ReactionEventPreview", () => { room: roomId, }); - room.getUnfilteredTimelineSet().addLiveEvent(message, {}); + room.getUnfilteredTimelineSet().addLiveEvent(message, { addToState: true }); const event = mkEvent({ event: true, @@ -107,7 +107,7 @@ describe("ReactionEventPreview", () => { room: roomId, }); - room.getUnfilteredTimelineSet().addLiveEvent(message, {}); + room.getUnfilteredTimelineSet().addLiveEvent(message, { addToState: true }); const event = mkEvent({ event: true, diff --git a/test/unit-tests/stores/room-list/previews/VoiceBroadcastPreview-test.ts b/test/unit-tests/stores/room-list/previews/VoiceBroadcastPreview-test.ts deleted file mode 100644 index a96f3c11bd..0000000000 --- a/test/unit-tests/stores/room-list/previews/VoiceBroadcastPreview-test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { Room } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastPreview } from "../../../../../src/stores/room-list/previews/VoiceBroadcastPreview"; -import { VoiceBroadcastInfoState } from "../../../../../src/voice-broadcast"; -import { mkEvent, stubClient } from "../../../../test-utils"; -import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils"; - -describe("VoiceBroadcastPreview.getTextFor", () => { - const roomId = "!room:example.com"; - const userId = "@user:example.com"; - const deviceId = "d42"; - let preview: VoiceBroadcastPreview; - - beforeAll(() => { - preview = new VoiceBroadcastPreview(); - }); - - it("when passing an event with empty content, it should return null", () => { - const event = mkEvent({ - event: true, - content: {}, - user: userId, - type: "m.room.message", - }); - expect(preview.getTextFor(event)).toBeNull(); - }); - - it("when passing a broadcast started event, it should return null", () => { - const event = mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Started, userId, deviceId); - expect(preview.getTextFor(event)).toBeNull(); - }); - - it("when passing a broadcast stopped event, it should return the expected text", () => { - const event = mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Stopped, userId, deviceId); - expect(preview.getTextFor(event)).toBe("@user:example.com ended a voice broadcast"); - }); - - it("when passing a redacted broadcast stopped event, it should return null", () => { - const event = mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Stopped, userId, deviceId); - event.makeRedacted( - mkEvent({ event: true, content: {}, user: userId, type: "m.room.redaction" }), - new Room(roomId, stubClient(), userId), - ); - expect(preview.getTextFor(event)).toBeNull(); - }); -}); diff --git a/test/unit-tests/stores/widgets/StopGapWidget-test.ts b/test/unit-tests/stores/widgets/StopGapWidget-test.ts index 397c289d22..1416711017 100644 --- a/test/unit-tests/stores/widgets/StopGapWidget-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidget-test.ts @@ -22,9 +22,6 @@ import { waitFor } from "jest-matrix-react"; import { stubClient, mkRoom, mkEvent } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { StopGapWidget } from "../../../../src/stores/widgets/StopGapWidget"; -import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions"; -import { VoiceBroadcastInfoEventType, VoiceBroadcastRecording } from "../../../../src/voice-broadcast"; -import { SdkContextClass } from "../../../../src/contexts/SDKContext"; import ActiveWidgetStore from "../../../../src/stores/ActiveWidgetStore"; import SettingsStore from "../../../../src/settings/SettingsStore"; @@ -225,41 +222,6 @@ describe("StopGapWidget", () => { expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent(), "!1:example.org"); }); }); - - describe("when there is a voice broadcast recording", () => { - let voiceBroadcastInfoEvent: MatrixEvent; - let voiceBroadcastRecording: VoiceBroadcastRecording; - - beforeEach(() => { - voiceBroadcastInfoEvent = mkEvent({ - event: true, - room: client.getRoom("x")?.roomId, - user: client.getUserId()!, - type: VoiceBroadcastInfoEventType, - content: {}, - }); - voiceBroadcastRecording = new VoiceBroadcastRecording(voiceBroadcastInfoEvent, client); - jest.spyOn(voiceBroadcastRecording, "pause"); - jest.spyOn(SdkContextClass.instance.voiceBroadcastRecordingsStore, "getCurrent").mockReturnValue( - voiceBroadcastRecording, - ); - }); - - describe(`and receiving a action:${ElementWidgetActions.JoinCall} message`, () => { - beforeEach(async () => { - messaging.on.mock.calls.find(([event, listener]) => { - if (event === `action:${ElementWidgetActions.JoinCall}`) { - listener(); - return true; - } - }); - }); - - it("should pause the current voice broadcast recording", () => { - expect(voiceBroadcastRecording.pause).toHaveBeenCalled(); - }); - }); - }); }); describe("StopGapWidget with stickyPromise", () => { let client: MockedObject; 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/useTopic-test.tsx b/test/unit-tests/useTopic-test.tsx index cbef9dba60..81afaae985 100644 --- a/test/unit-tests/useTopic-test.tsx +++ b/test/unit-tests/useTopic-test.tsx @@ -29,7 +29,7 @@ describe("useTopic", () => { event: true, }); - room.addLiveEvents([topic]); + room.addLiveEvents([topic], { addToState: true }); function RoomTopic() { const topic = useTopic(room); @@ -52,7 +52,7 @@ describe("useTopic", () => { }); act(() => { - room.addLiveEvents([updatedTopic]); + room.addLiveEvents([updatedTopic], { addToState: true }); }); expect(screen.queryByText("New topic")).toBeInTheDocument(); diff --git a/test/unit-tests/utils/EventRenderingUtils-test.ts b/test/unit-tests/utils/EventRenderingUtils-test.ts deleted file mode 100644 index 8a3cfded4a..0000000000 --- a/test/unit-tests/utils/EventRenderingUtils-test.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { getEventDisplayInfo } from "../../../src/utils/EventRenderingUtils"; -import { VoiceBroadcastInfoState } from "../../../src/voice-broadcast"; -import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-utils"; -import { createTestClient } from "../../test-utils"; - -describe("getEventDisplayInfo", () => { - const mkBroadcastInfoEvent = (state: VoiceBroadcastInfoState) => { - return mkVoiceBroadcastInfoStateEvent("!room:example.com", state, "@user:example.com", "ASD123"); - }; - - it("should return the expected value for a broadcast started event", () => { - expect(getEventDisplayInfo(createTestClient(), mkBroadcastInfoEvent(VoiceBroadcastInfoState.Started), false)) - .toMatchInlineSnapshot(` - { - "hasRenderer": true, - "isBubbleMessage": false, - "isInfoMessage": false, - "isLeftAlignedBubbleMessage": false, - "isSeeingThroughMessageHiddenForModeration": false, - "noBubbleEvent": true, - } - `); - }); - - it("should return the expected value for a broadcast stopped event", () => { - expect(getEventDisplayInfo(createTestClient(), mkBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped), false)) - .toMatchInlineSnapshot(` - { - "hasRenderer": true, - "isBubbleMessage": false, - "isInfoMessage": true, - "isLeftAlignedBubbleMessage": false, - "isSeeingThroughMessageHiddenForModeration": false, - "noBubbleEvent": true, - } - `); - }); -}); diff --git a/test/unit-tests/utils/EventUtils-test.ts b/test/unit-tests/utils/EventUtils-test.ts index 2fc13b507c..c7828c3a2f 100644 --- a/test/unit-tests/utils/EventUtils-test.ts +++ b/test/unit-tests/utils/EventUtils-test.ts @@ -35,8 +35,6 @@ import { import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent, stubClient } from "../../test-utils"; import dis from "../../../src/dispatcher/dispatcher"; import { Action } from "../../../src/dispatcher/actions"; -import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-utils"; -import { VoiceBroadcastInfoState } from "../../../src/voice-broadcast/types"; jest.mock("../../../src/dispatcher/dispatcher"); @@ -148,20 +146,6 @@ describe("EventUtils", () => { }, }); - const voiceBroadcastStart = mkVoiceBroadcastInfoStateEvent( - "!room:example.com", - VoiceBroadcastInfoState.Started, - "@user:example.com", - "ABC123", - ); - - const voiceBroadcastStop = mkVoiceBroadcastInfoStateEvent( - "!room:example.com", - VoiceBroadcastInfoState.Stopped, - "@user:example.com", - "ABC123", - ); - describe("isContentActionable()", () => { type TestCase = [string, MatrixEvent]; it.each([ @@ -172,7 +156,6 @@ describe("EventUtils", () => { ["room member event", roomMemberEvent], ["event without msgtype", noMsgType], ["event without content body property", noContentBody], - ["broadcast stop event", voiceBroadcastStop], ])("returns false for %s", (_description, event) => { expect(isContentActionable(event)).toBe(false); }); @@ -183,7 +166,6 @@ describe("EventUtils", () => { ["event with empty content body", emptyContentBody], ["event with a content body", niceTextMessage], ["beacon_info event", beaconInfoEvent], - ["broadcast start event", voiceBroadcastStart], ])("returns true for %s", (_description, event) => { expect(isContentActionable(event)).toBe(true); }); diff --git a/test/unit-tests/utils/arrays-test.ts b/test/unit-tests/utils/arrays-test.ts index 52b0053147..7e440d3ee5 100644 --- a/test/unit-tests/utils/arrays-test.ts +++ b/test/unit-tests/utils/arrays-test.ts @@ -24,6 +24,7 @@ import { asyncEvery, asyncSome, asyncSomeParallel, + asyncFilter, } from "../../../src/utils/arrays"; type TestParams = { input: number[]; output: number[] }; @@ -480,4 +481,15 @@ describe("arrays", () => { 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/exportUtils/HTMLExport-test.ts b/test/unit-tests/utils/exportUtils/HTMLExport-test.ts index 0fc96e4db7..d169b55a7c 100644 --- a/test/unit-tests/utils/exportUtils/HTMLExport-test.ts +++ b/test/unit-tests/utils/exportUtils/HTMLExport-test.ts @@ -593,18 +593,21 @@ describe("HTMLExport", () => { it("should not make /messages requests when exporting 'Current Timeline'", async () => { client.createMessagesRequest.mockRejectedValue(new Error("Should never be called")); - room.addLiveEvents([ - new MatrixEvent({ - event_id: `$eventId`, - type: EventType.RoomMessage, - sender: client.getSafeUserId(), - origin_server_ts: 123456789, - content: { - msgtype: "m.text", - body: `testing testing`, - }, - }), - ]); + room.addLiveEvents( + [ + new MatrixEvent({ + event_id: `$eventId`, + type: EventType.RoomMessage, + sender: client.getSafeUserId(), + origin_server_ts: 123456789, + content: { + msgtype: "m.text", + body: `testing testing`, + }, + }), + ], + { addToState: true }, + ); const exporter = new HTMLExporter( room, 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 8e33575fec..6cf6e3496b 100644 --- a/test/unit-tests/utils/notifications-test.ts +++ b/test/unit-tests/utils/notifications-test.ts @@ -121,7 +121,7 @@ describe("notifications", () => { user: USER_ID, msg: "Hello", }); - room.addLiveEvents([message]); + room.addLiveEvents([message], { addToState: true }); sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockResolvedValue({}); jest.spyOn(client, "getRooms").mockReturnValue([room]); jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => { @@ -187,7 +187,7 @@ describe("notifications", () => { user: USER_ID, ts: 1, }); - room.addLiveEvents([message]); + room.addLiveEvents([message], { addToState: true }); room.setUnreadNotificationCount(NotificationCountType.Total, 1); await clearAllNotifications(client); @@ -202,7 +202,7 @@ describe("notifications", () => { user: USER_ID, ts: 1, }); - room.addLiveEvents([message]); + room.addLiveEvents([message], { addToState: true }); room.setUnreadNotificationCount(NotificationCountType.Total, 1); jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(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`] = `