diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index 7097099250..96b96c8398 100644 --- a/.github/workflows/build_develop.yml +++ b/.github/workflows/build_develop.yml @@ -98,7 +98,7 @@ jobs: running-workflow-name: "Build & Deploy develop.element.io" repo-token: ${{ secrets.GITHUB_TOKEN }} wait-interval: 10 - check-regexp: ^((?!SonarCloud|SonarQube|issue|board|label|Release|prepare).)*$ + check-regexp: ^((?!SonarCloud|SonarQube|issue|board|label|Release|prepare|GitHub Pages).)*$ # We keep the latest develop.tar.gz on R2 instead of relying on the github artifact uploaded earlier # as the expires after 24h and requires auth to download. diff --git a/.github/workflows/dockerhub.yaml b/.github/workflows/dockerhub.yaml index 3c64e4efbc..3e9473bc17 100644 --- a/.github/workflows/dockerhub.yaml +++ b/.github/workflows/dockerhub.yaml @@ -45,7 +45,7 @@ jobs: install: true - name: Login to Docker Hub - uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3 + uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2aefb39a32..e495e87eaa 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -60,7 +60,7 @@ jobs: mdbook-version: "0.4.10" - name: Install mdbook extensions - run: cargo install mdbook-combiner mdbook-mermaid + run: cargo install mdbook-combiner@0.1.15 mdbook-mermaid - name: Prepare docs run: | diff --git a/.github/workflows/update-jitsi.yml b/.github/workflows/update-jitsi.yml index 3a1ea33cbf..10a082cf24 100644 --- a/.github/workflows/update-jitsi.yml +++ b/.github/workflows/update-jitsi.yml @@ -21,7 +21,7 @@ jobs: run: "yarn update:jitsi" - name: Create Pull Request - uses: peter-evans/create-pull-request@c55203cfde3e5c11a452d352b4393e68b85b4533 # v6 + uses: peter-evans/create-pull-request@6d6857d36972b65feb161a90e484f2984215f83e # v6 with: token: ${{ secrets.ELEMENT_BOT_TOKEN }} branch: actions/jitsi-update diff --git a/CHANGELOG.md b/CHANGELOG.md index 321acf18ed..f8c113a443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,47 @@ +Changes in [1.11.69](https://github.com/element-hq/element-web/releases/tag/v1.11.69) (2024-06-18) +================================================================================================== +## ✨ Features + +* Change avatar setting component to use a menu ([#12585](https://github.com/matrix-org/matrix-react-sdk/pull/12585)). Contributed by @dbkr. +* New user profile UI in User Settings ([#12548](https://github.com/matrix-org/matrix-react-sdk/pull/12548)). Contributed by @dbkr. +* MSC4108 support OIDC QR code login ([#12370](https://github.com/matrix-org/matrix-react-sdk/pull/12370)). Contributed by @t3chguy. + +## 🐛 Bug Fixes + +* Fix image upload preview size ([#12612](https://github.com/matrix-org/matrix-react-sdk/pull/12612)). Contributed by @RiotRobot. +* Fix screen sharing in recent Chrome (https://github.com/matrix-org/matrix-js-sdk/pull/4243). +* Fix roving tab index crash `compareDocumentPosition` ([#12594](https://github.com/matrix-org/matrix-react-sdk/pull/12594)). Contributed by @t3chguy. +* Keep dialog glass border on narrow screens ([#12591](https://github.com/matrix-org/matrix-react-sdk/pull/12591)). Contributed by @dbkr. +* Add missing a11y label to dismiss onboarding button in room list ([#12587](https://github.com/matrix-org/matrix-react-sdk/pull/12587)). Contributed by @t3chguy. +* Add hover / active state on avatar setting upload button ([#12590](https://github.com/matrix-org/matrix-react-sdk/pull/12590)). Contributed by @dbkr. +* Fix EditInPlace button styles ([#12589](https://github.com/matrix-org/matrix-react-sdk/pull/12589)). Contributed by @dbkr. +* Fix incorrect assumptions about required fields in /search response ([#12575](https://github.com/matrix-org/matrix-react-sdk/pull/12575)). Contributed by @t3chguy. +* Fix display of no avatar in avatar setting controls ([#12558](https://github.com/matrix-org/matrix-react-sdk/pull/12558)). Contributed by @dbkr. +* Element-R: pass pickleKey in as raw key for indexeddb encryption ([#12543](https://github.com/matrix-org/matrix-react-sdk/pull/12543)). Contributed by @richvdh. + + + +Changes in [1.11.68](https://github.com/element-hq/element-web/releases/tag/v1.11.68) (2024-06-04) +================================================================================================== +## ✨ Features + +* Tooltip: Improve accessibility for context menus ([#12462](https://github.com/matrix-org/matrix-react-sdk/pull/12462)). Contributed by @florianduros. +* Tooltip: Improve accessibility of space panel ([#12525](https://github.com/matrix-org/matrix-react-sdk/pull/12525)). Contributed by @florianduros. + +## 🐛 Bug Fixes + +* Close the release announcement when a dialog is opened ([#12559](https://github.com/matrix-org/matrix-react-sdk/pull/12559)). Contributed by @florianduros. +* Tooltip: close field tooltip when ESC is pressed ([#12553](https://github.com/matrix-org/matrix-react-sdk/pull/12553)). Contributed by @florianduros. +* Fix tabbedview breakpoint width ([#12556](https://github.com/matrix-org/matrix-react-sdk/pull/12556)). Contributed by @dbkr. +* Fix E2E icon display in room header ([#12545](https://github.com/matrix-org/matrix-react-sdk/pull/12545)). Contributed by @florianduros. +* Tooltip: Improve placement for space settings ([#12541](https://github.com/matrix-org/matrix-react-sdk/pull/12541)). Contributed by @florianduros. +* Fix deformed avatar in a call in a narrow timeline ([#12538](https://github.com/matrix-org/matrix-react-sdk/pull/12538)). Contributed by @florianduros. +* Shown own sent state indicator even when showReadReceipts is disabled ([#12540](https://github.com/matrix-org/matrix-react-sdk/pull/12540)). Contributed by @t3chguy. +* Ensure we do not fire the verification mismatch modal multiple times ([#12526](https://github.com/matrix-org/matrix-react-sdk/pull/12526)). Contributed by @t3chguy. +* Fix avatar in chat export ([#12537](https://github.com/matrix-org/matrix-react-sdk/pull/12537)). Contributed by @florianduros. +* Use `*` for italics as it doesn't break when used mid-word ([#12523](https://github.com/matrix-org/matrix-react-sdk/pull/12523)). Contributed by @t3chguy. + + Changes in [1.11.67](https://github.com/element-hq/element-web/releases/tag/v1.11.67) (2024-05-22) ================================================================================================== ## ✨ Features diff --git a/element.io/app/config.json b/element.io/app/config.json index 2214dbc7ea..27ab4abd6f 100644 --- a/element.io/app/config.json +++ b/element.io/app/config.json @@ -45,6 +45,6 @@ "privacy_policy_url": "https://element.io/cookie-policy", "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx", "setting_defaults": { - "RustCrypto.staged_rollout_percent": 30 + "RustCrypto.staged_rollout_percent": 60 } } diff --git a/linked-dependencies/matrix-react-sdk/.eslintrc.js b/linked-dependencies/matrix-react-sdk/.eslintrc.js index caeeca403d..4bec4e8320 100644 --- a/linked-dependencies/matrix-react-sdk/.eslintrc.js +++ b/linked-dependencies/matrix-react-sdk/.eslintrc.js @@ -98,8 +98,6 @@ module.exports = { "!matrix-js-sdk/src/secret-storage", "!matrix-js-sdk/src/room-hierarchy", "!matrix-js-sdk/src/rendezvous", - "!matrix-js-sdk/src/rendezvous/transports", - "!matrix-js-sdk/src/rendezvous/channels", "!matrix-js-sdk/src/indexeddb-worker", "!matrix-js-sdk/src/pushprocessor", "!matrix-js-sdk/src/extensible_events_v1", diff --git a/linked-dependencies/matrix-react-sdk/.github/CODEOWNERS b/linked-dependencies/matrix-react-sdk/.github/CODEOWNERS index 91764ba7f5..e7963c2673 100644 --- a/linked-dependencies/matrix-react-sdk/.github/CODEOWNERS +++ b/linked-dependencies/matrix-react-sdk/.github/CODEOWNERS @@ -11,4 +11,7 @@ /src/stores/SetupEncryptionStore.ts @matrix-org/element-crypto-web-reviewers /test/stores/SetupEncryptionStore-test.ts @matrix-org/element-crypto-web-reviewers +# Ignore translations as those will be updated by GHA for Localazy download /src/i18n/strings +# Ignore the synapse plugin as this is updated by GHA for docker image updating +/playwright/plugins/homeserver/synapse/index.ts diff --git a/linked-dependencies/matrix-react-sdk/.github/workflows/end-to-end-tests-netlify.yaml b/linked-dependencies/matrix-react-sdk/.github/workflows/end-to-end-tests-netlify.yaml index 4667bfb02b..a488cbbfb0 100644 --- a/linked-dependencies/matrix-react-sdk/.github/workflows/end-to-end-tests-netlify.yaml +++ b/linked-dependencies/matrix-react-sdk/.github/workflows/end-to-end-tests-netlify.yaml @@ -21,38 +21,13 @@ jobs: statuses: write deployments: write steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - uses: actions/setup-node@v4 - with: - cache: "yarn" - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Download blob reports from GitHub Actions Artifacts + - name: Download HTML report uses: actions/download-artifact@v4 with: github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} run-id: ${{ github.event.workflow_run.id }} - pattern: all-blob-reports-* - path: all-blob-reports - merge-multiple: true - - - name: Merge into HTML Report - run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts ./all-blob-reports - env: - # Only pass creds to the flaky-reporter on main branch runs - GITHUB_TOKEN: ${{ github.event.workflow_run.head_branch == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }} - - - name: Upload HTML report - uses: actions/upload-artifact@v4 - with: - name: html-report--attempt-${{ github.run_attempt }} + name: html-report path: playwright-report - retention-days: 14 - name: 📤 Deploy to Netlify uses: matrix-org/netlify-pr-preview@v3 diff --git a/linked-dependencies/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml b/linked-dependencies/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml index 0e224c04db..b663948254 100644 --- a/linked-dependencies/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml +++ b/linked-dependencies/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml @@ -46,6 +46,7 @@ jobs: build: name: "Build Element-Web" runs-on: ubuntu-latest + if: inputs.skip != true steps: - name: Checkout code uses: actions/checkout@v4 @@ -163,5 +164,43 @@ jobs: if: always() runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + if: inputs.skip != true + with: + persist-credentials: false + repository: ${{ inputs.react-sdk-repository || github.repository }} + + - uses: actions/setup-node@v4 + if: inputs.skip != true + with: + cache: "yarn" + + - name: Install dependencies + if: inputs.skip != true + run: yarn install --frozen-lockfile + + - name: Download blob reports from GitHub Actions Artifacts + if: inputs.skip != true + uses: actions/download-artifact@v4 + with: + pattern: all-blob-reports-* + path: all-blob-reports + merge-multiple: true + + - name: Merge into HTML Report + if: inputs.skip != true + run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts ./all-blob-reports + env: + # Only pass creds to the flaky-reporter on main branch runs + GITHUB_TOKEN: ${{ github.event.workflow_run.head_branch == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }} + + - name: Upload HTML report + if: inputs.skip != true + uses: actions/upload-artifact@v4 + with: + name: html-report + path: playwright-report + retention-days: 14 + - if: needs.playwright.result != 'skipped' && needs.playwright.result != 'success' run: exit 1 diff --git a/linked-dependencies/matrix-react-sdk/.github/workflows/playwright-image-updates.yaml b/linked-dependencies/matrix-react-sdk/.github/workflows/playwright-image-updates.yaml new file mode 100644 index 0000000000..15bea28e0f --- /dev/null +++ b/linked-dependencies/matrix-react-sdk/.github/workflows/playwright-image-updates.yaml @@ -0,0 +1,45 @@ +name: Update Playwright docker images +on: + workflow_dispatch: {} + schedule: + - cron: "0 6 * * *" # Every day at 6am UTC +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Update matrixdotorg/synapse image + run: | + docker pull "$IMAGE" + INSPECT=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE") + DIGEST=${INSPECT#*@} + sed -i "s/const DOCKER_TAG.*/const DOCKER_TAG = \"develop@$DIGEST\";/" playwright/plugins/homeserver/synapse/index.ts + env: + IMAGE: matrixdotorg/synapse:develop + + - name: Create Pull Request + id: cpr + uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5 + with: + token: ${{ secrets.ELEMENT_BOT_TOKEN }} + branch: actions/playwright-image-updates + delete-branch: true + title: Playwright Docker image updates + labels: | + T-Task + + - name: Enable automerge + run: gh pr merge --merge --auto "$PR_NUMBER" + if: steps.cpr.outputs.pull-request-operation == 'created' + env: + GH_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} + + - name: Enable autoapprove + run: | + gh pr review --approve "$PR_NUMBER" + if: steps.cpr.outputs.pull-request-operation == 'created' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} diff --git a/linked-dependencies/matrix-react-sdk/.github/workflows/static_analysis.yaml b/linked-dependencies/matrix-react-sdk/.github/workflows/static_analysis.yaml index 070ac5f854..6e225467af 100644 --- a/linked-dependencies/matrix-react-sdk/.github/workflows/static_analysis.yaml +++ b/linked-dependencies/matrix-react-sdk/.github/workflows/static_analysis.yaml @@ -27,7 +27,7 @@ jobs: cache: "yarn" - name: Install Deps - run: "./scripts/ci/install-deps.sh --ignore-scripts" + run: "./scripts/ci/install-deps.sh" - name: Typecheck run: "yarn run lint:types" diff --git a/linked-dependencies/matrix-react-sdk/.github/workflows/tests.yml b/linked-dependencies/matrix-react-sdk/.github/workflows/tests.yml index 7089569f73..3815c4fb4c 100644 --- a/linked-dependencies/matrix-react-sdk/.github/workflows/tests.yml +++ b/linked-dependencies/matrix-react-sdk/.github/workflows/tests.yml @@ -47,7 +47,7 @@ jobs: cache: "yarn" - name: Install Deps - run: "./scripts/ci/install-deps.sh --ignore-scripts" + run: "./scripts/ci/install-deps.sh" env: JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} diff --git a/linked-dependencies/matrix-react-sdk/CHANGELOG.md b/linked-dependencies/matrix-react-sdk/CHANGELOG.md index ea324f0e03..ea499883aa 100644 --- a/linked-dependencies/matrix-react-sdk/CHANGELOG.md +++ b/linked-dependencies/matrix-react-sdk/CHANGELOG.md @@ -1,3 +1,45 @@ +Changes in [3.101.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.101.0) (2024-06-18) +======================================================================================================= +## ✨ Features + +* Change avatar setting component to use a menu ([#12585](https://github.com/matrix-org/matrix-react-sdk/pull/12585)). Contributed by @dbkr. +* New user profile UI in User Settings ([#12548](https://github.com/matrix-org/matrix-react-sdk/pull/12548)). Contributed by @dbkr. +* MSC4108 support OIDC QR code login ([#12370](https://github.com/matrix-org/matrix-react-sdk/pull/12370)). Contributed by @t3chguy. + +## 🐛 Bug Fixes + +* [Backport staging] Fix image upload preview size ([#12612](https://github.com/matrix-org/matrix-react-sdk/pull/12612)). Contributed by @RiotRobot. +* Fix roving tab index crash `compareDocumentPosition` ([#12594](https://github.com/matrix-org/matrix-react-sdk/pull/12594)). Contributed by @t3chguy. +* Keep dialog glass border on narrow screens ([#12591](https://github.com/matrix-org/matrix-react-sdk/pull/12591)). Contributed by @dbkr. +* Add missing a11y label to dismiss onboarding button in room list ([#12587](https://github.com/matrix-org/matrix-react-sdk/pull/12587)). Contributed by @t3chguy. +* Add hover / active state on avatar setting upload button ([#12590](https://github.com/matrix-org/matrix-react-sdk/pull/12590)). Contributed by @dbkr. +* Fix EditInPlace button styles ([#12589](https://github.com/matrix-org/matrix-react-sdk/pull/12589)). Contributed by @dbkr. +* Fix incorrect assumptions about required fields in /search response ([#12575](https://github.com/matrix-org/matrix-react-sdk/pull/12575)). Contributed by @t3chguy. +* Fix display of no avatar in avatar setting controls ([#12558](https://github.com/matrix-org/matrix-react-sdk/pull/12558)). Contributed by @dbkr. +* Element-R: pass pickleKey in as raw key for indexeddb encryption ([#12543](https://github.com/matrix-org/matrix-react-sdk/pull/12543)). Contributed by @richvdh. + + +Changes in [3.100.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.100.0) (2024-06-04) +======================================================================================================= +## ✨ Features + +* Tooltip: Improve accessibility for context menus ([#12462](https://github.com/matrix-org/matrix-react-sdk/pull/12462)). Contributed by @florianduros. +* Tooltip: Improve accessibility of space panel ([#12525](https://github.com/matrix-org/matrix-react-sdk/pull/12525)). Contributed by @florianduros. + +## 🐛 Bug Fixes + +* Close the release announcement when a dialog is opened ([#12559](https://github.com/matrix-org/matrix-react-sdk/pull/12559)). Contributed by @florianduros. +* Tooltip: close field tooltip when ESC is pressed ([#12553](https://github.com/matrix-org/matrix-react-sdk/pull/12553)). Contributed by @florianduros. +* Fix tabbedview breakpoint width ([#12556](https://github.com/matrix-org/matrix-react-sdk/pull/12556)). Contributed by @dbkr. +* Fix E2E icon display in room header ([#12545](https://github.com/matrix-org/matrix-react-sdk/pull/12545)). Contributed by @florianduros. +* Tooltip: Improve placement for space settings ([#12541](https://github.com/matrix-org/matrix-react-sdk/pull/12541)). Contributed by @florianduros. +* Fix deformed avatar in a call in a narrow timeline ([#12538](https://github.com/matrix-org/matrix-react-sdk/pull/12538)). Contributed by @florianduros. +* Shown own sent state indicator even when showReadReceipts is disabled ([#12540](https://github.com/matrix-org/matrix-react-sdk/pull/12540)). Contributed by @t3chguy. +* Ensure we do not fire the verification mismatch modal multiple times ([#12526](https://github.com/matrix-org/matrix-react-sdk/pull/12526)). Contributed by @t3chguy. +* Fix avatar in chat export ([#12537](https://github.com/matrix-org/matrix-react-sdk/pull/12537)). Contributed by @florianduros. +* Use `*` for italics as it doesn't break when used mid-word ([#12523](https://github.com/matrix-org/matrix-react-sdk/pull/12523)). Contributed by @t3chguy. + + Changes in [3.99.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.99.0) (2024-05-07) ===================================================================================================== ## ✨ Features diff --git a/linked-dependencies/matrix-react-sdk/package.json b/linked-dependencies/matrix-react-sdk/package.json index 01e1ff56c5..6e21f438d1 100644 --- a/linked-dependencies/matrix-react-sdk/package.json +++ b/linked-dependencies/matrix-react-sdk/package.json @@ -1,7 +1,7 @@ { "name": "matrix-react-sdk", "version": "0.0.0", - "version-matrix": "3.100.0-rc.0", + "version-matrix": "3.101.0", "description": "SDK for matrix.org using React for Tchap", "author": "DINUM", "repository": { @@ -63,12 +63,13 @@ "resolutions": { "@types/react-dom": "17.0.25", "@types/react": "17.0.80", + "@types/seedrandom": "3.0.4", "oidc-client-ts": "3.0.1", "jwt-decode": "4.0.0" }, "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/analytics-events": "^0.20.0", + "@matrix-org/analytics-events": "^0.21.0", "@matrix-org/emojibase-bindings": "^1.1.2", "@matrix-org/matrix-wysiwyg": "2.17.0", "@matrix-org/olm": "3.2.15", @@ -77,11 +78,12 @@ "@sentry/browser": "^7.0.0", "@testing-library/react-hooks": "^8.0.1", "@vector-im/compound-design-tokens": "^1.2.0", - "@vector-im/compound-web": "^4.2.0", + "@vector-im/compound-web": "^4.4.1", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", "await-lock": "^2.1.0", + "bloom-filters": "^3.0.1", "blurhash": "^2.0.3", "classnames": "^2.2.6", "commonmark": "^0.31.0", @@ -91,7 +93,7 @@ "emojibase-regex": "15.3.0", "escape-html": "^1.0.3", "file-saver": "^2.0.5", - "filesize": "10.1.1", + "filesize": "10.1.2", "gfm.css": "^1.1.2", "glob-to-regexp": "^0.4.1", "graphemer": "^1.4.0", @@ -109,7 +111,7 @@ "maplibre-gl": "^2.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "32.3.0-rc.0", + "matrix-js-sdk": "33.1.0", "matrix-widget-api": "^1.5.0", "memoize-one": "^6.0.0", "minimist": "^1.2.5", @@ -117,7 +119,7 @@ "opus-recorder": "^8.0.3", "pako": "^2.0.3", "png-chunks-extract": "^1.0.0", - "posthog-js": "1.130.1", + "posthog-js": "1.135.2", "proposal-temporal": "^0.9.0", "qrcode": "1.5.3", "re-resizable": "^6.9.0", @@ -183,25 +185,26 @@ "@types/react-transition-group": "^4.4.0", "@types/sanitize-html": "2.11.0", "@types/sdp-transform": "^2.4.6", + "@types/seedrandom": "3.0.4", "@types/tar-js": "^0.3.2", "@types/ua-parser-js": "^0.7.36", "@types/uuid": "^9.0.2", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", - "axe-core": "4.9.0", + "axe-core": "4.9.1", "babel-jest": "^29.0.0", "blob-polyfill": "^7.0.0", "eslint": "8.57.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^9.0.0", - "eslint-plugin-deprecate": "0.8.4", + "eslint-plugin-deprecate": "0.8.5", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jest": "^28.0.0", "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-matrix-org": "1.2.1", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.3.0", - "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-unicorn": "^53.0.0", "express": "^4.18.2", "fake-indexeddb": "^5.0.2", "fetch-mock-jest": "^1.5.1", diff --git a/linked-dependencies/matrix-react-sdk/playwright/Dockerfile b/linked-dependencies/matrix-react-sdk/playwright/Dockerfile index 46d617ccc2..7179e08ab0 100644 --- a/linked-dependencies/matrix-react-sdk/playwright/Dockerfile +++ b/linked-dependencies/matrix-react-sdk/playwright/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/playwright:v1.43.1-jammy +FROM mcr.microsoft.com/playwright:v1.44.1-jammy WORKDIR /work/matrix-react-sdk VOLUME ["/work/element-web/node_modules"] diff --git a/linked-dependencies/matrix-react-sdk/playwright/e2e/crypto/verification.spec.ts b/linked-dependencies/matrix-react-sdk/playwright/e2e/crypto/verification.spec.ts index 6819606b64..e471b6b2f5 100644 --- a/linked-dependencies/matrix-react-sdk/playwright/e2e/crypto/verification.spec.ts +++ b/linked-dependencies/matrix-react-sdk/playwright/e2e/crypto/verification.spec.ts @@ -240,24 +240,26 @@ test.describe("User verification", () => { test.use({ displayName: "Alice", botCreateOpts: { displayName: "Bob", autoAcceptInvites: true, userIdPrefix: "bob_" }, + room: async ({ page, app, bot: bob, user: aliceCredentials }, use) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + + // the other user creates a DM + const dmRoomId = await createDMRoom(bob, aliceCredentials.userId); + + // accept the DM + await app.viewRoomByName("Bob"); + await page.getByRole("button", { name: "Start chatting" }).click(); + await use({ roomId: dmRoomId }); + }, }); test("can receive a verification request when there is no existing DM", async ({ page, - app, bot: bob, user: aliceCredentials, toasts, + room: { roomId: dmRoomId }, }) => { - await app.client.bootstrapCrossSigning(aliceCredentials); - - // the other user creates a DM - const dmRoomId = await createDMRoom(bob, aliceCredentials.userId); - - // accept the DM - await app.viewRoomByName("Bob"); - await page.getByRole("button", { name: "Start chatting" }).click(); - // once Alice has joined, Bob starts the verification const bobVerificationRequest = await bob.evaluateHandle( async (client, { dmRoomId, aliceCredentials }) => { @@ -294,6 +296,51 @@ test.describe("User verification", () => { await expect(page.getByText("You've successfully verified Bob!")).toBeVisible(); await page.getByRole("button", { name: "Got it" }).click(); }); + + test("can abort emoji verification when emoji mismatch", async ({ + page, + bot: bob, + user: aliceCredentials, + toasts, + room: { roomId: dmRoomId }, + cryptoBackend, + }) => { + test.skip(cryptoBackend === "legacy", "Not implemented for legacy crypto"); + + // once Alice has joined, Bob starts the verification + const bobVerificationRequest = await bob.evaluateHandle( + async (client, { dmRoomId, aliceCredentials }) => { + const room = client.getRoom(dmRoomId); + while (room.getMember(aliceCredentials.userId)?.membership !== "join") { + await new Promise((resolve) => { + room.once(window.matrixcs.RoomStateEvent.Members, resolve); + }); + } + + return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); + }, + { dmRoomId, aliceCredentials }, + ); + + // Accept verification via toast + const toast = await toasts.getToast("Verification requested"); + await toast.getByRole("button", { name: "Verify Session" }).click(); + + // request verification by emoji + await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click(); + + /* on the bot side, wait for the verifier to exist ... */ + const botVerifier = await awaitVerifier(bobVerificationRequest); + // ... confirm ... + botVerifier.evaluate((verifier) => verifier.verify()).catch(() => {}); + // ... and abort the verification + await page.getByRole("button", { name: "They don't match" }).click(); + + const dialog = page.locator(".mx_Dialog"); + await expect(dialog.getByText("Your messages are not secure")).toBeVisible(); + await dialog.getByRole("button", { name: "OK" }).click(); + await expect(dialog).not.toBeVisible(); + }); }); /** Extract the qrcode out of an on-screen html element */ diff --git a/linked-dependencies/matrix-react-sdk/playwright/e2e/file-upload/image-upload.spec.ts b/linked-dependencies/matrix-react-sdk/playwright/e2e/file-upload/image-upload.spec.ts new file mode 100644 index 0000000000..8f0403af31 --- /dev/null +++ b/linked-dependencies/matrix-react-sdk/playwright/e2e/file-upload/image-upload.spec.ts @@ -0,0 +1,45 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect } from "../../element-web-test"; + +test.describe("Image Upload", () => { + test.use({ + displayName: "Alice", + }); + + test.beforeEach(async ({ page, app, user }) => { + await app.client.createRoom({ name: "My Pictures" }); + await app.viewRoomByName("My Pictures"); + + // Wait until configuration is finished + await expect( + page + .locator(".mx_GenericEventListSummary[data-layout='group'] .mx_GenericEventListSummary_summary") + .getByText(`${user.displayName} created and configured the room.`), + ).toBeVisible(); + }); + + test("should show image preview when uploading an image", async ({ page, app }) => { + await page + .locator(".mx_MessageComposer_actions input[type='file']") + .setInputFiles("playwright/sample-files/riot.png"); + + expect(page.getByRole("button", { name: "Upload" })).toBeEnabled(); + expect(page.getByRole("button", { name: "Close dialog" })).toBeEnabled(); + expect(page).toMatchScreenshot("image-upload-preview.png"); + }); +}); diff --git a/linked-dependencies/matrix-react-sdk/playwright/e2e/room/room-header.spec.ts b/linked-dependencies/matrix-react-sdk/playwright/e2e/room/room-header.spec.ts index a3c5e8c8bc..4008517d09 100644 --- a/linked-dependencies/matrix-react-sdk/playwright/e2e/room/room-header.spec.ts +++ b/linked-dependencies/matrix-react-sdk/playwright/e2e/room/room-header.spec.ts @@ -276,4 +276,26 @@ test.describe("Room Header", () => { await expect(header).toMatchScreenshot("room-header-with-apps-button-not-highlighted.png"); }); }); + + test.describe("with encryption", () => { + test("should render the E2E icon and the buttons", async ({ page, app, user }) => { + // Create an encrypted room + await app.client.createRoom({ + name: "Test Encrypted Room", + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); + await app.viewRoomByName("Test Encrypted Room"); + + const header = page.locator(".mx_LegacyRoomHeader"); + await expect(header).toMatchScreenshot("encrypted-room-header.png"); + }); + }); }); diff --git a/linked-dependencies/matrix-react-sdk/playwright/e2e/settings/general-user-settings-tab.spec.ts b/linked-dependencies/matrix-react-sdk/playwright/e2e/settings/general-user-settings-tab.spec.ts index 625f1d6bd5..41210292a3 100644 --- a/linked-dependencies/matrix-react-sdk/playwright/e2e/settings/general-user-settings-tab.spec.ts +++ b/linked-dependencies/matrix-react-sdk/playwright/e2e/settings/general-user-settings-tab.spec.ts @@ -21,8 +21,6 @@ const USER_NAME_NEW = "Alice"; const IntegrationManager = "scalar.vector.im"; test.describe("General user settings tab", () => { - let userId: string; - test.use({ displayName: USER_NAME, config: { @@ -34,26 +32,18 @@ test.describe("General user settings tab", () => { }, }); - test("should be rendered properly", async ({ uut }) => { + test("should be rendered properly", async ({ uut, user }) => { await expect(uut).toMatchScreenshot("general.png"); // Assert that the top heading is rendered await expect(uut.getByRole("heading", { name: "General" })).toBeVisible(); - const profile = uut.locator(".mx_ProfileSettings_profile"); + const profile = uut.locator(".mx_UserProfileSettings_profile"); await profile.scrollIntoViewIfNeeded(); await expect(profile.getByRole("textbox", { name: "Display Name" })).toHaveValue(USER_NAME); // Assert that a userId is rendered - await expect(profile.locator(".mx_ProfileSettings_profile_controls_userId", { hasText: userId })).toBeVisible(); - - // Check avatar setting - const avatar = profile.locator(".mx_AvatarSetting_avatar"); - await avatar.hover(); - - // Hover effect - await expect(avatar.locator(".mx_AvatarSetting_hoverBg")).toBeVisible(); - await expect(avatar.locator(".mx_AvatarSetting_hover span").getByText("Upload")).toBeVisible(); + expect(uut.getByLabel("Username")).toHaveText(user.userId); // Wait until spinners disappear await expect(uut.getByTestId("accountSection").locator(".mx_Spinner")).not.toBeVisible(); @@ -130,20 +120,20 @@ test.describe("General user settings tab", () => { await expect(uut).toMatchScreenshot("general-smallscreen.png"); }); - test("should support adding and removing a profile picture", async ({ uut }) => { - const profileSettings = uut.locator(".mx_ProfileSettings"); + test("should support adding and removing a profile picture", async ({ uut, page }) => { + const profileSettings = uut.locator(".mx_UserProfileSettings"); // Upload a picture - await profileSettings - .locator(".mx_ProfileSettings_avatarUpload") - .setInputFiles("playwright/sample-files/riot.png"); + await profileSettings.getByAltText("Upload").setInputFiles("playwright/sample-files/riot.png"); - // Find and click "Remove" link button - await profileSettings.locator(".mx_ProfileSettings_profile").getByRole("button", { name: "Remove" }).click(); + // Image should be visible + await expect(profileSettings.locator(".mx_AvatarSetting_avatar img")).toBeVisible(); - // Assert that the link button disappeared - await expect( - profileSettings.locator(".mx_AvatarSetting_avatar .mx_AccessibleButton_kind_link_sm"), - ).not.toBeVisible(); + // Open the menu & click remove + await profileSettings.getByRole("button", { name: "Profile Picture" }).click(); + await page.getByRole("menuitem", { name: "Remove" }).click(); + + // Assert that the image disappeared + await expect(profileSettings.locator(".mx_AvatarSetting_avatar img")).not.toBeVisible(); }); test("should set a country calling code based on default_country_code", async ({ uut }) => { @@ -177,7 +167,7 @@ test.describe("General user settings tab", () => { test("should support changing a display name", async ({ uut, page, app }) => { // Change the diaplay name to USER_NAME_NEW const displayNameInput = uut - .locator(".mx_SettingsTab .mx_ProfileSettings") + .locator(".mx_SettingsTab .mx_UserProfileSettings") .getByRole("textbox", { name: "Display Name" }); await displayNameInput.fill(USER_NAME_NEW); await displayNameInput.press("Enter"); diff --git a/linked-dependencies/matrix-react-sdk/playwright/plugins/homeserver/synapse/index.ts b/linked-dependencies/matrix-react-sdk/playwright/plugins/homeserver/synapse/index.ts index c11f937cf3..c88fd641d9 100644 --- a/linked-dependencies/matrix-react-sdk/playwright/plugins/homeserver/synapse/index.ts +++ b/linked-dependencies/matrix-react-sdk/playwright/plugins/homeserver/synapse/index.ts @@ -25,6 +25,11 @@ import { Docker } from "../../docker"; import { HomeserverConfig, HomeserverInstance, Homeserver, StartHomeserverOpts, Credentials } from ".."; import { randB64Bytes } from "../../utils/rand"; +// Docker tag to use for `matrixdotorg/synapse` 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:c357ea1486c8cd2613932e97bd2f6ff4e8b4c4fafcb2c28d1e8ec0d383c56d9d"; + async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); @@ -103,7 +108,7 @@ export class Synapse implements Homeserver, HomeserverInstance { console.log(`Starting synapse with config dir ${synCfg.configDir}...`); const dockerSynapseParams = ["-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`]; const synapseId = await this.docker.run({ - image: "matrixdotorg/synapse:develop", + image: `matrixdotorg/synapse:${DOCKER_TAG}`, containerName: `react-sdk-playwright-synapse`, params: dockerSynapseParams, cmd: ["run"], diff --git a/linked-dependencies/matrix-react-sdk/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png b/linked-dependencies/matrix-react-sdk/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png new file mode 100644 index 0000000000..75a9c353de Binary files /dev/null and b/linked-dependencies/matrix-react-sdk/playwright/snapshots/file-upload/image-upload.spec.ts/image-upload-preview-linux.png differ diff --git a/linked-dependencies/matrix-react-sdk/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png b/linked-dependencies/matrix-react-sdk/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png new file mode 100644 index 0000000000..6dced2e990 Binary files /dev/null and b/linked-dependencies/matrix-react-sdk/playwright/snapshots/room/room-header.spec.ts/encrypted-room-header-linux.png differ diff --git a/linked-dependencies/matrix-react-sdk/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png b/linked-dependencies/matrix-react-sdk/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png index 253b230419..57e2a4026c 100644 Binary files a/linked-dependencies/matrix-react-sdk/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png and b/linked-dependencies/matrix-react-sdk/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/linked-dependencies/matrix-react-sdk/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png b/linked-dependencies/matrix-react-sdk/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png index e11ef9c410..af35cc8bb4 100644 Binary files a/linked-dependencies/matrix-react-sdk/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png and b/linked-dependencies/matrix-react-sdk/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png differ diff --git a/linked-dependencies/matrix-react-sdk/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png b/linked-dependencies/matrix-react-sdk/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png index 75febc97d7..d59d2946da 100644 Binary files a/linked-dependencies/matrix-react-sdk/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png and b/linked-dependencies/matrix-react-sdk/playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-smallscreen-linux.png differ diff --git a/linked-dependencies/matrix-react-sdk/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png b/linked-dependencies/matrix-react-sdk/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png index 9fc79671a1..281f1cebe5 100644 Binary files a/linked-dependencies/matrix-react-sdk/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png and b/linked-dependencies/matrix-react-sdk/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png differ diff --git a/linked-dependencies/matrix-react-sdk/res/css/_common.pcss b/linked-dependencies/matrix-react-sdk/res/css/_common.pcss index 20ed9dfa39..d120194491 100644 --- a/linked-dependencies/matrix-react-sdk/res/css/_common.pcss +++ b/linked-dependencies/matrix-react-sdk/res/css/_common.pcss @@ -332,7 +332,10 @@ legend { .mx_Dialog_border { z-index: var(--dialog-zIndex-standard); position: relative; - max-height: calc(100% - var(--cpd-space-12x)); + width: 100%; + max-width: fit-content; + box-sizing: border-box; + max-height: calc(100% - var(--cpd-space-6x)); display: flex; flex-direction: column; @@ -597,7 +600,10 @@ legend { * Elements that should not be styled like a dialog button are mentioned in a :not() pseudo-class. * For the widest browser support, we use multiple :not pseudo-classes instead of :not(.a, .b). */ -.mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton), +.mx_Dialog + button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( + .mx_UserProfileSettings button + ), .mx_Dialog input[type="submit"], .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons input[type="submit"] { @@ -614,11 +620,17 @@ legend { font-family: inherit; } -.mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):last-child { +.mx_Dialog + button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( + .mx_UserProfileSettings button + ):last-child { margin-right: 0px; } -.mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):focus, +.mx_Dialog + button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( + .mx_UserProfileSettings button + ):focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, .mx_Dialog_buttons input[type="submit"]:focus { @@ -627,7 +639,10 @@ legend { .mx_Dialog button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]), .mx_Dialog input[type="submit"].mx_Dialog_primary, -.mx_Dialog_buttons button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), +.mx_Dialog_buttons + button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not( + .mx_UserProfileSettings button + ), .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { color: var(--cpd-color-text-on-solid-primary); background-color: var(--cpd-color-bg-action-primary-rest); @@ -637,7 +652,8 @@ legend { .mx_Dialog button.danger:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]), .mx_Dialog input[type="submit"].danger, -.mx_Dialog_buttons button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), +.mx_Dialog_buttons + button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button), .mx_Dialog_buttons input[type="submit"].danger { background-color: var(--cpd-color-bg-critical-primary); border: solid 1px var(--cpd-color-bg-critical-primary); @@ -650,7 +666,10 @@ legend { color: var(--cpd-color-text-critical-primary); } -.mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):disabled, +.mx_Dialog + button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( + .mx_UserProfileSettings button + ):disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, .mx_Dialog_buttons input[type="submit"]:disabled { diff --git a/linked-dependencies/matrix-react-sdk/res/css/_components.pcss b/linked-dependencies/matrix-react-sdk/res/css/_components.pcss index cc7e41bc99..a7c79bfbf2 100644 --- a/linked-dependencies/matrix-react-sdk/res/css/_components.pcss +++ b/linked-dependencies/matrix-react-sdk/res/css/_components.pcss @@ -337,7 +337,7 @@ @import "./views/settings/_Notifications.pcss"; @import "./views/settings/_PhoneNumbers.pcss"; @import "./views/settings/_PowerLevelSelector.pcss"; -@import "./views/settings/_ProfileSettings.pcss"; +@import "./views/settings/_RoomProfileSettings.pcss"; @import "./views/settings/_SecureBackupPanel.pcss"; @import "./views/settings/_SetIdServer.pcss"; @import "./views/settings/_SetIntegrationManager.pcss"; @@ -345,6 +345,7 @@ @import "./views/settings/_SpellCheckLanguages.pcss"; @import "./views/settings/_ThemeChoicePanel.pcss"; @import "./views/settings/_UpdateCheckButton.pcss"; +@import "./views/settings/_UserProfileSettings.pcss"; @import "./views/settings/tabs/_SettingsBanner.pcss"; @import "./views/settings/tabs/_SettingsIndent.pcss"; @import "./views/settings/tabs/_SettingsSection.pcss"; diff --git a/linked-dependencies/matrix-react-sdk/res/css/components/views/elements/_AppPermission.pcss b/linked-dependencies/matrix-react-sdk/res/css/components/views/elements/_AppPermission.pcss index 4bbe0ac07a..3b770c7879 100644 --- a/linked-dependencies/matrix-react-sdk/res/css/components/views/elements/_AppPermission.pcss +++ b/linked-dependencies/matrix-react-sdk/res/css/components/views/elements/_AppPermission.pcss @@ -44,24 +44,3 @@ limitations under the License. } } } - -.mx_Tooltip.mx_Tooltip--appPermission { - box-shadow: none; - background-color: $tooltip-timeline-bg-color; - color: $tooltip-timeline-fg-color; - border: none; - border-radius: 3px; - padding: 6px 8px; - - &.mx_Tooltip--appPermission--dark { - .mx_Tooltip_chevron::after { - border-right-color: $tooltip-timeline-bg-color; - } - } - - ul { - list-style-position: inside; - padding-left: 2px; - margin-left: 0; - } -} diff --git a/linked-dependencies/matrix-react-sdk/res/css/structures/_SpacePanel.pcss b/linked-dependencies/matrix-react-sdk/res/css/structures/_SpacePanel.pcss index 0252da01b7..d685617d5b 100644 --- a/linked-dependencies/matrix-react-sdk/res/css/structures/_SpacePanel.pcss +++ b/linked-dependencies/matrix-react-sdk/res/css/structures/_SpacePanel.pcss @@ -472,3 +472,10 @@ limitations under the License. .mx_SpacePanel_sharePublicSpace { margin: 0; } + +.mx_SpacePanel_Tooltip_KeyboardShortcut { + kbd { + font-family: inherit; + text-transform: capitalize; + } +} diff --git a/linked-dependencies/matrix-react-sdk/res/css/structures/_TabbedView.pcss b/linked-dependencies/matrix-react-sdk/res/css/structures/_TabbedView.pcss index 34a1766c19..04f0587b0a 100644 --- a/linked-dependencies/matrix-react-sdk/res/css/structures/_TabbedView.pcss +++ b/linked-dependencies/matrix-react-sdk/res/css/structures/_TabbedView.pcss @@ -167,7 +167,7 @@ limitations under the License. } /* Hide the labels on tabs, showing only the icons, on narrow viewports. */ -@media (max-width: 768px) { +@media (max-width: 1024px) { .mx_TabbedView_tabsOnLeft.mx_TabbedView_responsive { .mx_TabbedView_tabLabel_text { display: none; diff --git a/linked-dependencies/matrix-react-sdk/res/css/structures/_UserMenu.pcss b/linked-dependencies/matrix-react-sdk/res/css/structures/_UserMenu.pcss index 3d37fa73a6..24e06e9a2c 100644 --- a/linked-dependencies/matrix-react-sdk/res/css/structures/_UserMenu.pcss +++ b/linked-dependencies/matrix-react-sdk/res/css/structures/_UserMenu.pcss @@ -211,6 +211,10 @@ limitations under the License. .mx_UserMenu_iconSignOut::before { mask-image: url("$(res)/img/element-icons/leave.svg"); } + + .mx_UserMenu_iconQr::before { + mask-image: url("@vector-im/compound-design-tokens/icons/qr-code.svg"); + } } .mx_UserMenu_CustomStatusSection { diff --git a/linked-dependencies/matrix-react-sdk/res/css/views/dialogs/_UserSettingsDialog.pcss b/linked-dependencies/matrix-react-sdk/res/css/views/dialogs/_UserSettingsDialog.pcss index 41d39f8b79..1e27bb4b6a 100644 --- a/linked-dependencies/matrix-react-sdk/res/css/views/dialogs/_UserSettingsDialog.pcss +++ b/linked-dependencies/matrix-react-sdk/res/css/views/dialogs/_UserSettingsDialog.pcss @@ -14,6 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_SettingsDialog_toastContainer { + position: absolute; + bottom: var(--cpd-space-10x); + width: 100%; + display: flex; + justify-content: center; +} + /* ICONS */ /* ========================================================== */ diff --git a/linked-dependencies/matrix-react-sdk/res/css/views/messages/_LegacyCallEvent.pcss b/linked-dependencies/matrix-react-sdk/res/css/views/messages/_LegacyCallEvent.pcss index 403086d51d..4b8c6a73c4 100644 --- a/linked-dependencies/matrix-react-sdk/res/css/views/messages/_LegacyCallEvent.pcss +++ b/linked-dependencies/matrix-react-sdk/res/css/views/messages/_LegacyCallEvent.pcss @@ -204,7 +204,7 @@ limitations under the License. } .mx_LegacyCallEvent_info { - align-items: unset; + align-items: center; } } } diff --git a/linked-dependencies/matrix-react-sdk/res/css/views/rooms/_LegacyRoomHeader.pcss b/linked-dependencies/matrix-react-sdk/res/css/views/rooms/_LegacyRoomHeader.pcss index ce088f7deb..a570b0435a 100644 --- a/linked-dependencies/matrix-react-sdk/res/css/views/rooms/_LegacyRoomHeader.pcss +++ b/linked-dependencies/matrix-react-sdk/res/css/views/rooms/_LegacyRoomHeader.pcss @@ -65,6 +65,11 @@ limitations under the License. .mx_BetaCard_betaPill { margin-right: $spacing-8; } + + /* The container of E2EIcon in the legacy header needs to have its height set */ + & > span { + height: 100%; + } } .mx_LegacyRoomHeader_name { diff --git a/linked-dependencies/matrix-react-sdk/res/css/views/settings/_AvatarSetting.pcss b/linked-dependencies/matrix-react-sdk/res/css/views/settings/_AvatarSetting.pcss index 98bf3ab9b8..7f63c3a564 100644 --- a/linked-dependencies/matrix-react-sdk/res/css/views/settings/_AvatarSetting.pcss +++ b/linked-dependencies/matrix-react-sdk/res/css/views/settings/_AvatarSetting.pcss @@ -21,43 +21,10 @@ limitations under the License. margin-top: 8px; position: relative; - .mx_AvatarSetting_hover { - transition: opacity var(--hover-transition); - - /* position to place the hover bg over the entire thing */ - position: absolute; - inset: 0; - - pointer-events: none; /* let the pointer fall through the underlying thing */ - - line-height: 90px; - text-align: center; - - > span { - color: $primary-content; - position: relative; /* tricks the layout engine into putting this on top of the bg */ - font-weight: 500; - } - - .mx_AvatarSetting_hoverBg { - /* absolute position to lazily fill the entire container */ - position: absolute; - inset: 0; - - opacity: 0.5; - background-color: $quinary-content; - border-radius: 90px; - } - } - - &.mx_AvatarSetting_avatar_hovering .mx_AvatarSetting_hover { + &.mx_AvatarSetting_avatarDisplay:hover .mx_AvatarSetting_hover { opacity: 1; } - &:not(.mx_AvatarSetting_avatar_hovering) .mx_AvatarSetting_hover { - opacity: 0; - } - & > * { box-sizing: border-box; } @@ -71,54 +38,40 @@ limitations under the License. } & > img { - cursor: pointer; - object-fit: cover; - } - - & > img, - .mx_AvatarSetting_avatarPlaceholder { display: block; height: 90px; width: inherit; border-radius: 90px; cursor: pointer; - } - - .mx_AvatarSetting_avatarPlaceholder::before { - background-color: $quinary-content; - mask: url("$(res)/img/feather-customised/user.svg"); - mask-repeat: no-repeat; - mask-size: 36px; - mask-position: center; - content: ""; - position: absolute; - inset: 0; + object-fit: cover; } .mx_AvatarSetting_uploadButton { - width: 32px; - height: 32px; + width: 28px; + height: 28px; border-radius: 32px; - background-color: $secondary-content; + border: 1px solid var(--cpd-color-bg-canvas-default); + background-color: var(--cpd-color-bg-canvas-default); position: absolute; bottom: 0; right: 0; + text-align: center; + cursor: pointer; + + svg { + position: relative; + top: 3px; + } } - .mx_AvatarSetting_uploadButton::before { - content: ""; - display: block; - width: 100%; - height: 100%; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 55%; - background-color: $quinary-content; - mask-image: url("$(res)/img/feather-customised/edit.svg"); + .mx_AvatarSetting_uploadButton:hover, + .mx_AvatarSetting_uploadButton_active { + background-color: var(--cpd-color-bg-subtle-primary); } } -.mx_AvatarSetting_avatar .mx_AvatarSetting_avatarPlaceholder { - background-color: $system; +.mx_AvatarSetting_removeMenuItem svg, +.mx_AvatarSetting_removeMenuItem span { + color: var(--cpd-color-text-critical-primary) !important; } diff --git a/linked-dependencies/matrix-react-sdk/res/css/views/settings/_ProfileSettings.pcss b/linked-dependencies/matrix-react-sdk/res/css/views/settings/_RoomProfileSettings.pcss similarity index 73% rename from linked-dependencies/matrix-react-sdk/res/css/views/settings/_ProfileSettings.pcss rename to linked-dependencies/matrix-react-sdk/res/css/views/settings/_RoomProfileSettings.pcss index 5caff1f2c0..8af0249ab4 100644 --- a/linked-dependencies/matrix-react-sdk/res/css/views/settings/_ProfileSettings.pcss +++ b/linked-dependencies/matrix-react-sdk/res/css/views/settings/_RoomProfileSettings.pcss @@ -1,5 +1,5 @@ /* -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020, 2024 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,17 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ProfileSettings { +.mx_RoomProfileSettings { border-bottom: 1px solid $quinary-content; - .mx_ProfileSettings_avatarUpload { - display: none; - } - - .mx_ProfileSettings_profile { + .mx_RoomProfileSettings_profile { display: flex; - .mx_ProfileSettings_profile_controls { + .mx_RoomProfileSettings_profile_controls { flex-grow: 1; margin-inline-end: 54px; @@ -32,7 +28,7 @@ limitations under the License. margin-top: $spacing-8; } - .mx_ProfileSettings_profile_controls_topic { + .mx_RoomProfileSettings_profile_controls_topic { margin-top: $spacing-8; & > textarea { @@ -40,18 +36,18 @@ limitations under the License. resize: vertical; } - &.mx_ProfileSettings_profile_controls_topic--room textarea { + &.mx_RoomProfileSettings_profile_controls_topic--room textarea { min-height: 4em; } } - .mx_ProfileSettings_profile_controls_userId { + .mx_RoomProfileSettings_profile_controls_userId { margin-inline-end: $spacing-20; } } } - .mx_ProfileSettings_buttons { + .mx_RoomProfileSettings_buttons { display: flex; gap: var(--cpd-space-4x); margin-top: 10px; /* 18px is already accounted for by the

above the buttons */ diff --git a/linked-dependencies/matrix-react-sdk/res/css/views/settings/_UserProfileSettings.pcss b/linked-dependencies/matrix-react-sdk/res/css/views/settings/_UserProfileSettings.pcss new file mode 100644 index 0000000000..3a9dc7dcc7 --- /dev/null +++ b/linked-dependencies/matrix-react-sdk/res/css/views/settings/_UserProfileSettings.pcss @@ -0,0 +1,58 @@ +/* +Copyright 2019, 2020, 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_UserProfileSettings { + border-bottom: 1px solid $quinary-content; + + .mx_UserProfileSettings_profile { + display: flex; + margin-top: var(--cpd-space-6x); + gap: 16px; + /* This is temporary until the 'Remove' link is replaced by a context menu. */ + margin-bottom: 20px; + + .mx_UserProfileSettings_profile_displayName { + flex-grow: 1; + width: 100%; + } + } + + .mx_UserProfileSettings_profile_controls { + flex-grow: 1; + } + + .mx_UserProfileSettings_profile_controls_userId { + width: 100%; + .mx_CopyableText { + margin-top: var(--cpd-space-1x); + width: 100%; + box-sizing: border-box; + } + } + + .mx_UserProfileSettings_profile_controls_userId_label { + font-size: 15px; + font-weight: 500; + } +} + +@media (max-width: 768px) { + .mx_UserProfileSettings_profile { + flex-direction: column; + align-items: center; + gap: 30px; + } +} diff --git a/linked-dependencies/matrix-react-sdk/res/img/feather-customised/user.svg b/linked-dependencies/matrix-react-sdk/res/img/feather-customised/user.svg deleted file mode 100644 index 210ef99e6a..0000000000 --- a/linked-dependencies/matrix-react-sdk/res/img/feather-customised/user.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/linked-dependencies/matrix-react-sdk/src/BasePlatform.ts b/linked-dependencies/matrix-react-sdk/src/BasePlatform.ts index e7e4ff7e3c..3c515dff84 100644 --- a/linked-dependencies/matrix-react-sdk/src/BasePlatform.ts +++ b/linked-dependencies/matrix-react-sdk/src/BasePlatform.ts @@ -38,7 +38,7 @@ import { idbLoad, idbSave, idbDelete } from "./utils/StorageAccess"; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import { IConfigOptions } from "./IConfigOptions"; import SdkConfig from "./SdkConfig"; -import { buildAndEncodePickleKey, getPickleAdditionalData } from "./utils/tokens/pickling"; +import { buildAndEncodePickleKey, encryptPickleKey } from "./utils/tokens/pickling"; export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url"; export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url"; @@ -378,24 +378,16 @@ export default abstract class BasePlatform { * support storing pickle keys. */ public async createPickleKey(userId: string, deviceId: string): Promise { - if (!window.crypto || !window.crypto.subtle) { - return null; - } - const crypto = window.crypto; const randomArray = new Uint8Array(32); crypto.getRandomValues(randomArray); - const cryptoKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [ - "encrypt", - "decrypt", - ]); - const iv = new Uint8Array(32); - crypto.getRandomValues(iv); - - const additionalData = getPickleAdditionalData(userId, deviceId); - const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, cryptoKey, randomArray); + const data = await encryptPickleKey(randomArray, userId, deviceId); + if (data === undefined) { + // no crypto support + return null; + } try { - await idbSave("pickleKey", [userId, deviceId], { encrypted, iv, cryptoKey }); + await idbSave("pickleKey", [userId, deviceId], data); } catch (e) { return null; } diff --git a/linked-dependencies/matrix-react-sdk/src/DecryptionFailureTracker.ts b/linked-dependencies/matrix-react-sdk/src/DecryptionFailureTracker.ts index c842e55ec4..548555cd1d 100644 --- a/linked-dependencies/matrix-react-sdk/src/DecryptionFailureTracker.ts +++ b/linked-dependencies/matrix-react-sdk/src/DecryptionFailureTracker.ts @@ -14,38 +14,64 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { ScalableBloomFilter } from "bloom-filters"; +import { CryptoEvent, HttpApiEvent, MatrixClient, MatrixEventEvent, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { Error as ErrorEvent } from "@matrix-org/analytics-events/types/typescript/Error"; import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api"; import { PosthogAnalytics } from "./PosthogAnalytics"; +/** The key that we use to store the `reportedEvents` bloom filter in localstorage */ +const DECRYPTION_FAILURE_STORAGE_KEY = "mx_decryption_failure_event_ids"; + export class DecryptionFailure { - public readonly ts: number; + /** + * The time between our initial failure to decrypt and our successful + * decryption (if we managed to decrypt). + */ + public timeToDecryptMillis?: number; public constructor( public readonly failedEventId: string, public readonly errorCode: DecryptionFailureCode, - ) { - this.ts = Date.now(); - } + /** + * The time that we failed to decrypt the event. If we failed to decrypt + * multiple times, this will be the time of the first failure. + */ + public readonly ts: number, + /** + * Is the sender on a different server from us? + */ + public readonly isFederated: boolean | undefined, + /** + * Was the failed event ever visible to the user? + */ + public wasVisibleToUser: boolean, + /** + * Has the user verified their own cross-signing identity, as of the most + * recent decryption attempt for this event? + */ + public userTrustsOwnIdentity: boolean | undefined, + ) {} } type ErrorCode = ErrorEvent["name"]; -type TrackingFn = (count: number, trackedErrCode: ErrorCode, rawError: string) => void; +/** Properties associated with decryption errors, for classifying the error. */ +export type ErrorProperties = Omit; +type TrackingFn = (trackedErrCode: ErrorCode, rawError: string, properties: ErrorProperties) => void; export type ErrCodeMapFn = (errcode: DecryptionFailureCode) => ErrorCode; export class DecryptionFailureTracker { private static internalInstance = new DecryptionFailureTracker( - (total, errorCode, rawError) => { - for (let i = 0; i < total; i++) { - PosthogAnalytics.instance.trackEvent({ - eventName: "Error", - domain: "E2EE", - name: errorCode, - context: `mxc_crypto_error_type_${rawError}`, - }); - } + (errorCode, rawError, properties) => { + const event: ErrorEvent = { + eventName: "Error", + domain: "E2EE", + name: errorCode, + context: `mxc_crypto_error_type_${rawError}`, + ...properties, + }; + PosthogAnalytics.instance.trackEvent(event); }, (errorCode) => { // Map JS-SDK error codes to tracker codes for aggregation @@ -66,58 +92,82 @@ export class DecryptionFailureTracker { }, ); - // Map of event IDs to DecryptionFailure items. + /** Map of event IDs to `DecryptionFailure` items. + * + * Every `CHECK_INTERVAL_MS`, this map is checked for failures that happened > + * `MAXIMUM_LATE_DECRYPTION_PERIOD` ago (considered undecryptable), or + * decryptions that took > `GRACE_PERIOD_MS` (considered late decryptions). + * + * Any such events are then reported via the `TrackingFn`. + */ public failures: Map = new Map(); - // Set of event IDs that have been visible to the user. - public visibleEvents: Set = new Set(); - - // Map of visible event IDs to `DecryptionFailure`s. Every - // `CHECK_INTERVAL_MS`, this map is checked for failures that - // happened > `GRACE_PERIOD_MS` ago. Those that did are - // accumulated in `failureCounts`. - public visibleFailures: Map = new Map(); - - /** - * A histogram of the number of failures that will be tracked at the next tracking - * interval, split by failure error code. + /** Set of event IDs that have been visible to the user. + * + * This will only contain events that are not already in `reportedEvents`. */ - private failureCounts: Map = new Map(); + public visibleEvents: Set = new Set(); - // Event IDs of failures that were tracked previously - public trackedEvents: Set = new Set(); + /** Bloom filter tracking event IDs of failures that were reported previously */ + private reportedEvents: ScalableBloomFilter = new ScalableBloomFilter(); - // Set to an interval ID when `start` is called + /** Set to an interval ID when `start` is called */ public checkInterval: number | null = null; public trackInterval: number | null = null; - // Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`. - public static TRACK_INTERVAL_MS = 60000; - - // Call `checkFailures` every `CHECK_INTERVAL_MS`. + /** Call `checkFailures` every `CHECK_INTERVAL_MS`. */ public static CHECK_INTERVAL_MS = 40000; - // Give events a chance to be decrypted by waiting `GRACE_PERIOD_MS` before counting - // the failure in `failureCounts`. - public static GRACE_PERIOD_MS = 30000; + /** If the event is successfully decrypted in less than 4s, we don't report. */ + public static GRACE_PERIOD_MS = 4000; + + /** Maximum time for an event to be decrypted to be considered a late + * decryption. If it takes longer, we consider it undecryptable. */ + public static MAXIMUM_LATE_DECRYPTION_PERIOD = 60000; + + /** Properties that will be added to all reported events (mainly reporting + * information about the Matrix client). */ + private baseProperties?: ErrorProperties = {}; + + /** The user's domain (homeserver name). */ + private userDomain?: string; + + /** Whether the user has verified their own cross-signing keys. */ + private userTrustsOwnIdentity: boolean | undefined = undefined; + + /** Whether we are currently checking our own verification status. */ + private checkingVerificationStatus: boolean = false; + + /** Whether we should retry checking our own verification status after we're + * done our current check. i.e. we got notified that our keys changed while + * we were already checking, so the result could be out of date. */ + private retryVerificationStatus: boolean = false; /** * Create a new DecryptionFailureTracker. * - * Call `eventDecrypted(event, err)` on this instance when an event is decrypted. - * - * Call `start()` to start the tracker, and `stop()` to stop tracking. + * Call `start(client)` to start the tracker. The tracker will listen for + * decryption events on the client and track decryption failures, and will + * automatically stop tracking when the client logs out. * * @param {function} fn The tracking function, which will be called when failures - * are tracked. The function should have a signature `(count, trackedErrorCode) => {...}`, - * where `count` is the number of failures and `errorCode` matches the output of `errorCodeMapFn`. + * are tracked. The function should have a signature `(trackedErrorCode, rawError, properties) => {...}`, + * where `errorCode` matches the output of `errorCodeMapFn`, `rawError` is the original + * error (that is, the input to `errorCodeMapFn`), and `properties` is a map of the + * error properties for classifying the error. * * @param {function} errorCodeMapFn The function used to map decryption failure reason codes to the * `trackedErrorCode`. + * + * @param {boolean} checkReportedEvents Check if we have already reported an event. + * Defaults to `true`. This is only used for tests, to avoid possible false positives from + * the Bloom filter. This should be set to `false` for all tests except for those + * that specifically test the `reportedEvents` functionality. */ private constructor( private readonly fn: TrackingFn, private readonly errorCodeMapFn: ErrCodeMapFn, + private readonly checkReportedEvents: boolean = true, ) { if (!fn || typeof fn !== "function") { throw new Error("DecryptionFailureTracker requires tracking function"); @@ -132,130 +182,253 @@ export class DecryptionFailureTracker { return DecryptionFailureTracker.internalInstance; } - // loadTrackedEvents() { - // this.trackedEvents = new Set(JSON.parse(localStorage.getItem('mx-decryption-failure-event-ids')) || []); - // } + private loadReportedEvents(): void { + const storedFailures = localStorage.getItem(DECRYPTION_FAILURE_STORAGE_KEY); + if (storedFailures) { + this.reportedEvents = ScalableBloomFilter.fromJSON(JSON.parse(storedFailures)); + } else { + this.reportedEvents = new ScalableBloomFilter(); + } + } - // saveTrackedEvents() { - // localStorage.setItem('mx-decryption-failure-event-ids', JSON.stringify([...this.trackedEvents])); - // } + private saveReportedEvents(): void { + localStorage.setItem(DECRYPTION_FAILURE_STORAGE_KEY, JSON.stringify(this.reportedEvents.saveAsJSON())); + } - public eventDecrypted(e: MatrixEvent): void { + /** Callback for when an event is decrypted. + * + * This function is called by our `MatrixEventEvent.Decrypted` event + * handler after a decryption attempt on an event, whether the decryption + * is successful or not. + * + * @param e the event that was decrypted + * + * @param nowTs the current timestamp + */ + private eventDecrypted(e: MatrixEvent, nowTs: number): void { // for now we only track megolm decryption failures if (e.getWireContent().algorithm != "m.megolm.v1.aes-sha2") { return; } - const errCode = e.decryptionFailureReason; - if (errCode !== null) { - this.addDecryptionFailure(new DecryptionFailure(e.getId()!, errCode)); - } else { + if (errCode === null) { // Could be an event in the failures, remove it - this.removeDecryptionFailuresForEvent(e); + this.removeDecryptionFailuresForEvent(e, nowTs); + return; } - } - public addVisibleEvent(e: MatrixEvent): void { const eventId = e.getId()!; - if (this.trackedEvents.has(eventId)) { + // if it's already reported, we don't need to do anything + if (this.reportedEvents.has(eventId) && this.checkReportedEvents) { return; } - this.visibleEvents.add(eventId); - if (this.failures.has(eventId) && !this.visibleFailures.has(eventId)) { - this.visibleFailures.set(eventId, this.failures.get(eventId)!); + // if we already have a record of this event, use the previously-recorded timestamp + const failure = this.failures.get(eventId); + const ts = failure ? failure.ts : nowTs; + + const sender = e.getSender(); + const senderDomain = sender?.replace(/^.*?:/, ""); + let isFederated: boolean | undefined; + if (this.userDomain !== undefined && senderDomain !== undefined) { + isFederated = this.userDomain !== senderDomain; } + + const wasVisibleToUser = this.visibleEvents.has(eventId); + this.failures.set( + eventId, + new DecryptionFailure(eventId, errCode, ts, isFederated, wasVisibleToUser, this.userTrustsOwnIdentity), + ); } - public addDecryptionFailure(failure: DecryptionFailure): void { - const eventId = failure.failedEventId; + public addVisibleEvent(e: MatrixEvent): void { + const eventId = e.getId()!; - if (this.trackedEvents.has(eventId)) { + // if it's already reported, we don't need to do anything + if (this.reportedEvents.has(eventId) && this.checkReportedEvents) { return; } - this.failures.set(eventId, failure); - if (this.visibleEvents.has(eventId) && !this.visibleFailures.has(eventId)) { - this.visibleFailures.set(eventId, failure); + // if we've already marked the event as a failure, mark it as visible + // in the failure object + const failure = this.failures.get(eventId); + if (failure) { + failure.wasVisibleToUser = true; } + + this.visibleEvents.add(eventId); } - public removeDecryptionFailuresForEvent(e: MatrixEvent): void { + public removeDecryptionFailuresForEvent(e: MatrixEvent, nowTs: number): void { const eventId = e.getId()!; - this.failures.delete(eventId); - this.visibleFailures.delete(eventId); + const failure = this.failures.get(eventId); + if (failure) { + this.failures.delete(eventId); + + const timeToDecryptMillis = nowTs - failure.ts; + if (timeToDecryptMillis < DecryptionFailureTracker.GRACE_PERIOD_MS) { + // the event decrypted on time, so we don't need to report it + return; + } else if (timeToDecryptMillis <= DecryptionFailureTracker.MAXIMUM_LATE_DECRYPTION_PERIOD) { + // The event is a late decryption, so store the time it took. + // If the time to decrypt is longer than + // MAXIMUM_LATE_DECRYPTION_PERIOD, we consider the event as + // undecryptable, and leave timeToDecryptMillis undefined + failure.timeToDecryptMillis = timeToDecryptMillis; + } + this.reportFailure(failure); + } + } + + private async handleKeysChanged(client: MatrixClient): Promise { + if (this.checkingVerificationStatus) { + // Flag that we'll need to do another check once the current check completes. + this.retryVerificationStatus = true; + return; + } + + this.checkingVerificationStatus = true; + try { + do { + this.retryVerificationStatus = false; + this.userTrustsOwnIdentity = ( + await client.getCrypto()!.getUserVerificationStatus(client.getUserId()!) + ).isCrossSigningVerified(); + } while (this.retryVerificationStatus); + } finally { + this.checkingVerificationStatus = false; + } } /** * Start checking for and tracking failures. */ - public start(): void { + public async start(client: MatrixClient): Promise { + this.loadReportedEvents(); + await this.calculateClientProperties(client); + this.registerHandlers(client); this.checkInterval = window.setInterval( () => this.checkFailures(Date.now()), DecryptionFailureTracker.CHECK_INTERVAL_MS, ); + } - this.trackInterval = window.setInterval(() => this.trackFailures(), DecryptionFailureTracker.TRACK_INTERVAL_MS); + private async calculateClientProperties(client: MatrixClient): Promise { + const baseProperties: ErrorProperties = {}; + this.baseProperties = baseProperties; + + this.userDomain = client.getDomain() ?? undefined; + if (this.userDomain === "matrix.org") { + baseProperties.isMatrixDotOrg = true; + } else if (this.userDomain !== undefined) { + baseProperties.isMatrixDotOrg = false; + } + + const crypto = client.getCrypto(); + if (crypto) { + const version = crypto.getVersion(); + if (version.startsWith("Rust SDK")) { + baseProperties.cryptoSDK = "Rust"; + } else { + baseProperties.cryptoSDK = "Legacy"; + } + this.userTrustsOwnIdentity = ( + await crypto.getUserVerificationStatus(client.getUserId()!) + ).isCrossSigningVerified(); + } + } + + private registerHandlers(client: MatrixClient): void { + // After the client attempts to decrypt an event, we examine it to see + // if it needs to be reported. + const decryptedHandler = (e: MatrixEvent): void => this.eventDecrypted(e, Date.now()); + // When our keys change, we check if the cross-signing keys are now trusted. + const keysChangedHandler = (): void => { + this.handleKeysChanged(client).catch((e) => { + console.log("Error handling KeysChanged event", e); + }); + }; + // When logging out, remove our handlers and destroy state + const loggedOutHandler = (): void => { + client.removeListener(MatrixEventEvent.Decrypted, decryptedHandler); + client.removeListener(CryptoEvent.KeysChanged, keysChangedHandler); + client.removeListener(HttpApiEvent.SessionLoggedOut, loggedOutHandler); + this.stop(); + }; + + client.on(MatrixEventEvent.Decrypted, decryptedHandler); + client.on(CryptoEvent.KeysChanged, keysChangedHandler); + client.on(HttpApiEvent.SessionLoggedOut, loggedOutHandler); } /** * Clear state and stop checking for and tracking failures. */ - public stop(): void { + private stop(): void { if (this.checkInterval) clearInterval(this.checkInterval); if (this.trackInterval) clearInterval(this.trackInterval); + this.userTrustsOwnIdentity = undefined; this.failures = new Map(); this.visibleEvents = new Set(); - this.visibleFailures = new Map(); - this.failureCounts = new Map(); } /** - * Mark failures that occurred before nowTs - GRACE_PERIOD_MS as failures that should be - * tracked. Only mark one failure per event ID. + * Mark failures as undecryptable or late. Only mark one failure per event ID. + * * @param {number} nowTs the timestamp that represents the time now. */ public checkFailures(nowTs: number): void { - const failuresGivenGrace: Set = new Set(); const failuresNotReady: Map = new Map(); - for (const [eventId, failure] of this.visibleFailures) { - if (nowTs > failure.ts + DecryptionFailureTracker.GRACE_PERIOD_MS) { - failuresGivenGrace.add(failure); - this.trackedEvents.add(eventId); + for (const [eventId, failure] of this.failures) { + if ( + failure.timeToDecryptMillis !== undefined || + nowTs > failure.ts + DecryptionFailureTracker.MAXIMUM_LATE_DECRYPTION_PERIOD + ) { + // we report failures under two conditions: + // - if `timeToDecryptMillis` is set, we successfully decrypted + // the event, but we got the key late. We report it so that we + // have the late decrytion stats. + // - we haven't decrypted yet and it's past the time for it to be + // considered a "late" decryption, so we count it as + // undecryptable. + this.reportFailure(failure); } else { + // the event isn't old enough, so we still need to keep track of it failuresNotReady.set(eventId, failure); } } - this.visibleFailures = failuresNotReady; + this.failures = failuresNotReady; - // Commented out for now for expediency, we need to consider unbound nature of storing - // this in localStorage - // this.saveTrackedEvents(); - - this.aggregateFailures(failuresGivenGrace); - } - - private aggregateFailures(failures: Set): void { - for (const failure of failures) { - const errorCode = failure.errorCode; - this.failureCounts.set(errorCode, (this.failureCounts.get(errorCode) ?? 0) + 1); - } + this.saveReportedEvents(); } /** * If there are failures that should be tracked, call the given trackDecryptionFailure - * function with the number of failures that should be tracked. + * function with the failures that should be tracked. */ - public trackFailures(): void { - for (const [errorCode, count] of this.failureCounts.entries()) { - if (count > 0) { - const trackedErrorCode = this.errorCodeMapFn(errorCode); - - this.fn(count, trackedErrorCode, errorCode); - this.failureCounts.set(errorCode, 0); - } + private reportFailure(failure: DecryptionFailure): void { + const errorCode = failure.errorCode; + const trackedErrorCode = this.errorCodeMapFn(errorCode); + const properties: ErrorProperties = { + timeToDecryptMillis: failure.timeToDecryptMillis ?? -1, + wasVisibleToUser: failure.wasVisibleToUser, + }; + if (failure.isFederated !== undefined) { + properties.isFederated = failure.isFederated; + } + if (failure.userTrustsOwnIdentity !== undefined) { + properties.userTrustsOwnIdentity = failure.userTrustsOwnIdentity; } + if (this.baseProperties) { + Object.assign(properties, this.baseProperties); + } + this.fn(trackedErrorCode, errorCode, properties); + + this.reportedEvents.add(failure.failedEventId); + // once we've added it to reportedEvents, we won't check + // visibleEvents for it any more + this.visibleEvents.delete(failure.failedEventId); } } diff --git a/linked-dependencies/matrix-react-sdk/src/DeviceListener.ts b/linked-dependencies/matrix-react-sdk/src/DeviceListener.ts index db3c0bf1f4..bf23412ccd 100644 --- a/linked-dependencies/matrix-react-sdk/src/DeviceListener.ts +++ b/linked-dependencies/matrix-react-sdk/src/DeviceListener.ts @@ -26,7 +26,9 @@ import { import { logger } from "matrix-js-sdk/src/logger"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; +import { CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange"; +import { PosthogAnalytics } from "./PosthogAnalytics"; import dis from "./dispatcher/dispatcher"; import { hideToast as hideBulkUnverifiedSessionsToast, @@ -79,6 +81,10 @@ export default class DeviceListener { private enableBulkUnverifiedSessionsReminder = true; private deviceClientInformationSettingWatcherRef: string | undefined; + // Remember the current analytics state to avoid sending the same event multiple times. + private analyticsVerificationState?: string; + private analyticsRecoveryState?: string; + public static sharedInstance(): DeviceListener { if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener(); return window.mxDeviceListener; @@ -301,6 +307,7 @@ export default class DeviceListener { const crossSigningReady = await crypto.isCrossSigningReady(); const secretStorageReady = await crypto.isSecretStorageReady(); const allSystemsReady = crossSigningReady && secretStorageReady; + await this.reportCryptoSessionStateToAnalytics(cli); if (this.dismissedThisDeviceToast || allSystemsReady) { hideSetupEncryptionToast(); @@ -407,6 +414,70 @@ export default class DeviceListener { this.displayingToastsForDeviceIds = newUnverifiedDeviceIds; } + /** + * Reports current recovery state to analytics. + * Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S). + * @param cli - the matrix client + * @private + */ + private async reportCryptoSessionStateToAnalytics(cli: MatrixClient): Promise { + const crypto = cli.getCrypto()!; + const secretStorageReady = await crypto.isSecretStorageReady(); + const crossSigningStatus = await crypto.getCrossSigningStatus(); + const backupInfo = await this.getKeyBackupInfo(); + const is4SEnabled = (await cli.secretStorage.getDefaultKeyId()) != null; + const deviceVerificationStatus = await crypto.getDeviceVerificationStatus(cli.getUserId()!, cli.getDeviceId()!); + + const verificationState = + deviceVerificationStatus?.signedByOwner && deviceVerificationStatus?.crossSigningVerified + ? "Verified" + : "NotVerified"; + + let recoveryState: "Disabled" | "Enabled" | "Incomplete"; + if (!is4SEnabled) { + recoveryState = "Disabled"; + } else { + const allCrossSigningSecretsCached = + crossSigningStatus.privateKeysCachedLocally.masterKey && + crossSigningStatus.privateKeysCachedLocally.selfSigningKey && + crossSigningStatus.privateKeysCachedLocally.userSigningKey; + if (backupInfo != null) { + // There is a backup. Check that all secrets are stored in 4S and known locally. + // If they are not, recovery is incomplete. + const backupPrivateKeyIsInCache = (await crypto.getSessionBackupPrivateKey()) != null; + if (secretStorageReady && allCrossSigningSecretsCached && backupPrivateKeyIsInCache) { + recoveryState = "Enabled"; + } else { + recoveryState = "Incomplete"; + } + } else { + // No backup. Just consider cross-signing secrets. + if (secretStorageReady && allCrossSigningSecretsCached) { + recoveryState = "Enabled"; + } else { + recoveryState = "Incomplete"; + } + } + } + + if (this.analyticsVerificationState === verificationState && this.analyticsRecoveryState === recoveryState) { + // No changes, no need to send the event nor update the user properties + return; + } + this.analyticsRecoveryState = recoveryState; + this.analyticsVerificationState = verificationState; + + // Update user properties + PosthogAnalytics.instance.setProperty("recoveryState", recoveryState); + PosthogAnalytics.instance.setProperty("verificationState", verificationState); + + PosthogAnalytics.instance.trackEvent({ + eventName: "CryptoSessionState", + verificationState: verificationState, + recoveryState: recoveryState, + }); + } + /** * Check if key backup is enabled, and if not, raise an `Action.ReportKeyBackupNotEnabled` event (which will * trigger an auto-rageshake). diff --git a/linked-dependencies/matrix-react-sdk/src/Lifecycle.ts b/linked-dependencies/matrix-react-sdk/src/Lifecycle.ts index 8b04f74afc..90f320409f 100644 --- a/linked-dependencies/matrix-react-sdk/src/Lifecycle.ts +++ b/linked-dependencies/matrix-react-sdk/src/Lifecycle.ts @@ -18,12 +18,12 @@ limitations under the License. */ import { ReactNode } from "react"; -import { createClient, MatrixClient, SSOAction, OidcTokenRefresher } from "matrix-js-sdk/src/matrix"; +import { createClient, MatrixClient, SSOAction, OidcTokenRefresher, decodeBase64 } from "matrix-js-sdk/src/matrix"; import { IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; import { QueryDict } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; -import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg"; +import { IMatrixClientCreds, MatrixClientPeg, MatrixClientPegAssignOpts } from "./MatrixClientPeg"; import { ModuleRunner } from "./modules/ModuleRunner"; import EventIndexPeg from "./indexing/EventIndexPeg"; import createMatrixClient from "./utils/createMatrixClient"; @@ -422,6 +422,7 @@ async function onSuccessfulDelegatedAuthLogin(credentials: IMatrixClientCreds): } type TryAgainFunction = () => void; + /** * Display a friendly error to the user when token login or OIDC authorization fails * @param description error description @@ -821,7 +822,23 @@ async function doSetLoggedIn(credentials: IMatrixClientCreds, clearStorageEnable checkSessionLock(); dis.fire(Action.OnLoggedIn); - await startMatrixClient(client, /*startSyncing=*/ !softLogout); + + const clientPegOpts: MatrixClientPegAssignOpts = {}; + if (credentials.pickleKey) { + // The pickleKey, if provided, is probably a base64-encoded 256-bit key, so can be used for the crypto store. + if (credentials.pickleKey.length === 43) { + clientPegOpts.rustCryptoStoreKey = decodeBase64(credentials.pickleKey); + } else { + // We have some legacy pickle key. Continue using it as a password. + clientPegOpts.rustCryptoStorePassword = credentials.pickleKey; + } + } + + try { + await startMatrixClient(client, /*startSyncing=*/ !softLogout, clientPegOpts); + } finally { + clientPegOpts.rustCryptoStoreKey?.fill(0); + } return client; } @@ -955,11 +972,16 @@ export function isLoggingOut(): boolean { /** * Starts the matrix client and all other react-sdk services that * listen for events while a session is logged in. + * * @param client the matrix client to start - * @param {boolean} startSyncing True (default) to actually start - * syncing the client. + * @param startSyncing - `true` to actually start syncing the client. + * @param clientPegOpts - Options to pass through to {@link MatrixClientPeg.start}. */ -async function startMatrixClient(client: MatrixClient, startSyncing = true): Promise { +async function startMatrixClient( + client: MatrixClient, + startSyncing: boolean, + clientPegOpts: MatrixClientPegAssignOpts, +): Promise { logger.log(`Lifecycle: Starting MatrixClient`); // dispatch this before starting the matrix client: it's used @@ -990,10 +1012,10 @@ async function startMatrixClient(client: MatrixClient, startSyncing = true): Pro // index (e.g. the FilePanel), therefore initialize the event index // before the client. await EventIndexPeg.init(); - await MatrixClientPeg.start(); + await MatrixClientPeg.start(clientPegOpts); } else { logger.warn("Caller requested only auxiliary services be started"); - await MatrixClientPeg.assign(); + await MatrixClientPeg.assign(clientPegOpts); } checkSessionLock(); diff --git a/linked-dependencies/matrix-react-sdk/src/MatrixClientPeg.ts b/linked-dependencies/matrix-react-sdk/src/MatrixClientPeg.ts index d14003dbfa..72340cb35f 100644 --- a/linked-dependencies/matrix-react-sdk/src/MatrixClientPeg.ts +++ b/linked-dependencies/matrix-react-sdk/src/MatrixClientPeg.ts @@ -66,6 +66,27 @@ export interface IMatrixClientCreds { freshLogin?: boolean; } +export interface MatrixClientPegAssignOpts { + /** + * If we are using Rust crypto, a key with which to encrypt the indexeddb. + * + * If provided, it must be exactly 32 bytes of data. If both this and + * {@link MatrixClientPegAssignOpts.rustCryptoStorePassword} are undefined, + * the store will be unencrypted. + */ + rustCryptoStoreKey?: Uint8Array; + + /** + * If we are using Rust crypto, a password which will be used to derive a key to encrypt the store with. + * + * An alternative to {@link MatrixClientPegAssignOpts.rustCryptoStoreKey}. Ignored if `rustCryptoStoreKey` is set. + * + * Deriving a key from a password is (deliberately) a slow operation, so prefer to pass a `rustCryptoStoreKey` + * directly where possible. + */ + rustCryptoStorePassword?: string; +} + /** * Holds the current instance of the `MatrixClient` to use across the codebase. * Looking for an `MatrixClient`? Just look for the `MatrixClientPeg` on the peg @@ -78,15 +99,6 @@ export interface IMatrixClientPeg { */ opts: IStartClientOpts; - /** - * Return the server name of the user's homeserver - * Throws an error if unable to deduce the homeserver name - * (e.g. if the user is not logged in) - * - * @returns {string} The homeserver name, if present. - */ - getHomeserverName(): string; - /** * Get the current MatrixClient, if any */ @@ -103,14 +115,14 @@ export interface IMatrixClientPeg { unset(): void; /** - * Prepare the MatrixClient for use, including initialising the store and crypto, but do not start it + * Prepare the MatrixClient for use, including initialising the store and crypto, but do not start it. */ - assign(): Promise; + assign(opts?: MatrixClientPegAssignOpts): Promise; /** - * Prepare the MatrixClient for use, including initialising the store and crypto, and start it + * Prepare the MatrixClient for use, including initialising the store and crypto, and start it. */ - start(): Promise; + start(opts?: MatrixClientPegAssignOpts): Promise; /** * If we've registered a user ID we set this to the ID of the @@ -257,7 +269,10 @@ class MatrixClientPegClass implements IMatrixClientPeg { PlatformPeg.get()?.reload(); }; - public async assign(): Promise { + /** + * Implementation of {@link IMatrixClientPeg.assign}. + */ + public async assign(assignOpts: MatrixClientPegAssignOpts = {}): Promise { if (!this.matrixClient) { throw new Error("createClient must be called first"); } @@ -284,7 +299,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { // try to initialise e2e on the new client if (!SettingsStore.getValue("lowBandwidth")) { - await this.initClientCrypto(); + await this.initClientCrypto(assignOpts.rustCryptoStoreKey, assignOpts.rustCryptoStorePassword); } const opts = utils.deepCopy(this.opts); @@ -310,8 +325,16 @@ class MatrixClientPegClass implements IMatrixClientPeg { /** * Attempt to initialize the crypto layer on a newly-created MatrixClient + * + * @param rustCryptoStoreKey - If we are using Rust crypto, a key with which to encrypt the indexeddb. + * If provided, it must be exactly 32 bytes of data. If both this and `rustCryptoStorePassword` are + * undefined, the store will be unencrypted. + * + * @param rustCryptoStorePassword - An alternative to `rustCryptoStoreKey`. Ignored if `rustCryptoStoreKey` is set. + * A password which will be used to derive a key to encrypt the store with. Deriving a key from a password is + * (deliberately) a slow operation, so prefer to pass a `rustCryptoStoreKey` directly where possible. */ - private async initClientCrypto(): Promise { + private async initClientCrypto(rustCryptoStoreKey?: Uint8Array, rustCryptoStorePassword?: string): Promise { if (!this.matrixClient) { throw new Error("createClient must be called first"); } @@ -347,7 +370,13 @@ class MatrixClientPegClass implements IMatrixClientPeg { // Now we can initialise the right crypto impl. if (useRustCrypto) { - await this.matrixClient.initRustCrypto(); + if (!rustCryptoStoreKey && !rustCryptoStorePassword) { + logger.error("Warning! Not using an encryption key for rust crypto store."); + } + await this.matrixClient.initRustCrypto({ + storageKey: rustCryptoStoreKey, + storagePassword: rustCryptoStorePassword, + }); StorageManager.setCryptoInitialised(true); // TODO: device dehydration and whathaveyou @@ -376,22 +405,17 @@ class MatrixClientPegClass implements IMatrixClientPeg { } } - public async start(): Promise { - const opts = await this.assign(); + /** + * Implementation of {@link IMatrixClientPeg.start}. + */ + public async start(assignOpts?: MatrixClientPegAssignOpts): Promise { + const opts = await this.assign(assignOpts); logger.log(`MatrixClientPeg: really starting MatrixClient`); await this.matrixClient!.startClient(opts); logger.log(`MatrixClientPeg: MatrixClient started`); } - public getHomeserverName(): string { - const matches = /^@[^:]+:(.+)$/.exec(this.safeGet().getSafeUserId()); - if (matches === null || matches.length < 1) { - throw new Error("Failed to derive homeserver name from user ID!"); - } - return matches[1]; - } - private namesToRoomName(names: string[], count: number): string | undefined { const countWithoutMe = count - 1; if (!names.length) { diff --git a/linked-dependencies/matrix-react-sdk/src/Modal.tsx b/linked-dependencies/matrix-react-sdk/src/Modal.tsx index 2ac12d280f..f39372d532 100644 --- a/linked-dependencies/matrix-react-sdk/src/Modal.tsx +++ b/linked-dependencies/matrix-react-sdk/src/Modal.tsx @@ -65,10 +65,12 @@ interface IOptions { export enum ModalManagerEvent { Opened = "opened", + Closed = "closed", } type HandlerMap = { [ModalManagerEvent.Opened]: () => void; + [ModalManagerEvent.Closed]: () => void; }; export class ModalManager extends TypedEventEmitter { @@ -232,6 +234,7 @@ export class ModalManager extends TypedEventEmitter { const modal = this.getCurrentModal(); if (!modal) { diff --git a/linked-dependencies/matrix-react-sdk/src/PasswordReset.ts b/linked-dependencies/matrix-react-sdk/src/PasswordReset.ts index ecff316e6c..0564f008ac 100644 --- a/linked-dependencies/matrix-react-sdk/src/PasswordReset.ts +++ b/linked-dependencies/matrix-react-sdk/src/PasswordReset.ts @@ -97,11 +97,7 @@ export default class PasswordReset { // Note: Though this sounds like a login type for identity servers only, it // has a dual purpose of being used for homeservers too. type: "m.login.email.identity", - // TODO: Remove `threepid_creds` once servers support proper UIA - // See https://github.com/matrix-org/synapse/issues/5665 - // See https://github.com/matrix-org/matrix-doc/issues/2220 threepid_creds: creds, - threepidCreds: creds, }, this.password, this.logoutDevices, diff --git a/linked-dependencies/matrix-react-sdk/src/Searching.ts b/linked-dependencies/matrix-react-sdk/src/Searching.ts index 691600d591..ce3ea96c72 100644 --- a/linked-dependencies/matrix-react-sdk/src/Searching.ts +++ b/linked-dependencies/matrix-react-sdk/src/Searching.ts @@ -248,7 +248,7 @@ async function localPagination( // We only need to restore the encryption state for the new results, so // remember how many of them we got. - const newResultCount = localResult.results.length; + const newResultCount = localResult.results?.length ?? 0; const response = { search_categories: { @@ -419,21 +419,21 @@ function combineEvents( // This is a first search call, combine the events from the server and // the local index. Note where our oldest event came from, we shall // fetch the next batch of events from the other source. - if (compareOldestEvents(localEvents.results, serverEvents.results) < 0) { + if (compareOldestEvents(localEvents.results ?? [], serverEvents.results) < 0) { oldestEventFrom = "local"; } - combineEventSources(previousSearchResult, response, localEvents.results, serverEvents.results); - response.highlights = localEvents.highlights.concat(serverEvents.highlights); + combineEventSources(previousSearchResult, response, localEvents.results ?? [], serverEvents.results); + response.highlights = (localEvents.highlights ?? []).concat(serverEvents.highlights ?? []); } else if (localEvents) { // This is a pagination call fetching more events from the local index, // meaning that our oldest event was on the server. // Change the source of the oldest event if our local event is older // than the cached one. - if (compareOldestEvents(localEvents.results, cachedEvents) < 0) { + if (compareOldestEvents(localEvents.results ?? [], cachedEvents) < 0) { oldestEventFrom = "local"; } - combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents); + combineEventSources(previousSearchResult, response, localEvents.results ?? [], cachedEvents); } else if (serverEvents && serverEvents.results) { // This is a pagination call fetching more events from the server, // meaning that our oldest event was in the local index. diff --git a/linked-dependencies/matrix-react-sdk/src/SecurityManager.ts b/linked-dependencies/matrix-react-sdk/src/SecurityManager.ts index 6d43d83f61..c2254d3dfe 100644 --- a/linked-dependencies/matrix-react-sdk/src/SecurityManager.ts +++ b/linked-dependencies/matrix-react-sdk/src/SecurityManager.ts @@ -14,13 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - DeviceVerificationStatus, - ICryptoCallbacks, - MatrixClient, - encodeBase64, - SecretStorage, -} from "matrix-js-sdk/src/matrix"; +import { Crypto, ICryptoCallbacks, MatrixClient, encodeBase64, SecretStorage } from "matrix-js-sdk/src/matrix"; import { deriveKey } from "matrix-js-sdk/src/crypto/key_passphrase"; import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto/recoverykey"; import { logger } from "matrix-js-sdk/src/logger"; @@ -249,7 +243,7 @@ async function onSecretRequested( deviceId: string, requestId: string, name: string, - deviceTrust: DeviceVerificationStatus, + deviceTrust: Crypto.DeviceVerificationStatus, ): Promise { logger.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust); const client = MatrixClientPeg.safeGet(); diff --git a/linked-dependencies/matrix-react-sdk/src/SlidingSyncManager.ts b/linked-dependencies/matrix-react-sdk/src/SlidingSyncManager.ts index c4387e85d6..4885ffa8dc 100644 --- a/linked-dependencies/matrix-react-sdk/src/SlidingSyncManager.ts +++ b/linked-dependencies/matrix-react-sdk/src/SlidingSyncManager.ts @@ -359,10 +359,14 @@ export class SlidingSyncManager { let proxyUrl: string | undefined; try { - const clientWellKnown = await AutoDiscovery.findClientConfig(client.getDomain()!); + const clientDomain = await client.getDomain(); + if (clientDomain === null) { + throw new RangeError("Homeserver domain is null"); + } + const clientWellKnown = await AutoDiscovery.findClientConfig(clientDomain); proxyUrl = clientWellKnown?.["org.matrix.msc3575.proxy"]?.url; } catch (e) { - // client.getDomain() is invalid, `AutoDiscovery.findClientConfig` has thrown + // Either client.getDomain() is null so we've shorted out, or is invalid so `AutoDiscovery.findClientConfig` has thrown } if (proxyUrl != undefined) { diff --git a/linked-dependencies/matrix-react-sdk/src/accessibility/KeyboardShortcuts.ts b/linked-dependencies/matrix-react-sdk/src/accessibility/KeyboardShortcuts.ts index 398dc27008..9a78f07df4 100644 --- a/linked-dependencies/matrix-react-sdk/src/accessibility/KeyboardShortcuts.ts +++ b/linked-dependencies/matrix-react-sdk/src/accessibility/KeyboardShortcuts.ts @@ -203,6 +203,7 @@ export const KEY_ICON: Record = { if (IS_MAC) { KEY_ICON[Key.META] = "⌘"; KEY_ICON[Key.ALT] = "⌥"; + KEY_ICON[Key.SHIFT] = "⇧"; } export const CATEGORIES: Record = { diff --git a/linked-dependencies/matrix-react-sdk/src/accessibility/RovingTabIndex.tsx b/linked-dependencies/matrix-react-sdk/src/accessibility/RovingTabIndex.tsx index 9a2a855242..9da2b4281e 100644 --- a/linked-dependencies/matrix-react-sdk/src/accessibility/RovingTabIndex.tsx +++ b/linked-dependencies/matrix-react-sdk/src/accessibility/RovingTabIndex.tsx @@ -18,7 +18,7 @@ import React, { createContext, useCallback, useContext, - useLayoutEffect, + useEffect, useMemo, useRef, useReducer, @@ -144,7 +144,7 @@ export const reducer: Reducer = (state: IState, action: Action) } if (document.activeElement === document.body) { // if the focus got reverted to the body then the user was likely focused on the unmounted element - state.activeRef?.current?.focus(); + setTimeout(() => state.activeRef?.current?.focus(), 0); } } @@ -362,7 +362,7 @@ export const useRovingTabIndex = ( } // setup (after refs) - useLayoutEffect(() => { + useEffect(() => { context.dispatch({ type: Type.Register, payload: { ref }, @@ -390,4 +390,3 @@ export const useRovingTabIndex = ( // re-export the semantic helper components for simplicity export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper"; export { RovingAccessibleButton } from "./roving/RovingAccessibleButton"; -export { RovingAccessibleTooltipButton } from "./roving/RovingAccessibleTooltipButton"; diff --git a/linked-dependencies/matrix-react-sdk/src/accessibility/context_menu/ContextMenuTooltipButton.tsx b/linked-dependencies/matrix-react-sdk/src/accessibility/context_menu/ContextMenuTooltipButton.tsx index 2ae8a5de9d..3a3048d41f 100644 --- a/linked-dependencies/matrix-react-sdk/src/accessibility/context_menu/ContextMenuTooltipButton.tsx +++ b/linked-dependencies/matrix-react-sdk/src/accessibility/context_menu/ContextMenuTooltipButton.tsx @@ -18,9 +18,9 @@ limitations under the License. import React, { ComponentProps, forwardRef, Ref } from "react"; -import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton"; +import AccessibleButton from "../../components/views/elements/AccessibleButton"; -type Props = ComponentProps> & { +type Props = ComponentProps> & { // whether the context menu is currently open isExpanded: boolean; }; @@ -31,17 +31,17 @@ export const ContextMenuTooltipButton = forwardRef(function , ) { return ( - {children} - + ); }); diff --git a/linked-dependencies/matrix-react-sdk/src/accessibility/roving/RovingAccessibleTooltipButton.tsx b/linked-dependencies/matrix-react-sdk/src/accessibility/roving/RovingAccessibleTooltipButton.tsx deleted file mode 100644 index 76927c1773..0000000000 --- a/linked-dependencies/matrix-react-sdk/src/accessibility/roving/RovingAccessibleTooltipButton.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { ComponentProps } from "react"; - -import { useRovingTabIndex } from "../RovingTabIndex"; -import { Ref } from "./types"; -import AccessibleButton from "../../components/views/elements/AccessibleButton"; - -type Props = Omit>, "tabIndex"> & { - inputRef?: Ref; -}; - -// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. -export const RovingAccessibleTooltipButton = ({ - inputRef, - onFocus, - element, - ...props -}: Props): JSX.Element => { - const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); - return ( - { - onFocusInternal(); - onFocus?.(event); - }} - ref={ref} - tabIndex={isActive ? 0 : -1} - /> - ); -}; diff --git a/linked-dependencies/matrix-react-sdk/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx b/linked-dependencies/matrix-react-sdk/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index 7d9dd7d55f..a231a90604 100644 --- a/linked-dependencies/matrix-react-sdk/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx +++ b/linked-dependencies/matrix-react-sdk/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -341,9 +341,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { const tabPayload = payload as OpenToTabPayload; Modal.createDialog( UserSettingsDialog, - { initialTabId: tabPayload.initialTabId as UserTab, sdkContext: this.stores }, + { ...payload.props, initialTabId: tabPayload.initialTabId as UserTab, sdkContext: this.stores }, /*className=*/ undefined, /*isPriority=*/ false, /*isStatic=*/ true, @@ -1587,17 +1586,9 @@ export default class MatrixChat extends React.PureComponent { ); }); - const dft = DecryptionFailureTracker.instance; - - // Shelved for later date when we have time to think about persisting history of - // tracked events across sessions. - // dft.loadTrackedEventHashMap(); - - dft.start(); - - // When logging out, stop tracking failures and destroy state - cli.on(HttpApiEvent.SessionLoggedOut, () => dft.stop()); - cli.on(MatrixEventEvent.Decrypted, (e) => dft.eventDecrypted(e)); + DecryptionFailureTracker.instance + .start(cli) + .catch((e) => logger.error("Unable to start DecryptionFailureTracker", e)); cli.on(ClientEvent.Room, (room) => { if (cli.isCryptoEnabled()) { diff --git a/linked-dependencies/matrix-react-sdk/src/components/structures/RoomSearchView.tsx b/linked-dependencies/matrix-react-sdk/src/components/structures/RoomSearchView.tsx index 0a90ea39d9..2fd883f04e 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/structures/RoomSearchView.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/structures/RoomSearchView.tsx @@ -161,7 +161,7 @@ export const RoomSearchView = forwardRef( }, []); // eslint-disable-line react-hooks/exhaustive-deps // show searching spinner - if (results?.count === undefined) { + if (results === null) { return (

= ({ space }) => { showSpaceSettings(space); }} title={_t("common|settings")} + placement="bottom" /> ); } diff --git a/linked-dependencies/matrix-react-sdk/src/components/structures/UserMenu.tsx b/linked-dependencies/matrix-react-sdk/src/components/structures/UserMenu.tsx index 544e8d215e..ef33f82b0d 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/structures/UserMenu.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/structures/UserMenu.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React, { createRef, ReactNode } from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { discoverAndValidateOIDCIssuerWellKnown, Room } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -30,7 +30,7 @@ import Modal from "../../Modal"; import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme"; -import { RovingAccessibleTooltipButton } from "../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton } from "../../accessibility/RovingTabIndex"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import { getHomePageUrl } from "../../utils/pages"; @@ -52,6 +52,8 @@ import { Icon as LiveIcon } from "../../../res/img/compound/live-8px.svg"; import { VoiceBroadcastRecording, VoiceBroadcastRecordingsStoreEvent } from "../../voice-broadcast"; import { SDKContext } from "../../contexts/SDKContext"; import { shouldShowFeedback } from "../../utils/Feedback"; +import { shouldShowQr } from "../views/settings/devices/LoginWithQRSection"; +import { Features } from "../../settings/Settings"; interface IProps { isPanelCollapsed: boolean; @@ -66,6 +68,8 @@ interface IState { isHighContrast: boolean; selectedSpace?: Room | null; showLiveAvatarAddon: boolean; + showQrLogin: boolean; + supportsQrLogin: boolean; } const toRightOf = (rect: PartialDOMRect): MenuProps => { @@ -103,6 +107,8 @@ export default class UserMenu extends React.Component { isHighContrast: this.isUserOnHighContrastTheme(), selectedSpace: SpaceStore.instance.activeSpaceRoom, showLiveAvatarAddon: this.context.voiceBroadcastRecordingsStore.hasCurrent(), + showQrLogin: false, + supportsQrLogin: false, }; OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); @@ -126,6 +132,7 @@ export default class UserMenu extends React.Component { ); this.dispatcherRef = defaultDispatcher.register(this.onAction); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); + this.checkQrLoginSupport(); } public componentWillUnmount(): void { @@ -140,6 +147,29 @@ export default class UserMenu extends React.Component { ); } + private checkQrLoginSupport = async (): Promise => { + if (!this.context.client || !SettingsStore.getValue(Features.OidcNativeFlow)) return; + + const { issuer } = await this.context.client.getAuthIssuer().catch(() => ({ issuer: undefined })); + if (issuer) { + const [oidcClientConfig, versions, wellKnown, isCrossSigningReady] = await Promise.all([ + discoverAndValidateOIDCIssuerWellKnown(issuer), + this.context.client.getVersions(), + this.context.client.waitForClientWellKnown(), + this.context.client.getCrypto()?.isCrossSigningReady(), + ]); + + const supportsQrLogin = shouldShowQr( + this.context.client, + !!isCrossSigningReady, + oidcClientConfig, + versions, + wellKnown, + ); + this.setState({ supportsQrLogin, showQrLogin: true }); + } + }; + private isUserOnDarkTheme(): boolean { if (SettingsStore.getValue("use_system_theme")) { return window.matchMedia("(prefers-color-scheme: dark)").matches; @@ -237,11 +267,11 @@ export default class UserMenu extends React.Component { SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab }; - private onSettingsOpen = (ev: ButtonEvent, tabId?: string): void => { + private onSettingsOpen = (ev: ButtonEvent, tabId?: string, props?: Record): void => { ev.preventDefault(); ev.stopPropagation(); - const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId }; + const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId, props }; defaultDispatcher.dispatch(payload); this.setState({ contextMenuPosition: null }); // also close the menu }; @@ -368,9 +398,33 @@ export default class UserMenu extends React.Component { ); } + let linkNewDeviceButton: JSX.Element | undefined; + if (this.state.showQrLogin) { + const extraProps: Omit< + React.ComponentProps, + "iconClassname" | "label" | "onClick" + > = {}; + if (!this.state.supportsQrLogin) { + extraProps.disabled = true; + extraProps.title = _t("user_menu|link_new_device_not_supported"); + extraProps.caption = _t("user_menu|link_new_device_not_supported_caption"); + extraProps.placement = "right"; + } + + linkNewDeviceButton = ( + this.onSettingsOpen(e, UserTab.SessionManager, { showMsc4108QrCode: true })} + /> + ); + } + let primaryOptionList = ( {homeButton} + {linkNewDeviceButton} {
- { alt="" width={16} /> - + {topSection} {primaryOptionList} diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/auth/InteractiveAuthEntryComponents.tsx index e8969f12ad..7bed60d603 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -26,10 +26,8 @@ import SettingsStore from "../../../settings/SettingsStore"; import { LocalisedPolicy, Policies } from "../../../Terms"; import { AuthHeaderModifier } from "../../structures/auth/header/AuthHeaderModifier"; import AccessibleButton, { AccessibleButtonKind, ButtonEvent } from "../elements/AccessibleButton"; -import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import Field from "../elements/Field"; import Spinner from "../elements/Spinner"; -import { Alignment } from "../elements/Tooltip"; import CaptchaForm from "./CaptchaForm"; /* This file contains a collection of components which are used by the @@ -121,9 +119,6 @@ export class PasswordAuthEntry extends React.Component ( - this.setState({ requested: false }) + ? (open) => { + if (!open) this.setState({ requested: false }); + } : undefined } onClick={async (): Promise => { @@ -527,7 +523,7 @@ export class EmailIdentityAuthEntry extends React.Component< }} > {text} - + ), }, )} @@ -634,11 +630,7 @@ export class MsisdnAuthEntry extends React.Component { + private finished = false; + public constructor(props: IProps) { super(props); @@ -66,6 +94,10 @@ export default class LoginWithQR extends React.Component { }; } + private get ourIntent(): RendezvousIntent { + return RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE; + } + public componentDidMount(): void { this.updateMode(this.props.mode).then(() => {}); } @@ -81,27 +113,36 @@ export default class LoginWithQR extends React.Component { if (this.state.rendezvous) { const rendezvous = this.state.rendezvous; rendezvous.onFailure = undefined; - await rendezvous.cancel(RendezvousFailureReason.UserCancelled); + if (rendezvous instanceof MSC3906Rendezvous) { + await rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled); + } this.setState({ rendezvous: undefined }); } if (mode === Mode.Show) { - await this.generateCode(); + await this.generateAndShowCode(); } } public componentWillUnmount(): void { - if (this.state.rendezvous) { + if (this.state.rendezvous && !this.finished) { // eslint-disable-next-line react/no-direct-mutation-state this.state.rendezvous.onFailure = undefined; // calling cancel will call close() as well to clean up the resources - this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled).then(() => {}); + if (this.state.rendezvous instanceof MSC3906Rendezvous) { + this.state.rendezvous.cancel(LegacyRendezvousFailureReason.UserCancelled); + } else { + this.state.rendezvous.cancel(MSC4108FailureReason.UserCancelled); + } } } - private approveLogin = async (): Promise => { - if (!this.state.rendezvous) { + private async legacyApproveLogin(): Promise { + if (!(this.state.rendezvous instanceof MSC3906Rendezvous)) { throw new Error("Rendezvous not found"); } + if (!this.props.client) { + throw new Error("No client to approve login with"); + } this.setState({ phase: Phase.Loading }); try { @@ -121,7 +162,7 @@ export default class LoginWithQR extends React.Component { } if (!this.props.client.getCrypto()) { // no E2EE to set up - this.props.onFinished(true); + this.onFinished(true); return; } this.setState({ phase: Phase.Verifying }); @@ -132,7 +173,7 @@ export default class LoginWithQR extends React.Component { } finally { this.setState({ rendezvous: undefined }); } - this.props.onFinished(true); + this.onFinished(true); } catch (e) { logger.error("Error whilst approving sign in", e); if (e instanceof HTTPError && e.httpStatus === 429) { @@ -140,27 +181,38 @@ export default class LoginWithQR extends React.Component { this.setState({ phase: Phase.Error, failureReason: LoginWithQRFailureReason.RateLimited }); return; } - this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown }); } - }; + } - private generateCode = async (): Promise => { - let rendezvous: MSC3906Rendezvous; - try { - const fallbackRzServer = this.props.client.getClientWellKnown()?.["io.element.rendezvous"]?.server; - const transport = new MSC3886SimpleHttpRendezvousTransport({ - onFailure: this.onFailure, - client: this.props.client, - fallbackRzServer, - }); + private onFinished(success: boolean): void { + this.finished = true; + this.props.onFinished(success); + } - const channel = new MSC3903ECDHv2RendezvousChannel( - transport, - undefined, - this.onFailure, - ); + private generateAndShowCode = async (): Promise => { + let rendezvous: MSC4108SignInWithQR | MSC3906Rendezvous; + try { + const fallbackRzServer = this.props.client?.getClientWellKnown()?.["io.element.rendezvous"]?.server; - rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure); + if (this.props.legacy) { + const transport = new MSC3886SimpleHttpRendezvousTransport({ + onFailure: this.onFailure, + client: this.props.client, + fallbackRzServer, + }); + const channel = new MSC3903ECDHv2RendezvousChannel(transport, undefined, this.onFailure); + rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure); + } else { + const transport = new MSC4108RendezvousSession({ + onFailure: this.onFailure, + client: this.props.client, + fallbackRzServer, + }); + await transport.send(""); + const channel = new MSC4108SecureChannel(transport, undefined, this.onFailure); + rendezvous = new MSC4108SignInWithQR(channel, false, this.props.client, this.onFailure); + } await rendezvous.generateCode(); this.setState({ @@ -170,23 +222,84 @@ export default class LoginWithQR extends React.Component { }); } catch (e) { logger.error("Error whilst generating QR code", e); - this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.HomeserverLacksSupport }); + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport }); return; } try { - const confirmationDigits = await rendezvous.startAfterShowingCode(); - this.setState({ phase: Phase.Connected, confirmationDigits }); - } catch (e) { - logger.error("Error whilst doing QR login", e); - // only set to error phase if it hasn't already been set by onFailure or similar - if (this.state.phase !== Phase.Error) { - this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + if (rendezvous instanceof MSC3906Rendezvous) { + const confirmationDigits = await rendezvous.startAfterShowingCode(); + this.setState({ phase: Phase.LegacyConnected, confirmationDigits }); + } else if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) { + // MSC4108-Flow: NewScanned + await rendezvous.negotiateProtocols(); + const { verificationUri } = await rendezvous.deviceAuthorizationGrant(); + this.setState({ + phase: Phase.OutOfBandConfirmation, + verificationUri, + }); } + + // we ask the user to confirm that the channel is secure + } catch (e: RendezvousError | unknown) { + logger.error("Error whilst approving login", e); + if (rendezvous instanceof MSC3906Rendezvous) { + // only set to error phase if it hasn't already been set by onFailure or similar + if (this.state.phase !== Phase.Error) { + this.setState({ phase: Phase.Error, failureReason: LegacyRendezvousFailureReason.Unknown }); + } + } else { + await rendezvous?.cancel( + e instanceof RendezvousError + ? (e.code as MSC4108FailureReason) + : ClientRendezvousFailureReason.Unknown, + ); + } + } + }; + + private approveLogin = async (checkCode: string | undefined): Promise => { + if (!(this.state.rendezvous instanceof MSC4108SignInWithQR)) { + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown }); + throw new Error("Rendezvous not found"); + } + + if (!this.state.lastScannedCode && this.state.rendezvous?.checkCode !== checkCode) { + this.setState({ failureReason: LoginWithQRFailureReason.CheckCodeMismatch }); + return; + } + + try { + if (this.ourIntent === RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE) { + // MSC4108-Flow: NewScanned + this.setState({ phase: Phase.Loading }); + + if (this.state.verificationUri) { + window.open(this.state.verificationUri, "_blank"); + } + + this.setState({ phase: Phase.WaitingForDevice }); + + // send secrets + await this.state.rendezvous.shareSecrets(); + + // done + this.onFinished(true); + } else { + this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.Unknown }); + throw new Error("New device flows around OIDC are not yet implemented"); + } + } catch (e: RendezvousError | unknown) { + logger.error("Error whilst approving sign in", e); + this.setState({ + phase: Phase.Error, + failureReason: e instanceof RendezvousError ? e.code : ClientRendezvousFailureReason.Unknown, + }); } }; private onFailure = (reason: RendezvousFailureReason): void => { + if (this.state.phase === Phase.Error) return; // Already in failed state logger.info(`Rendezvous failed: ${reason}`); this.setState({ phase: Phase.Error, failureReason: reason }); }; @@ -195,44 +308,72 @@ export default class LoginWithQR extends React.Component { this.setState({ rendezvous: undefined, confirmationDigits: undefined, + verificationUri: undefined, failureReason: undefined, + userCode: undefined, + checkCode: undefined, + homeserverBaseUrl: undefined, + lastScannedCode: undefined, + mediaPermissionError: false, }); } - private onClick = async (type: Click): Promise => { + private onClick = async (type: Click, checkCode?: string): Promise => { switch (type) { case Click.Cancel: - await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + if (this.state.rendezvous instanceof MSC3906Rendezvous) { + await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled); + } else { + await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled); + } this.reset(); - this.props.onFinished(false); + this.onFinished(false); break; case Click.Approve: - await this.approveLogin(); + await (this.props.legacy ? this.legacyApproveLogin() : this.approveLogin(checkCode)); break; case Click.Decline: await this.state.rendezvous?.declineLoginOnExistingDevice(); this.reset(); - this.props.onFinished(false); - break; - case Click.TryAgain: - this.reset(); - await this.updateMode(this.props.mode); + this.onFinished(false); break; case Click.Back: - await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); - this.props.onFinished(false); + if (this.state.rendezvous instanceof MSC3906Rendezvous) { + await this.state.rendezvous?.cancel(LegacyRendezvousFailureReason.UserCancelled); + } else { + await this.state.rendezvous?.cancel(MSC4108FailureReason.UserCancelled); + } + this.onFinished(false); + break; + case Click.ShowQr: + await this.updateMode(Mode.Show); break; } }; public render(): React.ReactNode { + if (this.state.rendezvous instanceof MSC3906Rendezvous) { + return ( + + ); + } + return ( ); } diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/auth/LoginWithQRFlow.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/auth/LoginWithQRFlow.tsx index 6a6b78a29b..036dc1b451 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/auth/LoginWithQRFlow.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/auth/LoginWithQRFlow.tsx @@ -14,12 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode } from "react"; -import { RendezvousFailureReason as LegacyRendezvousFailureReason } from "matrix-js-sdk/src/rendezvous"; +import React, { createRef, ReactNode } from "react"; +import { + ClientRendezvousFailureReason, + LegacyRendezvousFailureReason, + MSC4108FailureReason, +} from "matrix-js-sdk/src/rendezvous"; import { Icon as ChevronLeftIcon } from "@vector-im/compound-design-tokens/icons/chevron-left.svg"; import { Icon as CheckCircleSolidIcon } from "@vector-im/compound-design-tokens/icons/check-circle-solid.svg"; import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error.svg"; -import { Heading, Text } from "@vector-im/compound-web"; +import { Heading, MFAInput, Text } from "@vector-im/compound-web"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; @@ -30,13 +34,24 @@ import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg"; import { Click, Phase } from "./LoginWithQR-types"; import SdkConfig from "../../../SdkConfig"; import { FailureReason, LoginWithQRFailureReason } from "./LoginWithQR"; +import { XOR } from "../../../@types/common"; +import { ErrorMessage } from "../../structures/ErrorMessage"; + +/** + * @deprecated the MSC3906 implementation is deprecated in favour of MSC4108. + */ +interface MSC3906Props extends Pick { + code?: string; + confirmationDigits?: string; +} interface Props { phase: Phase; - code?: string; - onClick(type: Click): Promise; + code?: Uint8Array; + onClick(type: Click, checkCodeEntered?: string): Promise; failureReason?: FailureReason; - confirmationDigits?: string; + userCode?: string; + checkCode?: string; } // n.b MSC3886/MSC3903/MSC3906 that this is based on are now closed. @@ -46,17 +61,19 @@ interface Props { /** * A component that implements the UI for sign in and E2EE set up with a QR code. * - * This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906 + * This supports the unstable features of MSC3906 and MSC4108 */ -export default class LoginWithQRFlow extends React.Component { - public constructor(props: Props) { +export default class LoginWithQRFlow extends React.Component> { + private checkCodeInput = createRef(); + + public constructor(props: XOR) { super(props); } private handleClick = (type: Click): ((e: React.FormEvent) => Promise) => { return async (e: React.FormEvent): Promise => { e.preventDefault(); - await this.props.onClick(type); + await this.props.onClick(type, type === Click.Approve ? this.checkCodeInput.current?.value : undefined); }; }; @@ -90,24 +107,26 @@ export default class LoginWithQRFlow extends React.Component { let message: ReactNode | undefined; switch (this.props.failureReason) { - case LegacyRendezvousFailureReason.UnsupportedAlgorithm: - case LegacyRendezvousFailureReason.UnsupportedTransport: - case LegacyRendezvousFailureReason.HomeserverLacksSupport: + case MSC4108FailureReason.UnsupportedProtocol: + case LegacyRendezvousFailureReason.UnsupportedProtocol: title = _t("auth|qr_code_login|error_unsupported_protocol_title"); message = _t("auth|qr_code_login|error_unsupported_protocol"); break; + case MSC4108FailureReason.UserCancelled: case LegacyRendezvousFailureReason.UserCancelled: title = _t("auth|qr_code_login|error_user_cancelled_title"); message = _t("auth|qr_code_login|error_user_cancelled"); break; + case MSC4108FailureReason.AuthorizationExpired: + case ClientRendezvousFailureReason.Expired: case LegacyRendezvousFailureReason.Expired: title = _t("auth|qr_code_login|error_expired_title"); message = _t("auth|qr_code_login|error_expired"); break; - case LegacyRendezvousFailureReason.InvalidCode: + case ClientRendezvousFailureReason.InsecureChannelDetected: title = _t("auth|qr_code_login|error_insecure_channel_detected_title"); message = ( <> @@ -125,13 +144,13 @@ export default class LoginWithQRFlow extends React.Component { ); break; - case LegacyRendezvousFailureReason.OtherDeviceAlreadySignedIn: + case ClientRendezvousFailureReason.OtherDeviceAlreadySignedIn: success = true; title = _t("auth|qr_code_login|error_other_device_already_signed_in_title"); message = _t("auth|qr_code_login|error_other_device_already_signed_in"); break; - case LegacyRendezvousFailureReason.UserDeclined: + case ClientRendezvousFailureReason.UserDeclined: title = _t("auth|qr_code_login|error_user_declined_title"); message = _t("auth|qr_code_login|error_user_declined"); break; @@ -141,8 +160,16 @@ export default class LoginWithQRFlow extends React.Component { message = _t("auth|qr_code_login|error_rate_limited"); break; - case LegacyRendezvousFailureReason.OtherDeviceNotSignedIn: - case LegacyRendezvousFailureReason.Unknown: + case ClientRendezvousFailureReason.ETagMissing: + title = _t("error|something_went_wrong"); + message = _t("auth|qr_code_login|error_etag_missing"); + break; + + case MSC4108FailureReason.DeviceAlreadyExists: + case MSC4108FailureReason.DeviceNotFound: + case MSC4108FailureReason.UnexpectedMessageReceived: + case ClientRendezvousFailureReason.OtherDeviceNotSignedIn: + case ClientRendezvousFailureReason.Unknown: default: title = _t("error|something_went_wrong"); message = _t("auth|qr_code_login|error_unexpected"); @@ -150,18 +177,6 @@ export default class LoginWithQRFlow extends React.Component { } className = "mx_LoginWithQR_error"; backButton = false; - buttons = ( - <> - - {_t("action|try_again")} - - {this.cancelButton()} - - ); main = ( <>
{ ); break; } - case Phase.Connected: + case Phase.LegacyConnected: backButton = false; main = ( <> @@ -213,9 +228,62 @@ export default class LoginWithQRFlow extends React.Component { ); break; + case Phase.OutOfBandConfirmation: + backButton = false; + main = ( + <> + + {_t("auth|qr_code_login|check_code_heading")} + + {_t("auth|qr_code_login|check_code_explainer")} + + + + + ); + + buttons = ( + <> + + {_t("action|continue")} + + + {_t("action|cancel")} + + + ); + break; case Phase.ShowingQR: if (this.props.code) { - const data = Buffer.from(this.props.code ?? ""); + const data = + typeof this.props.code !== "string" ? this.props.code : Buffer.from(this.props.code ?? ""); main = ( <> @@ -249,12 +317,19 @@ export default class LoginWithQRFlow extends React.Component { case Phase.Loading: main = this.simpleSpinner(); break; - case Phase.Connecting: - main = this.simpleSpinner(_t("auth|qr_code_login|connecting")); - buttons = this.cancelButton(); - break; case Phase.WaitingForDevice: - main = this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device")); + main = ( + <> + {this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"))} + {this.props.userCode ? ( +
+

{_t("auth|qr_code_login|security_code")}

+

{_t("auth|qr_code_login|security_code_prompt")}

+

{this.props.userCode}

+
+ ) : null} + + ); buttons = this.cancelButton(); break; case Phase.Verifying: diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/avatars/RoomAvatar.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/avatars/RoomAvatar.tsx index 22dbb4dcf6..8380fb477a 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/avatars/RoomAvatar.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/avatars/RoomAvatar.tsx @@ -44,6 +44,19 @@ interface IState { urls: string[]; } +export function idNameForRoom(room: Room): string { + const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); + // If the room is a DM, we use the other user's ID for the color hash + // in order to match the room avatar with their avatar + if (dmMapUserId) return dmMapUserId; + + if (room instanceof LocalRoom && room.targets.length === 1) { + return room.targets[0].userId; + } + + return room.roomId; +} + export default class RoomAvatar extends React.Component { public static defaultProps = { size: "36px", @@ -117,17 +130,10 @@ export default class RoomAvatar extends React.Component { const room = this.props.room; if (room) { - const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); - // If the room is a DM, we use the other user's ID for the color hash - // in order to match the room avatar with their avatar - if (dmMapUserId) return dmMapUserId; - - if (room instanceof LocalRoom && room.targets.length === 1) { - return room.targets[0].userId; - } + return idNameForRoom(room); + } else { + return this.props.oobData?.roomId; } - - return this.props.room?.roomId || this.props.oobData?.roomId; } public render(): React.ReactNode { diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/beta/BetaCard.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/beta/BetaCard.tsx index 84c7a27fe0..7d17c3af94 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/beta/BetaCard.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/beta/BetaCard.tsx @@ -27,7 +27,6 @@ import SdkConfig from "../../../SdkConfig"; import SettingsFlag from "../elements/SettingsFlag"; import { useFeatureEnabled } from "../../../hooks/useSettings"; import InlineSpinner from "../elements/InlineSpinner"; -import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { shouldShowFeedback } from "../../../utils/Feedback"; // XXX: Keep this around for re-use in future Betas @@ -50,19 +49,15 @@ export const BetaPill: React.FC = ({ }) => { if (onClick) { return ( - -
{tooltipTitle}
-
{tooltipCaption}
-
- } + aria-label={`${tooltipTitle} ${tooltipCaption}`} + title={tooltipTitle} + caption={tooltipCaption} onClick={onClick} > {_t("common|beta")} - + ); } diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/dialogs/CreateRoomDialog.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/dialogs/CreateRoomDialog.tsx index a1fdc13f29..fa0eef5086 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/dialogs/CreateRoomDialog.tsx @@ -436,7 +436,7 @@ export default class CreateRoomDialog extends React.Component { { timeline = [
{_t("server_offline|empty_timeline")}
]; } - const serverName = MatrixClientPeg.getHomeserverName(); + const serverName = MatrixClientPeg.safeGet().getDomain(); return ( (UIFeature.Voip); const mjolnirEnabled = useSettingValue("feature_mjolnir"); + // store this prop in state as changing tabs back and forth should clear it + const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode); const getTabs = (): NonEmptyArray> => { const tabs: Tab[] = []; @@ -98,7 +103,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { UserTab.SessionManager, _td("settings|sessions|title"), "mx_UserSettingsDialog_sessionsIcon", - , + , undefined, ), ); @@ -205,29 +210,41 @@ export default function UserSettingsDialog(props: IProps): JSX.Element { return tabs as NonEmptyArray>; }; - const [activeTabId, setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.General, props.initialTabId); + const [activeTabId, _setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.General, props.initialTabId); + const setActiveTabId = (tabId: UserTab): void => { + _setActiveTabId(tabId); + // Clear this so switching away from the tab and back to it will not show the QR code again + setShowMsc4108QrCode(false); + }; + + const [activeToast, toastRack] = useActiveToast(); return ( // XXX: SDKContext is provided within the LoggedInView subtree. // Modals function outside the MatrixChat React tree, so sdkContext is reprovided here to simulate that. // The longer term solution is to move our ModalManager into the React tree to inherit contexts properly. - -
- -
-
+ + +
+ +
+
+ {activeToast && {activeToast}} +
+
+
); } diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/dialogs/devtools/ServerInfo.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/dialogs/devtools/ServerInfo.tsx index e055fe1b5a..5b24ea4326 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/dialogs/devtools/ServerInfo.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/dialogs/devtools/ServerInfo.tsx @@ -38,7 +38,7 @@ export async function getServerVersionFromFederationApi(client: MatrixClient): P let baseUrl = client.getHomeserverUrl(); try { - const hsName = MatrixClientPeg.getHomeserverName(); + const hsName = MatrixClientPeg.safeGet().getDomain(); // We don't use the js-sdk Autodiscovery module here as it only support client well-known, not server ones. const response = await fetch(`https://${hsName}/.well-known/matrix/server`); const json = await response.json(); diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx index 3873fd2909..1a0fab6202 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/dialogs/security/CreateCrossSigningDialog.tsx @@ -101,9 +101,6 @@ export default class CreateCrossSigningDialog extends React.PureComponent(SdkConfig.getObject("room_directory")?.get("servers") ?? []); removeAll(configServers, homeServer); // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one. diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/elements/AccessibleButton.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/elements/AccessibleButton.tsx index d94162393e..76b90506dc 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/elements/AccessibleButton.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/elements/AccessibleButton.tsx @@ -106,6 +106,11 @@ type Props = DynamicHtmlElementProps & * Callback for when the tooltip is opened or closed. */ onTooltipOpenChange?: TooltipProps["onOpenChange"]; + + /** + * Whether the tooltip should be disabled. + */ + disableTooltip?: TooltipProps["disabled"]; }; /** @@ -140,6 +145,7 @@ const AccessibleButton = forwardRef(function , ref: Ref, @@ -217,6 +223,7 @@ const AccessibleButton = forwardRef(function {button} diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton.tsx deleted file mode 100644 index 759643da1c..0000000000 --- a/linked-dependencies/matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { SyntheticEvent, FocusEvent, forwardRef, useEffect, Ref, useState, ComponentProps } from "react"; - -import AccessibleButton from "./AccessibleButton"; -import Tooltip, { Alignment } from "./Tooltip"; - -/** - * Type of props accepted by {@link AccessibleTooltipButton}. - * - * Extends that of {@link AccessibleButton}. - */ -type Props = ComponentProps> & { - /** - * Title to show in the tooltip and use as aria-label - */ - title?: string; - /** - * Tooltip node to show in the tooltip, takes precedence over `title` - */ - tooltip?: React.ReactNode; - /** - * Trigger label to render - */ - label?: string; - /** - * Classname to apply to the tooltip - */ - tooltipClassName?: string; - /** - * Force the tooltip to be hidden - */ - forceHide?: boolean; - /** - * Alignment to render the tooltip with - */ - alignment?: Alignment; - /** - * Function to call when the children are hovered over - */ - onHover?: (hovering: boolean) => void; - /** - * Function to call when the tooltip goes from shown to hidden. - */ - onHideTooltip?(ev: SyntheticEvent): void; -}; - -/** - * @deprecated use AccessibleButton with `title` and `caption` instead. - */ -const AccessibleTooltipButton = forwardRef(function ( - { title, tooltip, children, forceHide, alignment, onHideTooltip, tooltipClassName, element, ...props }: Props, - ref: Ref, -) { - const [hover, setHover] = useState(false); - - useEffect(() => { - // If forceHide is set then force hover to off to hide the tooltip - if (forceHide && hover) { - setHover(false); - } - }, [forceHide, hover]); - - const showTooltip = (): void => { - props.onHover?.(true); - if (forceHide) return; - setHover(true); - }; - - const hideTooltip = (ev: SyntheticEvent): void => { - props.onHover?.(false); - setHover(false); - onHideTooltip?.(ev); - }; - - const onFocus = (ev: FocusEvent): void => { - // We only show the tooltip if focus arrived here from some other - // element, to avoid leaving tooltips hanging around when a modal closes - if (ev.relatedTarget) showTooltip(); - }; - - const tip = hover && (title || tooltip) && ( - - ); - return ( - - {children} - {props.label} - {(tooltip || title) && tip} - - ); -}); - -export default AccessibleTooltipButton; diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/elements/AppPermission.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/elements/AppPermission.tsx index 362863fc3a..fd788290c0 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/elements/AppPermission.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/elements/AppPermission.tsx @@ -18,6 +18,7 @@ limitations under the License. import React from "react"; import { RoomMember } from "matrix-js-sdk/src/matrix"; +import { Tooltip } from "@vector-im/compound-web"; import { _t } from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; @@ -29,7 +30,6 @@ import Heading from "../typography/Heading"; import AccessibleButton from "./AccessibleButton"; import { parseUrl } from "../../../utils/UrlUtils"; import { Icon as HelpIcon } from "../../../../res/img/feather-customised/help-circle.svg"; -import TooltipTarget from "./TooltipTarget"; interface IProps { url: string; @@ -99,31 +99,27 @@ export default class AppPermission extends React.Component { ); - const warningTooltipText = ( -
- {_t("analytics|shared_data_heading")} -
    -
  • {_t("widget|shared_data_name")}
  • -
  • {_t("widget|shared_data_avatar")}
  • -
  • {_t("widget|shared_data_mxid")}
  • -
  • {_t("widget|shared_data_device_id")}
  • -
  • {_t("widget|shared_data_theme")}
  • -
  • {_t("widget|shared_data_lang")}
  • -
  • {_t("widget|shared_data_url", { brand })}
  • -
  • {_t("widget|shared_data_room_id")}
  • -
  • {_t("widget|shared_data_widget_id")}
  • -
-
- ); const warningTooltip = ( - +
  • {_t("widget|shared_data_name")}
  • +
  • {_t("widget|shared_data_avatar")}
  • +
  • {_t("widget|shared_data_mxid")}
  • +
  • {_t("widget|shared_data_device_id")}
  • +
  • {_t("widget|shared_data_theme")}
  • +
  • {_t("widget|shared_data_lang")}
  • +
  • {_t("widget|shared_data_url", { brand })}
  • +
  • {_t("widget|shared_data_room_id")}
  • +
  • {_t("widget|shared_data_widget_id")}
  • + + } > - -
    +
    + +
    +
    ); // Due to i18n limitations, we can't dedupe the code for variables in these two messages. diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/elements/CopyableText.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/elements/CopyableText.tsx index 5d9946d2c1..994d81607b 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/elements/CopyableText.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/elements/CopyableText.tsx @@ -22,14 +22,14 @@ import { _t } from "../../../languageHandler"; import { copyPlaintext } from "../../../utils/strings"; import AccessibleButton, { ButtonEvent } from "./AccessibleButton"; -interface IProps { +interface IProps extends React.HTMLAttributes { children?: React.ReactNode; getTextToCopy: () => string | null; border?: boolean; className?: string; } -const CopyableText: React.FC = ({ children, getTextToCopy, border = true, className }) => { +const CopyableText: React.FC = ({ children, getTextToCopy, border = true, className, ...props }) => { const [tooltip, setTooltip] = useState(undefined); const onCopyClickInternal = async (e: ButtonEvent): Promise => { @@ -50,7 +50,7 @@ const CopyableText: React.FC = ({ children, getTextToCopy, border = true }); return ( -
    +
    {children} { return this.props.inputRef ?? this._inputRef; } + private onKeyDown = (evt: KeyboardEvent): void => { + // If the tooltip is displayed to show a feedback and Escape is pressed + // The tooltip is hided + if (this.state.feedbackVisible && evt.key === Key.ESCAPE) { + evt.preventDefault(); + evt.stopPropagation(); + this.setState({ + feedbackVisible: false, + }); + } + }; + public render(): React.ReactNode { /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ const { @@ -318,7 +338,7 @@ export default class Field extends React.PureComponent { }); return ( -
    +
    {prefixContainer} {fieldInput} diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/elements/RoomTopic.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/elements/RoomTopic.tsx index f926ef5cf4..9647188304 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/elements/RoomTopic.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/elements/RoomTopic.tsx @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useContext, useRef } from "react"; +import React, { useCallback, useContext, useState } from "react"; import { Room, EventType } from "matrix-js-sdk/src/matrix"; import classNames from "classnames"; +import { Tooltip } from "@vector-im/compound-web"; import { useTopic } from "../../../hooks/room/useTopic"; -import { Alignment } from "./Tooltip"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; @@ -28,7 +28,6 @@ import InfoDialog from "../dialogs/InfoDialog"; import { useDispatcher } from "../../../hooks/useDispatcher"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AccessibleButton from "./AccessibleButton"; -import TooltipTarget from "./TooltipTarget"; import { Linkify, topicToHtml } from "../../../HtmlUtils"; import { tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks"; @@ -49,10 +48,10 @@ export function onRoomTopicLinkClick(e: React.MouseEvent): void { export default function RoomTopic({ room, className, ...props }: IProps): JSX.Element { const client = useContext(MatrixClientContext); - const ref = useRef(null); + const [disableTooltip, setDisableTooltip] = useState(false); const topic = useTopic(room); - const body = topicToHtml(topic?.text, topic?.html, ref); + const body = topicToHtml(topic?.text, topic?.html); const onClick = useCallback( (e: React.MouseEvent) => { @@ -70,14 +69,14 @@ export default function RoomTopic({ room, className, ...props }: IProps): JSX.El [props], ); - const ignoreHover = (ev: React.MouseEvent): boolean => { - return (ev.target as HTMLElement).tagName.toUpperCase() === "A"; + const onHover = (ev: React.MouseEvent | React.FocusEvent): void => { + setDisableTooltip((ev.target as HTMLElement).tagName.toUpperCase() === "A"); }; useDispatcher(dis, (payload) => { if (payload.action === Action.ShowRoomTopic) { const canSetTopic = room.currentState.maySendStateEvent(EventType.RoomTopic, client.getSafeUserId()); - const body = topicToHtml(topic?.text, topic?.html, ref, true); + const body = topicToHtml(topic?.text, topic?.html, undefined, true); const modal = Modal.createDialog(InfoDialog, { title: room.name, @@ -115,18 +114,24 @@ export default function RoomTopic({ room, className, ...props }: IProps): JSX.El } }); + // Do not render the tooltip if the topic is empty + // We still need to have a div for the header buttons to be displayed correctly + if (!body) return
    ; + return ( - - {body} - + +
    + {body} +
    +
    ); } diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/elements/Tooltip.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/elements/Tooltip.tsx index aafa28b59a..fdba5f6f5c 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/elements/Tooltip.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/elements/Tooltip.tsx @@ -57,6 +57,9 @@ export interface ITooltipProps { type State = Partial>; +/** + * @deprecated Use [compound tooltip](https://element-hq.github.io/compound-web/?path=/docs/tooltip--docs) instead + */ export default class Tooltip extends React.PureComponent { private static container: HTMLElement; private parent: Element | null = null; diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/elements/TooltipTarget.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/elements/TooltipTarget.tsx deleted file mode 100644 index 89de915b45..0000000000 --- a/linked-dependencies/matrix-react-sdk/src/components/views/elements/TooltipTarget.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* -Copyright 2021 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { forwardRef, HTMLAttributes, useRef } from "react"; -import { randomString } from "matrix-js-sdk/src/randomstring"; - -import useFocus from "../../../hooks/useFocus"; -import useHover from "../../../hooks/useHover"; -import Tooltip, { ITooltipProps } from "./Tooltip"; - -interface IProps - extends HTMLAttributes, - Omit { - tooltipTargetClassName?: string; - ignoreHover?: (ev: React.MouseEvent) => boolean; -} - -/** - * Generic tooltip target element that handles tooltip visibility state - * and displays children - */ -const TooltipTarget = forwardRef( - ( - { - children, - tooltipTargetClassName, - // tooltip pass through props - className, - id, - label, - alignment, - tooltipClassName, - maxParentWidth, - ignoreHover, - ...rest - }, - ref, - ) => { - const idRef = useRef("mx_TooltipTarget_" + randomString(8)); - // Use generated ID if one is not passed - if (id === undefined) { - id = idRef.current; - } - - const [isFocused, focusProps] = useFocus(); - const [isHovering, hoverProps] = useHover(ignoreHover || (() => false)); - - // No need to fill up the DOM with hidden tooltip elements. Only add the - // tooltip when we're hovering over the item (performance) - const tooltip = (isFocused || isHovering) && ( - - ); - - return ( -
    - {children} - {tooltip} -
    - ); - }, -); - -export default TooltipTarget; diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/messages/DownloadActionButton.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/messages/DownloadActionButton.tsx index 4105426bb5..457a79b8db 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/messages/DownloadActionButton.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/messages/DownloadActionButton.tsx @@ -20,7 +20,7 @@ import classNames from "classnames"; import { Icon as DownloadIcon } from "../../../../res/img/download.svg"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; -import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; import Spinner from "../elements/Spinner"; import { _t, _td, TranslationKey } from "../../../languageHandler"; import { FileDownloader } from "../../../utils/FileDownloader"; @@ -93,7 +93,7 @@ export default class DownloadActionButton extends React.PureComponent {spinner} - + ); } } diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/messages/MLocationBody.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/messages/MLocationBody.tsx index 29c1c97e1a..eedf5a6046 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/messages/MLocationBody.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/messages/MLocationBody.tsx @@ -17,6 +17,7 @@ limitations under the License. import React from "react"; import { MatrixEvent, ClientEvent, ClientEventHandlerMap } from "matrix-js-sdk/src/matrix"; import { randomString } from "matrix-js-sdk/src/randomstring"; +import { Tooltip } from "@vector-im/compound-web"; import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; @@ -27,8 +28,6 @@ import { isSelfLocation, } from "../../../utils/location"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import TooltipTarget from "../elements/TooltipTarget"; -import { Alignment } from "../elements/Tooltip"; import { SmartMarker, Map, LocationViewDialog } from "../location"; import { IBodyProps } from "./IBodyProps"; import { createReconnectedListener } from "../../../utils/connection"; @@ -126,7 +125,7 @@ export const LocationBodyFallbackContent: React.FC<{ event: MatrixEvent; error: interface LocationBodyContentProps { mxEvent: MatrixEvent; mapId: string; - tooltip?: string; + tooltip: string; onError: (error: Error) => void; onClick?: () => void; } @@ -156,13 +155,9 @@ export const LocationBodyContent: React.FC = ({ return (
    - {tooltip ? ( - - {mapElement} - - ) : ( - mapElement - )} + +
    {mapElement}
    +
    ); }; diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/messages/MessageActionBar.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/messages/MessageActionBar.tsx index 86c8fb127a..2c314d284e 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/messages/MessageActionBar.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/messages/MessageActionBar.tsx @@ -43,7 +43,7 @@ import ContextMenu, { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } fr import { isContentActionable, canEditContent, editEvent, canCancel } from "../../../utils/EventUtils"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import Toolbar from "../../../accessibility/Toolbar"; -import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; import MessageContextMenu from "../context_menus/MessageContextMenu"; import Resend from "../../../Resend"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -235,7 +235,7 @@ const ReplyInThreadButton: React.FC = ({ mxEvent }) => { const title = !hasARelation ? _t("action|reply_in_thread") : _t("threads|error_start_thread_existing_relation"); return ( - = ({ mxEvent }) => { placement="left" > - + ); }; @@ -391,7 +391,7 @@ export default class MessageActionBar extends React.PureComponent - , + , ); } const cancelSendingButton = ( - - + ); const threadTooltipButton = ; @@ -431,7 +431,7 @@ export default class MessageActionBar extends React.PureComponent - , + , ); // The delete button should appear last, so we can just drop it at the end @@ -458,7 +458,7 @@ export default class MessageActionBar extends React.PureComponent - , + , ); } // We hide the react button in search results as we don't show reactions in results @@ -515,7 +515,7 @@ export default class MessageActionBar extends React.PureComponent {this.props.isQuoteExpanded ? : } - , + , ); } diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/messages/ReactionsRowButton.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/messages/ReactionsRowButton.tsx index 2737212d33..1dbd1bd7bf 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/messages/ReactionsRowButton.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/messages/ReactionsRowButton.tsx @@ -44,20 +44,10 @@ export interface IProps { customReactionImagesEnabled?: boolean; } -interface IState { - tooltipRendered: boolean; - tooltipVisible: boolean; -} - -export default class ReactionsRowButton extends React.PureComponent { +export default class ReactionsRowButton extends React.PureComponent { public static contextType = MatrixClientContext; public context!: React.ContextType; - public state = { - tooltipRendered: false, - tooltipVisible: false, - }; - public onClick = (): void => { const { mxEvent, myReactionEvent, content } = this.props; if (myReactionEvent) { @@ -74,21 +64,6 @@ export default class ReactionsRowButton extends React.PureComponent { - this.setState({ - // To avoid littering the DOM with a tooltip for every reaction, - // only render it on first use. - tooltipRendered: true, - tooltipVisible: true, - }); - }; - - public onMouseLeave = (): void => { - this.setState({ - tooltipVisible: false, - }); - }; - public render(): React.ReactNode { const { mxEvent, content, count, reactionEvents, myReactionEvent } = this.props; @@ -97,19 +72,6 @@ export default class ReactionsRowButton extends React.PureComponent - ); - } - const room = this.context.getRoom(mxEvent.getRoomId()); let label: string | undefined; let customReactionName: string | undefined; @@ -156,20 +118,24 @@ export default class ReactionsRowButton extends React.PureComponent - {reactionContent} - - {tooltip} - + + {reactionContent} + + + ); } } diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/messages/ReactionsRowButtonTooltip.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/messages/ReactionsRowButtonTooltip.tsx index f2a3d26109..5b4db10ed6 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/messages/ReactionsRowButtonTooltip.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/messages/ReactionsRowButtonTooltip.tsx @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { PropsWithChildren } from "react"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { Tooltip } from "@vector-im/compound-web"; import { unicodeToShortcode } from "../../../HtmlUtils"; import { _t } from "../../../languageHandler"; import { formatList } from "../../../utils/FormattingUtils"; -import Tooltip from "../elements/Tooltip"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { REACTION_SHORTCODE_KEY } from "./ReactionsRow"; interface IProps { @@ -30,20 +30,18 @@ interface IProps { content: string; // A list of Matrix reaction events for this key reactionEvents: MatrixEvent[]; - visible: boolean; // Whether to render custom image reactions customReactionImagesEnabled?: boolean; } -export default class ReactionsRowButtonTooltip extends React.PureComponent { +export default class ReactionsRowButtonTooltip extends React.PureComponent> { public static contextType = MatrixClientContext; public context!: React.ContextType; public render(): React.ReactNode { - const { content, reactionEvents, mxEvent, visible } = this.props; + const { content, reactionEvents, mxEvent, children } = this.props; const room = this.context.getRoom(mxEvent.getRoomId()); - let tooltipLabel: JSX.Element | undefined; if (room) { const senders: string[] = []; let customReactionName: string | undefined; @@ -57,34 +55,16 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent - {_t( - "timeline|reactions|tooltip", - { - shortName, - }, - { - reactors: () => { - return
    {formatList(senders, 6)}
    ; - }, - reactedWith: (sub) => { - if (!shortName) { - return null; - } - return
    {sub}
    ; - }, - }, - )} -
    - ); - } + const formattedSenders = formatList(senders, 6); + const caption = shortName ? _t("timeline|reactions|tooltip_caption", { shortName }) : undefined; - let tooltip: JSX.Element | undefined; - if (tooltipLabel) { - tooltip = ; + return ( + + {children} + + ); } - return tooltip; + return children; } } diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/pips/WidgetPip.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/pips/WidgetPip.tsx index 2ba9e39e25..9bba2ccc53 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/pips/WidgetPip.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/pips/WidgetPip.tsx @@ -26,7 +26,7 @@ import WidgetStore from "../../../stores/WidgetStore"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; import Toolbar from "../../../accessibility/Toolbar"; -import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; import { Icon as BackIcon } from "../../../../res/img/element-icons/back.svg"; import { Icon as HangupIcon } from "../../../../res/img/element-icons/call/hangup.svg"; import { _t } from "../../../languageHandler"; @@ -125,14 +125,14 @@ export const WidgetPip: FC = ({ widgetId, room, viewingRoom, onStartMovin {(call !== null || WidgetType.JITSI.matches(widget?.type)) && ( - - + )}
    diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/right_panel/EncryptionPanel.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/right_panel/EncryptionPanel.tsx index d6e2ee4407..26ba841f71 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/right_panel/EncryptionPanel.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/right_panel/EncryptionPanel.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { VerificationPhase, VerificationRequest, VerificationRequestEvent } from "matrix-js-sdk/src/crypto-api"; import { RoomMember, User } from "matrix-js-sdk/src/matrix"; @@ -69,13 +69,17 @@ const EncryptionPanel: React.FC = (props: IProps) => { awaitPromise(); } }, [verificationRequestPromise]); + // Use a ref to track whether we are already showing the mismatch modal as state may not update fast enough + // if two change events are fired in quick succession like can happen with rust crypto. + const isShowingMismatchModal = useRef(false); const changeHandler = useCallback(() => { // handle transitions -> cancelled for mismatches which fire a modal instead of showing a card if ( - request && - request.phase === VerificationPhase.Cancelled && + !isShowingMismatchModal.current && + request?.phase === VerificationPhase.Cancelled && MISMATCHES.includes(request.cancellationCode ?? "") ) { + isShowingMismatchModal.current = true; Modal.createDialog(ErrorDialog, { headerImage: require("../../../../res/img/e2e/warning-deprecated.svg").default, title: _t("encryption|messages_not_secure|title"), diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/right_panel/UserInfo.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/right_panel/UserInfo.tsx index dbc6acb29b..d9839252f9 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/right_panel/UserInfo.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/right_panel/UserInfo.tsx @@ -43,7 +43,6 @@ import DMRoomMap from "../../../utils/DMRoomMap"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import SdkConfig from "../../../SdkConfig"; import MultiInviter from "../../../utils/MultiInviter"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import E2EIcon from "../rooms/E2EIcon"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { textualPowerLevel } from "../../../Roles"; @@ -1413,8 +1412,7 @@ const BasicUserInfo: React.FC<{ // We don't need a perfect check here, just something to pass as "probably not our homeserver". If // someone does figure out how to bypass this check the worst that happens is an error. - // FIXME this should be using cli instead of MatrixClientPeg.matrixClient - if (isSynapseAdmin && member.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) { + if (isSynapseAdmin && member.userId.endsWith(`:${cli.getDomain()}`)) { synapseDeactivateButton = ( ; @@ -57,8 +57,7 @@ export default class RoomProfileSettings extends React.Component if (!room) throw new Error(`Expected a room for ID: ${props.roomId}`); const avatarEvent = room.currentState.getStateEvents(EventType.RoomAvatar, ""); - let avatarUrl = avatarEvent?.getContent()["url"] ?? null; - if (avatarUrl) avatarUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(96); + const avatarUrl = avatarEvent?.getContent()["url"] ?? null; const topicEvent = room.currentState.getStateEvents(EventType.RoomTopic, ""); const topic = topicEvent && topicEvent.getContent() ? topicEvent.getContent()["topic"] : ""; @@ -71,8 +70,8 @@ export default class RoomProfileSettings extends React.Component originalDisplayName: name, displayName: name, originalAvatarUrl: avatarUrl, - avatarUrl: avatarUrl, avatarFile: null, + avatarRemovalPending: false, originalTopic: topic, topic: topic, profileFieldsTouched: {}, @@ -82,16 +81,23 @@ export default class RoomProfileSettings extends React.Component }; } - private uploadAvatar = (): void => { - this.avatarUpload.current?.click(); + private onAvatarChanged = (file: File): void => { + this.setState({ + avatarFile: file, + avatarRemovalPending: false, + profileFieldsTouched: { + ...this.state.profileFieldsTouched, + avatar: true, + }, + }); }; private removeAvatar = (): void => { // clear file upload field so same file can be selected if (this.avatarUpload.current) this.avatarUpload.current.value = ""; this.setState({ - avatarUrl: null, avatarFile: null, + avatarRemovalPending: true, profileFieldsTouched: { ...this.state.profileFieldsTouched, avatar: true, @@ -112,8 +118,8 @@ export default class RoomProfileSettings extends React.Component profileFieldsTouched: {}, displayName: this.state.originalDisplayName, topic: this.state.originalTopic, - avatarUrl: this.state.originalAvatarUrl, avatarFile: null, + avatarRemovalPending: false, }); }; @@ -138,11 +144,12 @@ export default class RoomProfileSettings extends React.Component if (this.state.avatarFile) { const { content_uri: uri } = await client.uploadContent(this.state.avatarFile); await client.sendStateEvent(this.props.roomId, EventType.RoomAvatar, { url: uri }, ""); - newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); - newState.originalAvatarUrl = newState.avatarUrl; + newState.originalAvatarUrl = uri; newState.avatarFile = null; - } else if (this.state.originalAvatarUrl !== this.state.avatarUrl) { + } else if (this.state.avatarRemovalPending) { await client.sendStateEvent(this.props.roomId, EventType.RoomAvatar, {}, ""); + newState.avatarRemovalPending = false; + newState.originalAvatarUrl = null; } if (this.state.originalTopic !== this.state.topic) { @@ -192,39 +199,11 @@ export default class RoomProfileSettings extends React.Component } }; - private onAvatarChanged = (e: React.ChangeEvent): void => { - if (!e.target.files || !e.target.files.length) { - this.setState({ - avatarUrl: this.state.originalAvatarUrl, - avatarFile: null, - profileFieldsTouched: { - ...this.state.profileFieldsTouched, - avatar: false, - }, - }); - return; - } - - const file = e.target.files[0]; - const reader = new FileReader(); - reader.onload = (ev) => { - this.setState({ - avatarUrl: String(ev.target?.result), - avatarFile: file, - profileFieldsTouched: { - ...this.state.profileFieldsTouched, - avatar: true, - }, - }); - }; - reader.readAsDataURL(file); - }; - public render(): React.ReactNode { let profileSettingsButtons; if (this.state.canSetName || this.state.canSetTopic || this.state.canSetAvatar) { profileSettingsButtons = ( -
    +
    ); } + const canRemove = this.state.profileFieldsTouched.avatar + ? Boolean(this.state.avatarFile) + : Boolean(this.state.originalAvatarUrl); + return ( -
    - -
    -
    + +
    +
    /> />
    {profileSettingsButtons} diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/rooms/EventTile.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/rooms/EventTile.tsx index 108e0d9d93..5682cce846 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/rooms/EventTile.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/rooms/EventTile.tsx @@ -1162,20 +1162,18 @@ export class UnwrappedEventTile extends React.Component const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); let msgOption: JSX.Element | undefined; - if (this.props.showReadReceipts) { - if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { - msgOption = ; - } else { - msgOption = ( - - ); - } + if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { + msgOption = ; + } else if (this.props.showReadReceipts) { + msgOption = ( + + ); } let replyChain: JSX.Element | undefined; diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx index c817222dab..bc41f20b22 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx @@ -16,7 +16,7 @@ limitations under the License. import React from "react"; -import { RovingAccessibleTooltipButton } from "../../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton } from "../../../../accessibility/RovingTabIndex"; import Toolbar from "../../../../accessibility/Toolbar"; import { _t } from "../../../../languageHandler"; import { Icon as LinkIcon } from "../../../../../res/img/element-icons/link.svg"; @@ -32,22 +32,22 @@ export function EventTileThreadToolbar({ }): JSX.Element { return ( - - - + - + ); } diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/rooms/ExtraTile.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/rooms/ExtraTile.tsx index 3bb3a21525..3e734651c0 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/rooms/ExtraTile.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/rooms/ExtraTile.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from "react"; import classNames from "classnames"; -import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; import NotificationBadge from "./NotificationBadge"; import { NotificationState } from "../../../stores/notifications/NotificationState"; import { ButtonEvent } from "../elements/AccessibleButton"; @@ -73,15 +73,15 @@ export default function ExtraTile({ ); if (isMinimized) nameContainer = null; - const Button = isMinimized ? RovingAccessibleTooltipButton : RovingAccessibleButton; return ( -
    - + ); } diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/rooms/MessageComposer.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/rooms/MessageComposer.tsx index 613701bf23..bb4b4c7245 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/rooms/MessageComposer.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/rooms/MessageComposer.tsx @@ -25,6 +25,7 @@ import { THREAD_RELATION_TYPE, } from "matrix-js-sdk/src/matrix"; import { Optional } from "matrix-events-sdk"; +import { Tooltip } from "@vector-im/compound-web"; import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -40,7 +41,6 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore"; import { RecordingState } from "../../../audio/VoiceRecording"; -import Tooltip, { Alignment } from "../elements/Tooltip"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import { E2EStatus } from "../../../utils/ShieldUtils"; import SendMessageComposer, { SendMessageComposer as SendMessageComposerClass } from "./SendMessageComposer"; @@ -110,7 +110,6 @@ interface IState { } export class MessageComposer extends React.Component { - private tooltipId = `mx_MessageComposer_${Math.random()}`; private dispatcherRef?: string; private messageComposerInput = createRef(); private voiceRecordingButton = createRef(); @@ -568,12 +567,9 @@ export class MessageComposer extends React.Component { } let recordingTooltip: JSX.Element | undefined; - if (this.state.recordingTimeLeftSeconds) { - const secondsLeft = Math.round(this.state.recordingTimeLeftSeconds); - recordingTooltip = ( - - ); - } + + const isTooltipOpen = Boolean(this.state.recordingTimeLeftSeconds); + const secondsLeft = this.state.recordingTimeLeftSeconds ? Math.round(this.state.recordingTimeLeftSeconds) : 0; const threadId = this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : null; @@ -599,68 +595,66 @@ export class MessageComposer extends React.Component { }); return ( -
    - {recordingTooltip} -
    - -
    - {e2eIcon} - {composer} -
    - {controls} - {canSendMessages && ( - { - setUpVoiceBroadcastPreRecording( - this.props.room, - MatrixClientPeg.safeGet(), - SdkContextClass.instance.voiceBroadcastPlaybacksStore, - SdkContextClass.instance.voiceBroadcastRecordingsStore, - SdkContextClass.instance.voiceBroadcastPreRecordingStore, - ); - this.toggleButtonMenu(); - }} - /> - )} - {showSendButton && ( - - )} + +
    + {recordingTooltip} +
    + +
    + {e2eIcon} + {composer} +
    + {controls} + {canSendMessages && ( + { + setUpVoiceBroadcastPreRecording( + this.props.room, + MatrixClientPeg.safeGet(), + SdkContextClass.instance.voiceBroadcastPlaybacksStore, + SdkContextClass.instance.voiceBroadcastRecordingsStore, + SdkContextClass.instance.voiceBroadcastPreRecordingStore, + ); + this.toggleButtonMenu(); + }} + /> + )} + {showSendButton && ( + + )} +
    -
    + ); } } diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/rooms/MessageComposerFormatBar.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/rooms/MessageComposerFormatBar.tsx index 5893540528..04406158ae 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/rooms/MessageComposerFormatBar.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/rooms/MessageComposerFormatBar.tsx @@ -18,7 +18,7 @@ import React, { createRef } from "react"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; -import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; import Toolbar from "../../../accessibility/Toolbar"; export enum Formatting { @@ -131,7 +131,7 @@ class FormatButton extends React.PureComponent { // element="button" and type="button" are necessary for the buttons to work on WebKit, // otherwise the text is deselected before onClick can ever be called return ( - it.roomMember?.name ?? it.userId); const tooltipText = readReceiptTooltip(tooltipMembers, maxAvatars); - const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({ - label: ( - <> -
    - {_t("timeline|read_receipt_title", { count: readReceipts.length })} -
    -
    {tooltipText}
    - - ), - alignment: Alignment.TopRight, - }); - // return early if there are no read receipts if (readReceipts.length === 0) { // We currently must include `mx_ReadReceiptGroup_container` in @@ -185,34 +172,35 @@ export function ReadReceiptGroup({ return (
    -
    - - {remText} - +
    + - {avatars} - - - {tooltip} - {contextMenu} -
    + {remText} + + {avatars} + +
    + {contextMenu} +
    +
    ); } @@ -222,60 +210,48 @@ interface ReadReceiptPersonProps extends IReadReceiptProps { onAfterClick?: () => void; } -function ReadReceiptPerson({ +// Export for testing +export function ReadReceiptPerson({ userId, roomMember, ts, isTwelveHour, onAfterClick, }: ReadReceiptPersonProps): JSX.Element { - const [{ showTooltip, hideTooltip }, tooltip] = useTooltip({ - alignment: Alignment.Top, - tooltipClassName: "mx_ReadReceiptGroup_person--tooltip", - label: ( - <> -
    {roomMember?.rawDisplayName ?? userId}
    -
    {userId}
    - - ), - }); - return ( - { - dis.dispatch({ - action: Action.ViewUser, - // XXX: We should be using a real member object and not assuming what the receiver wants. - // The ViewUser action leads to the RightPanelStore, and RightPanelStoreIPanelState defines the - // member property of IRightPanelCardState as `RoomMember | User`, so we’re fine for now, but we - // should definitely clean this up later - member: roomMember ?? ({ userId } as User), - push: false, - }); - onAfterClick?.(); - }} - onMouseOver={showTooltip} - onMouseLeave={hideTooltip} - onFocus={showTooltip} - onBlur={hideTooltip} - onWheel={hideTooltip} - > -
    @@ -284,10 +278,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
    @@ -531,10 +522,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
    diff --git a/linked-dependencies/matrix-react-sdk/test/components/structures/auth/ForgotPassword-test.tsx b/linked-dependencies/matrix-react-sdk/test/components/structures/auth/ForgotPassword-test.tsx index 6977fe9ec4..b43e6bea6a 100644 --- a/linked-dependencies/matrix-react-sdk/test/components/structures/auth/ForgotPassword-test.tsx +++ b/linked-dependencies/matrix-react-sdk/test/components/structures/auth/ForgotPassword-test.tsx @@ -302,10 +302,6 @@ describe("", () => { client_secret: expect.any(String), sid: testSid, }, - threepidCreds: { - client_secret: expect.any(String), - sid: testSid, - }, }, testPassword, false, @@ -334,10 +330,6 @@ describe("", () => { client_secret: expect.any(String), sid: testSid, }, - threepidCreds: { - client_secret: expect.any(String), - sid: testSid, - }, }, testPassword, false, @@ -430,10 +422,6 @@ describe("", () => { client_secret: expect.any(String), sid: testSid, }, - threepidCreds: { - client_secret: expect.any(String), - sid: testSid, - }, }, testPassword, true, diff --git a/linked-dependencies/matrix-react-sdk/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx b/linked-dependencies/matrix-react-sdk/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx new file mode 100644 index 0000000000..e6e3e1383e --- /dev/null +++ b/linked-dependencies/matrix-react-sdk/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from "react"; +import { render, screen, waitFor, act } from "@testing-library/react"; +import { AuthType } from "matrix-js-sdk/src/interactive-auth"; +import userEvent from "@testing-library/user-event"; + +import { EmailIdentityAuthEntry } from "../../../../src/components/views/auth/InteractiveAuthEntryComponents"; +import { createTestClient } from "../../../test-utils"; + +describe("", () => { + const renderIdentityAuth = () => { + const matrixClient = createTestClient(); + + return render( + , + ); + }; + + test("should render", () => { + const { container } = renderIdentityAuth(); + expect(container).toMatchSnapshot(); + }); + + test("should clear the requested state when the button tooltip is hidden", async () => { + renderIdentityAuth(); + + // After a click on the resend button, the button should display the resent label + screen.getByRole("button", { name: "Resend" }).click(); + await waitFor(() => expect(screen.queryByRole("button", { name: "Resent!" })).toBeInTheDocument()); + expect(screen.queryByRole("button", { name: "Resend" })).toBeNull(); + + const resentButton = screen.getByRole("button", { name: "Resent!" }); + // Hover briefly the button and wait for the tooltip to be displayed + await userEvent.hover(resentButton); + await waitFor(() => expect(screen.getByRole("tooltip", { name: "Resent!" })).toBeInTheDocument()); + + // On unhover, it should display again the resend button + await act(() => userEvent.unhover(resentButton)); + await waitFor(() => expect(screen.queryByRole("button", { name: "Resend" })).toBeInTheDocument()); + }); +}); diff --git a/linked-dependencies/matrix-react-sdk/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap b/linked-dependencies/matrix-react-sdk/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap new file mode 100644 index 0000000000..65f86a35d2 --- /dev/null +++ b/linked-dependencies/matrix-react-sdk/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render 1`] = ` +
    +
    +

    + + To create your account, open the link in the email we just sent to + + alice@example.xyz + + . + +

    +

    + + Did not receive it? +

    + +

    +
    +
    +`; diff --git a/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/RoomSettingsDialog-test.tsx b/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/RoomSettingsDialog-test.tsx index 9a5f9b6745..a94e35dc71 100644 --- a/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/RoomSettingsDialog-test.tsx +++ b/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/RoomSettingsDialog-test.tsx @@ -31,6 +31,7 @@ import RoomSettingsDialog from "../../../../src/components/views/dialogs/RoomSet import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { UIFeature } from "../../../../src/settings/UIFeature"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; describe("", () => { const userId = "@alice:server.org"; @@ -62,6 +63,11 @@ describe("", () => { }); jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false); + + const dmRoomMap = { + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap; + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); }); const getComponent = (onFinished = jest.fn(), propRoomId = roomId) => diff --git a/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/SpotlightDialog-test.tsx b/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/SpotlightDialog-test.tsx index 5bf1029bc9..f8fe3c00a7 100644 --- a/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/SpotlightDialog-test.tsx +++ b/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/SpotlightDialog-test.tsx @@ -82,8 +82,8 @@ function mockClient({ }: MockClientOptions = {}): MatrixClient { stubClient(); const cli = MatrixClientPeg.safeGet(); - MatrixClientPeg.getHomeserverName = jest.fn(() => homeserver); cli.getUserId = jest.fn(() => userId); + cli.getDomain = jest.fn(() => homeserver); cli.getHomeserverUrl = jest.fn(() => homeserver); cli.getThirdpartyProtocols = jest.fn(() => Promise.resolve(thirdPartyProtocols)); cli.publicRooms = jest.fn((options) => { diff --git a/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx b/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx index 1412074ed9..06b13f1df7 100644 --- a/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx +++ b/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx @@ -18,7 +18,7 @@ import { render, RenderResult, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import { mocked, MockedObject } from "jest-mock"; -import { CryptoApi, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; +import { Crypto, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { defer, IDeferred, sleep } from "matrix-js-sdk/src/utils"; import { BackupTrustInfo, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; @@ -35,7 +35,7 @@ import RestoreKeyBackupDialog from "../../../../../src/components/views/dialogs/ describe("CreateSecretStorageDialog", () => { let mockClient: MockedObject; - let mockCrypto: MockedObject; + let mockCrypto: MockedObject; beforeEach(() => { mockClient = getMockClientWithEventEmitter({ diff --git a/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx b/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx index 0436fb2bf2..c4a5ef1ee1 100644 --- a/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx +++ b/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/security/ExportE2eKeysDialog-test.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from "react"; import { screen, fireEvent, render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { CryptoApi, IMegolmSessionData } from "matrix-js-sdk/src/matrix"; +import { Crypto, IMegolmSessionData } from "matrix-js-sdk/src/matrix"; import * as MegolmExportEncryption from "../../../../../src/utils/MegolmExportEncryption"; import ExportE2eKeysDialog from "../../../../../src/async-components/views/dialogs/security/ExportE2eKeysDialog"; @@ -70,7 +70,7 @@ describe("ExportE2eKeysDialog", () => { cli.getCrypto = () => { return { exportRoomKeysAsJson, - } as unknown as CryptoApi; + } as unknown as Crypto.CryptoApi; }; // Mock the result of encrypting the sessions. If we don't do this, the diff --git a/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/security/ImportE2eKeysDialog-test.tsx b/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/security/ImportE2eKeysDialog-test.tsx index af7b85b0c2..f119966030 100644 --- a/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/security/ImportE2eKeysDialog-test.tsx +++ b/linked-dependencies/matrix-react-sdk/test/components/views/dialogs/security/ImportE2eKeysDialog-test.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from "react"; import { fireEvent, render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { CryptoApi } from "matrix-js-sdk/src/matrix"; +import { Crypto } from "matrix-js-sdk/src/matrix"; import ImportE2eKeysDialog from "../../../../../src/async-components/views/dialogs/security/ImportE2eKeysDialog"; import * as MegolmExportEncryption from "../../../../../src/utils/MegolmExportEncryption"; @@ -75,7 +75,7 @@ describe("ImportE2eKeysDialog", () => { cli.getCrypto = () => { return { importRoomKeysAsJson, - } as unknown as CryptoApi; + } as unknown as Crypto.CryptoApi; }; // Mock the result of decrypting the sessions, to avoid needing to diff --git a/linked-dependencies/matrix-react-sdk/test/components/views/elements/Field-test.tsx b/linked-dependencies/matrix-react-sdk/test/components/views/elements/Field-test.tsx index ce826282ac..7cb3074927 100644 --- a/linked-dependencies/matrix-react-sdk/test/components/views/elements/Field-test.tsx +++ b/linked-dependencies/matrix-react-sdk/test/components/views/elements/Field-test.tsx @@ -69,6 +69,10 @@ describe("Field", () => { // Expect 'alert' role expect(screen.queryByRole("alert")).toBeInTheDocument(); + + // Close the feedback is Escape is pressed + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(screen.queryByRole("alert")).toBeNull(); }); it("Should mark the feedback as status if valid", async () => { @@ -87,6 +91,10 @@ describe("Field", () => { // Expect 'status' role expect(screen.queryByRole("status")).toBeInTheDocument(); + + // Close the feedback is Escape is pressed + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(screen.queryByRole("status")).toBeNull(); }); it("Should mark the feedback as tooltip if custom tooltip set", async () => { @@ -106,6 +114,10 @@ describe("Field", () => { // Expect 'tooltip' role expect(screen.queryByRole("tooltip")).toBeInTheDocument(); + + // Close the feedback is Escape is pressed + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(screen.queryByRole("tooltip")).toBeNull(); }); }); }); diff --git a/linked-dependencies/matrix-react-sdk/test/components/views/elements/RoomTopic-test.tsx b/linked-dependencies/matrix-react-sdk/test/components/views/elements/RoomTopic-test.tsx index dc05779794..8e62bd641f 100644 --- a/linked-dependencies/matrix-react-sdk/test/components/views/elements/RoomTopic-test.tsx +++ b/linked-dependencies/matrix-react-sdk/test/components/views/elements/RoomTopic-test.tsx @@ -16,7 +16,8 @@ limitations under the License. import React from "react"; import { Room } from "matrix-js-sdk/src/matrix"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { mkEvent, stubClient } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; @@ -33,9 +34,12 @@ describe("", () => { window.location.href = originalHref; }); - function runClickTest(topic: string, clickText: string) { + /** + * Create a room with the given topic + * @param topic + */ + function createRoom(topic: string) { stubClient(); - const room = new Room("!pMBteVpcoJRdCJxDmn:matrix.org", MatrixClientPeg.safeGet(), "@alice:example.org"); const topicEvent = mkEvent({ type: "m.room.topic", @@ -45,11 +49,27 @@ describe("", () => { ts: 123, event: true, }); - room.addLiveEvents([topicEvent]); + return room; + } + + /** + * Create a room and render it + * @param topic + */ + const renderRoom = (topic: string) => { + const room = createRoom(topic); render(); + }; + /** + * Create a room and click on the given text + * @param topic + * @param clickText + */ + function runClickTest(topic: string, clickText: string) { + renderRoom(topic); fireEvent.click(screen.getByText(clickText)); } @@ -78,4 +98,18 @@ describe("", () => { expect(window.location.href).toEqual(expectedHref); expect(dis.fire).toHaveBeenCalledWith(Action.ShowRoomTopic); }); + + it("should open the tooltip when hovering a text", async () => { + const topic = "room topic"; + renderRoom(topic); + await userEvent.hover(screen.getByText(topic)); + await waitFor(() => expect(screen.getByRole("tooltip", { name: "Click to read topic" })).toBeInTheDocument()); + }); + + it("should not open the tooltip when hovering a link", async () => { + const topic = "https://matrix.org"; + renderRoom(topic); + await userEvent.hover(screen.getByText(topic)); + await waitFor(() => expect(screen.queryByRole("tooltip", { name: "Click to read topic" })).toBeNull()); + }); }); diff --git a/linked-dependencies/matrix-react-sdk/test/components/views/elements/TooltipTarget-test.tsx b/linked-dependencies/matrix-react-sdk/test/components/views/elements/TooltipTarget-test.tsx deleted file mode 100644 index 0823229a90..0000000000 --- a/linked-dependencies/matrix-react-sdk/test/components/views/elements/TooltipTarget-test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; -import { fireEvent, render } from "@testing-library/react"; - -import { Alignment } from "../../../../src/components/views/elements/Tooltip"; -import TooltipTarget from "../../../../src/components/views/elements/TooltipTarget"; - -describe("", () => { - const defaultProps = { - "tooltipTargetClassName": "test tooltipTargetClassName", - "className": "test className", - "tooltipClassName": "test tooltipClassName", - "label": "test label", - "alignment": Alignment.Left, - "id": "test id", - "data-testid": "test", - }; - - const getComponent = (props = {}) => { - const wrapper = render( - // wrap in element so renderIntoDocument can render functional component - - - child - - , - ); - return wrapper.getByTestId("test"); - }; - - const getVisibleTooltip = () => document.querySelector(".mx_Tooltip.mx_Tooltip_visible"); - - it("renders container", () => { - const component = getComponent(); - expect(component).toMatchSnapshot(); - expect(getVisibleTooltip()).toBeFalsy(); - }); - - const alignmentKeys = Object.keys(Alignment).filter((o: any) => isNaN(o)); - it.each(alignmentKeys)("displays %s aligned tooltip on mouseover", async (alignment: any) => { - const wrapper = getComponent({ alignment: Alignment[alignment] })!; - fireEvent.mouseOver(wrapper); - expect(getVisibleTooltip()).toMatchSnapshot(); - }); - - it("hides tooltip on mouseleave", () => { - const wrapper = getComponent()!; - fireEvent.mouseOver(wrapper); - expect(getVisibleTooltip()).toBeTruthy(); - fireEvent.mouseLeave(wrapper); - expect(getVisibleTooltip()).toBeFalsy(); - }); - - it("displays tooltip on focus", () => { - const wrapper = getComponent()!; - fireEvent.focus(wrapper); - expect(getVisibleTooltip()).toBeTruthy(); - }); - - it("hides tooltip on blur", async () => { - const wrapper = getComponent()!; - fireEvent.focus(wrapper); - expect(getVisibleTooltip()).toBeTruthy(); - fireEvent.blur(wrapper); - expect(getVisibleTooltip()).toBeFalsy(); - }); -}); diff --git a/linked-dependencies/matrix-react-sdk/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap b/linked-dependencies/matrix-react-sdk/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap index 4b84fa46c6..b344e3cd58 100644 --- a/linked-dependencies/matrix-react-sdk/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap +++ b/linked-dependencies/matrix-react-sdk/test/components/views/elements/__snapshots__/AppTile-test.tsx.snap @@ -116,7 +116,6 @@ exports[`AppTile for a pinned widget should render 1`] = ` Using this widget may share data
    displays Bottom aligned tooltip on mouseover 1`] = ` -